-- ============================================================
-- FILE: Profiles.lua
-- PURPOSE: Full-settings export/import and per-character profile
--   snapshots. Lets players back up and restore their entire UI
--   layout as a single copy-pasteable string, and share layouts
--   between characters or via the Companion app.
-- HOW IT WORKS:
--   EXPORT: DeepCopy of MidnightUISettings → strip keys that match
--   DEFAULTS (delta-only, smaller string) → hand-rolled Serialize
--   (pure Lua, no load/loadstring) → Base64Encode → prefix
--   "MUI2:<version>:<payload>". movers are always included even
--   when they match defaults.
--   IMPORT: strip whitespace → detect format prefix (MUI2/MUI1/raw)
--   → Base64Decode → SafeDeserialize (hand-rolled recursive parser)
--   → ApplyProfileTable (MUI2: merge delta onto fresh defaults;
--   MUI1/raw: wholesale replace) → re-init all modules via Core.
--   Three deferred C_Timer.After calls (0.3s, 0.6s) re-apply
--   overlay positions after modules finish re-initializing.
--   PER-CHARACTER: SaveCurrentProfile() snapshots the current
--   settings with metadata (class, level, timestamps) under the
--   "Name-Realm" key in MidnightUISettings.Profiles. Called on
--   login and logout automatically.
--   COMPANION BRIDGE: on PLAYER_LOGIN, checks
--   MidnightUISettings.PendingProfileImport (written by the
--   Companion web app) and applies it if present.
-- DEPENDS ON:
--   MidnightUI_Settings.DEFAULTS          — for delta-export strip
--   MidnightUI_Settings.InitializeSettings— called after import
--   MidnightUI_Core.InitModules           — re-init on import
--   MidnightUI_RefreshAllUnitFrames       — refresh unit frames
--   MidnightUI_ApplyCooldownManagerSettings
--   MidnightUI_MoverRegistry              — StringToPoint + ApplyAll
--   MidnightUI_Diagnostics                — debug logging
--   MidnightUISettings                    (SavedVariable)
-- EXPORTS:
--   M.ExportProfileString()               → "MUI2:ver:base64" string
--   M.ImportProfileString(text)           → ok, err
--   M.ImportProfileFromKey(key)           → ok, err
--   M.SaveCurrentProfile(reason)
--   M.PruneStaleProfiles(maxAgeSeconds)   → number removed
--   M.GetProfileOptions(excludeKey)       → sorted list table
--   M.GetCharacterKey()                   → "Name-Realm" string
--   M.MarkWelcomeSeen() / M.HasSeenWelcome()
--   M.MarkWhatsNewSeen(ver) / M.HasSeenWhatsNew(ver)
-- OWNED SAVEDVARIABLES:
--   MidnightUISettings.Profiles           — char-keyed snapshots
--   MidnightUISettings.WelcomeSeen        — char-keyed bool flags
--   MidnightUISettings.WhatsNewSeen       — char-keyed version flags
--   MidnightUISettings.PendingProfileImport — Companion app bridge
-- EVENTS REGISTERED:
--   PLAYER_LOGIN  — apply PendingProfileImport, then SaveCurrentProfile
--   PLAYER_LOGOUT — SaveCurrentProfile("logout")
-- FRAMES CREATED: one anonymous event listener frame
-- GOTCHAS:
--   SafeDeserialize uses shared upvalue state (pos/str/len) inside a
--   do...end block. WoW is single-threaded so this is safe, but it
--   must never be called re-entrantly.
--   EXCLUDED_KEYS prevents the Profiles/WelcomeSeen/WhatsNewSeen
--   tables from being included in the exported payload (they are
--   preserved and restored separately in ApplyProfileTable).
--   MigrateOldPositions handles pre-MoverRegistry position arrays
--   from MUI1/raw imports, converting {point,rel,x,y} arrays to the
--   "ANCHOR,parent,REL,x,y" string format used by MoverRegistry.
-- ============================================================

local _G = _G
local CreateFrame = CreateFrame
local date = date
local time = time
local type = type
local tostring = tostring
local pairs = pairs
local ipairs = ipairs
local table = table
local strfind = string.find
local strsub = string.sub
local strgsub = string.gsub
local strbyte = string.byte
local strchar = string.char
local strmatch = string.match
local tconcat = table.concat
local math_huge = math.huge

local ADDON_VERSION = C_AddOns and C_AddOns.GetAddOnMetadata
    and C_AddOns.GetAddOnMetadata("MidnightUI", "Version") or "0.0.0"

local M = _G.MidnightUI_Profiles or {}
_G.MidnightUI_Profiles = M


-- ============================================================
-- SECTION: Module Init + Helpers
-- Standard module table pattern. ADDON_VERSION read from TOC via
-- C_AddOns.GetAddOnMetadata with a "0.0.0" fallback.
-- Log() routes through Diagnostics when available, falls back to
-- MidnightUI_Debug global.
-- SafeToString() wraps pcall around tostring and a concatenation
-- test — essential when dealing with potentially restricted/secret
-- values that would throw on normal tostring.
-- READS:  _G.MidnightUI_Diagnostics, _G.MidnightUI_Debug
-- WRITES: _G.MidnightUI_Profiles (module table)
-- FUNCTIONS:
--   Log(msg)             — debug log via Diagnostics or Debug global
--   SafeToString(value)  — taint-safe tostring with "[Restricted]" fallback
-- ============================================================
local function Log(msg)
    if _G.MidnightUI_Diagnostics and _G.MidnightUI_Diagnostics.LogDebugSource then
        _G.MidnightUI_Diagnostics.LogDebugSource("Profiles", msg)
        return
    end
    if _G.MidnightUI_Debug then
        _G.MidnightUI_Debug("[Profiles] " .. tostring(msg))
    end
end

local function SafeToString(value)
    if value == nil then return "nil" end
    local ok, s = pcall(tostring, value)
    if not ok then return "[Restricted]" end
    local ok2 = pcall(function() return s .. "" end)
    if not ok2 then return "[Restricted]" end
    return s
end


-- ============================================================
-- SECTION: Settings Store Guard + Character Key
-- EnsureStore() guarantees MidnightUISettings and its three
-- sub-tables (Profiles, WelcomeSeen, WhatsNewSeen) exist before
-- any profile operation touches them. Called at the top of every
-- public API function.
-- GetCharacterKey() returns the "Name-Realm" string used as the
-- stable per-character identifier across all profile storage.
-- READS:  _G.MidnightUISettings, UnitName, GetRealmName
-- WRITES: _G.MidnightUISettings (initializes missing sub-tables)
-- FUNCTIONS:
--   EnsureStore()       → MidnightUISettings (guaranteed non-nil)
--   M.GetCharacterKey() → "Name-Realm" string
-- ============================================================
local function EnsureStore()
    _G.MidnightUISettings = _G.MidnightUISettings or {}
    local s = _G.MidnightUISettings
    if not s.Profiles then s.Profiles = {} end
    if not s.WelcomeSeen then s.WelcomeSeen = {} end
    if not s.WhatsNewSeen then s.WhatsNewSeen = {} end
    return s
end


function M.GetCharacterKey()
    local name = (UnitName and UnitName("player")) or "Unknown"
    local realm = (GetRealmName and GetRealmName()) or "UnknownRealm"
    return name .. "-" .. realm
end


-- ============================================================
-- SECTION: Deep Copy + Serialization
-- EXCLUDED_KEYS: keys skipped by DeepCopy during export — prevents
-- profile/welcome/migration-flag state from leaking into the string.
-- DeepCopy(): full recursive table copy with cycle detection via
-- the `seen` table. Skips EXCLUDED_KEYS at every level.
-- Serialize(): pure-Lua value→string conversion. Handles numbers
-- (including NaN → "0", ±inf → "1/0"/"-1/0"), booleans, strings
-- (via %q), and tables (array portion first, then string keys).
-- Cycle-safe via the `stack` argument. Used by ExportProfileString.
-- READS:  (pure functions, no global reads)
-- WRITES: (pure functions, no global writes)
-- FUNCTIONS:
--   DeepCopy(src, seen)     → deep copy of src, excluding EXCLUDED_KEYS
--   Serialize(value, stack) → Lua-literal string representation
-- ============================================================
local EXCLUDED_KEYS = {
    Profiles = true, WelcomeSeen = true, WhatsNewSeen = true,
    _version = true, _moversMigrated = true, _wasReset = true,
    PendingReset = true, PendingResetReopenWelcome = true,
    PendingResetForceWelcome = true, PendingProfileImport = true,
}

local function DeepCopy(src, seen)
    if type(src) ~= "table" then return src end
    if not seen then seen = {} end
    if seen[src] then return nil end
    local dst = {}
    seen[src] = dst
    for k, v in pairs(src) do
        if not EXCLUDED_KEYS[k] then
            local ck = DeepCopy(k, seen)
            if ck ~= nil then
                dst[ck] = DeepCopy(v, seen)
            end
        end
    end
    return dst
end


local function Serialize(value, stack)
    local t = type(value)
    if t == "number" then
        if value ~= value then return "0" end
        if value == math_huge then return "1/0" end
        if value == -math_huge then return "-1/0" end
        return tostring(value)
    elseif t == "boolean" then
        return value and "true" or "false"
    elseif t == "string" then
        return string.format("%q", value)
    elseif t == "table" then
        stack = stack or {}
        if stack[value] then return "nil" end
        stack[value] = true
        local parts = {}
        local arrayLen = #value
        local arrayKeys = {}
        if arrayLen > 0 then
            for i, v in ipairs(value) do
                parts[#parts + 1] = Serialize(v, stack)
                arrayKeys[i] = true
            end
        end
        for k, v in pairs(value) do
            if not arrayKeys[k] then
                parts[#parts + 1] = "[" .. Serialize(k, stack) .. "]=" .. Serialize(v, stack)
            end
        end
        stack[value] = nil
        return "{" .. tconcat(parts, ",") .. "}"
    end
    return "nil"
end


-- ============================================================
-- SECTION: Safe Deserializer
-- Hand-rolled recursive descent parser. Replaces loadstring/load
-- (forbidden in WoW). Handles: strings (single/double quoted with
-- escape sequences including \NNN octal), numbers (integer, float,
-- scientific notation, ±inf), booleans, nil, and nested tables
-- (both array and hash, comma/semicolon separated, [expr]=val and
-- bare-ident=val key forms).
-- State is held in module-upvalue locals (pos/str/len) inside a
-- do...end block — safe because WoW is single-threaded, but
-- SafeDeserialize must not be called re-entrantly.
-- Returns: result table, nil  on success
--          nil, errString      on failure
-- READS:  (pure, no global state beyond shared upvalue locals)
-- WRITES: (pure, no global state)
-- FUNCTIONS:
--   SafeDeserialize(input) → table|nil, errString|nil
--   [upvalue helpers]: skipWhitespace, peek, consume, parseValue,
--     parseString, parseNumber, matchKeyword, parseTable
-- ============================================================
local SafeDeserialize

do
    local pos
    local str
    local len

    local function skipWhitespace()
        while pos <= len do
            local c = strbyte(str, pos)
            if c == 32 or c == 9 or c == 10 or c == 13 then
                pos = pos + 1
            else
                break
            end
        end
    end

    local function peek()
        skipWhitespace()
        if pos > len then return nil end
        return strbyte(str, pos)
    end

    local function consume(expected)
        skipWhitespace()
        if pos > len then return false end
        if strbyte(str, pos) == expected then
            pos = pos + 1
            return true
        end
        return false
    end

    local parseValue

    local function parseString()
        local quote = strbyte(str, pos)
        pos = pos + 1
        local parts = {}
        while pos <= len do
            local c = strbyte(str, pos)
            if c == quote then
                pos = pos + 1
                return tconcat(parts)
            elseif c == 92 then
                pos = pos + 1
                if pos > len then return nil end
                local esc = strbyte(str, pos)
                pos = pos + 1
                if esc == 110 then parts[#parts + 1] = "\n"
                elseif esc == 116 then parts[#parts + 1] = "\t"
                elseif esc == 114 then parts[#parts + 1] = "\r"
                elseif esc == 92 then parts[#parts + 1] = "\\"
                elseif esc == quote then parts[#parts + 1] = strchar(quote)
                elseif esc == 48 then
                    local numStr = "0"
                    for _ = 1, 2 do
                        if pos <= len then
                            local d = strbyte(str, pos)
                            if d >= 48 and d <= 57 then
                                numStr = numStr .. strchar(d)
                                pos = pos + 1
                            else
                                break
                            end
                        end
                    end
                    parts[#parts + 1] = strchar(tonumber(numStr))
                elseif esc >= 48 and esc <= 57 then
                    local numStr = strchar(esc)
                    for _ = 1, 2 do
                        if pos <= len then
                            local d = strbyte(str, pos)
                            if d >= 48 and d <= 57 then
                                numStr = numStr .. strchar(d)
                                pos = pos + 1
                            else
                                break
                            end
                        end
                    end
                    local code = tonumber(numStr)
                    if code and code <= 255 then
                        parts[#parts + 1] = strchar(code)
                    end
                else
                    parts[#parts + 1] = strchar(esc)
                end
            else
                parts[#parts + 1] = strchar(c)
                pos = pos + 1
            end
        end
        return nil
    end

    local function parseNumber()
        local startPos = pos
        if strbyte(str, pos) == 45 then pos = pos + 1 end
        local c = strbyte(str, pos)
        if not c then return nil end
        if not ((c >= 48 and c <= 57) or c == 46) then
            pos = startPos
            return nil
        end
        while pos <= len do
            c = strbyte(str, pos)
            if (c >= 48 and c <= 57) or c == 46 or c == 101 or c == 69 or c == 43 or c == 45 then
                pos = pos + 1
            else
                break
            end
        end
        local numStr = strsub(str, startPos, pos - 1)
        local val = tonumber(numStr)
        if not val then
            pos = startPos
            return nil
        end
        return val
    end

    local function matchKeyword(keyword)
        local klen = #keyword
        if pos + klen - 1 > len then return false end
        if strsub(str, pos, pos + klen - 1) == keyword then
            if pos + klen <= len then
                local next_c = strbyte(str, pos + klen)
                if (next_c >= 48 and next_c <= 57) or (next_c >= 65 and next_c <= 90)
                    or (next_c >= 97 and next_c <= 122) or next_c == 95 then
                    return false
                end
            end
            pos = pos + klen
            return true
        end
        return false
    end

    local function parseTable()
        local result = {}
        local arrayIndex = 1

        while true do
            skipWhitespace()
            if pos > len then return nil end
            if strbyte(str, pos) == 125 then
                pos = pos + 1
                return result
            end

            if strbyte(str, pos) == 91 then
                pos = pos + 1
                local key = parseValue()
                if key == nil then return nil end
                skipWhitespace()
                if not consume(93) then return nil end
                skipWhitespace()
                if not consume(61) then return nil end
                local val = parseValue()
                result[key] = val
            else
                local savedPos = pos
                local ident = strmatch(str, "^([%a_][%w_]*)", pos)
                if ident then
                    local afterIdent = pos + #ident
                    local eqPos = afterIdent
                    while eqPos <= len do
                        local ec = strbyte(str, eqPos)
                        if ec == 32 or ec == 9 or ec == 10 or ec == 13 then
                            eqPos = eqPos + 1
                        else
                            break
                        end
                    end
                    if eqPos <= len and strbyte(str, eqPos) == 61 then
                        pos = eqPos + 1
                        local val = parseValue()
                        result[ident] = val
                    else
                        pos = savedPos
                        local val = parseValue()
                        result[arrayIndex] = val
                        arrayIndex = arrayIndex + 1
                    end
                else
                    local val = parseValue()
                    if val == nil and strbyte(str, pos) == 125 then
                    else
                        result[arrayIndex] = val
                        arrayIndex = arrayIndex + 1
                    end
                end
            end

            skipWhitespace()
            if pos <= len then
                local sep = strbyte(str, pos)
                if sep == 44 or sep == 59 then
                    pos = pos + 1
                end
            end
        end
    end

    parseValue = function()
        skipWhitespace()
        if pos > len then return nil end

        local c = strbyte(str, pos)

        if c == 34 or c == 39 then
            return parseString()
        end

        if c == 123 then
            pos = pos + 1
            return parseTable()
        end

        if (c >= 48 and c <= 57) or c == 46 then
            return parseNumber()
        end
        if c == 45 then
            if strsub(str, pos, pos + 3) == "-1/0" then
                pos = pos + 4
                return -math_huge
            end
            return parseNumber()
        end

        if strsub(str, pos, pos + 2) == "1/0" then
            pos = pos + 3
            return math_huge
        end

        if matchKeyword("true") then return true end
        if matchKeyword("false") then return false end
        if matchKeyword("nil") then return nil end

        return nil
    end

    function SafeDeserialize(input)
        if type(input) ~= "string" or input == "" then
            return nil, "empty input"
        end
        str = input
        len = #str
        pos = 1

        local result = parseValue()

        skipWhitespace()
        if pos <= len then
            return nil, "unexpected data at position " .. pos
        end

        if type(result) ~= "table" then
            return nil, "top-level value is not a table"
        end

        return result
    end
end


-- ============================================================
-- SECTION: Base64 Codec
-- Pure-Lua Base64 encode/decode operating on raw byte strings.
-- Used to make the serialized profile string safe for copy-paste
-- in chat, macros, and text fields without special characters.
-- Non-base64 characters (including whitespace) are stripped before
-- decoding.
-- READS:  (pure functions)
-- WRITES: (pure functions)
-- FUNCTIONS:
--   Base64Encode(data) → base64 string (with = padding)
--   Base64Decode(data) → raw byte string
-- ============================================================
local B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"

local function Base64Encode(data)
    if not data or data == "" then return "" end
    return ((data:gsub(".", function(x)
        local r, b = "", x:byte()
        for i = 8, 1, -1 do
            r = r .. (b % 2^i - b % 2^(i-1) > 0 and "1" or "0")
        end
        return r
    end) .. "0000"):gsub("%d%d%d?%d?%d?%d?", function(x)
        if #x < 6 then return "" end
        local c = 0
        for i = 1, 6 do
            c = c + (x:sub(i,i) == "1" and 2^(6-i) or 0)
        end
        return B64:sub(c+1, c+1)
    end) .. ({ "", "==", "=" })[#data % 3 + 1])
end

local function Base64Decode(data)
    if not data or data == "" then return "" end
    data = data:gsub("[^" .. B64 .. "=]", "")
    return (data:gsub(".", function(x)
        if x == "=" then return "" end
        local r, f = "", (B64:find(x, 1, true) - 1)
        for i = 6, 1, -1 do
            r = r .. (f % 2^i - f % 2^(i-1) > 0 and "1" or "0")
        end
        return r
    end):gsub("%d%d%d?%d?%d?%d?%d?%d?", function(x)
        if #x ~= 8 then return "" end
        local c = 0
        for i = 1, 8 do
            c = c + (x:sub(i,i) == "1" and 2^(8-i) or 0)
        end
        return string.char(c)
    end))
end


-- ============================================================
-- SECTION: Export System
-- ALWAYS_EXPORT_KEYS: keys whose values are always included in the
-- export even if they match the defaults (currently just "movers").
-- RemoveMatchingDefaults(): recursively strips key/value pairs from
-- a snapshot that are identical to the DEFAULTS table, producing a
-- compact delta. Returns nil when the subtable is entirely default.
-- RecursiveMerge(): deep-merges `source` onto `target`, treating
-- array tables (those with integer keys) as atomic replacements.
-- GetDefaults(): returns MidnightUI_Settings.DEFAULTS (nil if not
-- yet loaded).
-- BuildExportTable(): DeepCopy settings → remove defaults → stamp
-- _exportVersion → ensure movers key exists → return delta table.
-- M.ExportProfileString(): BuildExportTable → Serialize → Base64 →
-- prefix "MUI2:<version>:<payload>".
-- READS:  MidnightUISettings, MidnightUI_Settings.DEFAULTS
-- WRITES: (pure functions, no mutations except temporary table ops)
-- FUNCTIONS:
--   RemoveMatchingDefaults(userTable, defaultsTable) → delta table|nil
--   RecursiveMerge(target, source)   — in-place merge
--   GetDefaults()                    → DEFAULTS table|nil
--   BuildExportTable()               → delta table
--   M.ExportProfileString()          → "MUI2:ver:b64" string
-- ============================================================
local ALWAYS_EXPORT_KEYS = {
    movers = true,
}

local function RemoveMatchingDefaults(userTable, defaultsTable)
    if type(userTable) ~= "table" then return userTable end
    if type(defaultsTable) ~= "table" then return userTable end

    local result = {}
    local hasContent = false

    for k, userVal in pairs(userTable) do
        local defaultVal = defaultsTable[k]

        if ALWAYS_EXPORT_KEYS[k] then
            result[k] = userVal
            if userVal ~= nil then hasContent = true end
        elseif type(userVal) == "table" and type(defaultVal) == "table" then
            local sub = RemoveMatchingDefaults(userVal, defaultVal)
            if sub ~= nil then
                result[k] = sub
                hasContent = true
            end
        elseif defaultVal == nil then
            result[k] = userVal
            hasContent = true
        elseif userVal ~= defaultVal then
            result[k] = userVal
            hasContent = true
        end
    end

    if not hasContent then return nil end
    return result
end

local function RecursiveMerge(target, source)
    if type(source) ~= "table" or type(target) ~= "table" then return end
    for k, v in pairs(source) do
        if type(v) == "table" then
            if v[1] ~= nil then
                target[k] = DeepCopy(v)
            elseif type(target[k]) == "table" then
                RecursiveMerge(target[k], v)
            else
                target[k] = DeepCopy(v)
            end
        else
            target[k] = v
        end
    end
end


local function GetDefaults()
    local S = _G.MidnightUI_Settings
    return S and S.DEFAULTS
end

local function BuildExportTable()
    if _G.MidnightUI_Settings and _G.MidnightUI_Settings.SyncKeybindsFromBlizzard then
        _G.MidnightUI_Settings.SyncKeybindsFromBlizzard()
    end
    local s = EnsureStore()
    local snapshot = DeepCopy(s)
    if not snapshot then return {} end

    local defaults = GetDefaults()
    if defaults then
        local delta = RemoveMatchingDefaults(snapshot, defaults)
        if delta then
            delta._exportVersion = ADDON_VERSION
            if not delta.movers then delta.movers = {} end
            return delta
        end
    end

    snapshot._exportVersion = ADDON_VERSION
    return snapshot
end

function M.ExportProfileString()
    local export = BuildExportTable()
    local serialized = Serialize(export)
    local encoded = Base64Encode(serialized)
    return "MUI2:" .. ADDON_VERSION .. ":" .. encoded
end

-- ============================================================
-- SECTION: Import System
-- DecodeProfileString(): detect format prefix, Base64Decode, parse
-- via SafeDeserialize, tag with _format/_sourceVersion.
--   MUI2:<ver>:<b64>   — delta import (merge onto defaults)
--   MUI1:<b64>         — legacy full import (wholesale replace)
--   {…}                — raw Lua table string (wholesale replace)
-- WipeTable(): clears all keys from a table (uses Blizzard's wipe()
-- when available, otherwise manual iteration).
-- MigrateOldPositions(): converts pre-MoverRegistry position arrays
-- from legacy MUI1/raw imports to the "ANCHOR,parent,REL,x,y"
-- string format used by MoverRegistry. Covers all named overlays
-- plus 8 action bars.
-- WriteMoverPositionsToSubtables(): reverse-maps movers strings back
-- into the old subtable position arrays (for modules that still read
-- the old path during import re-init).
-- ApplyProfileTable(): core import engine.
--   MUI2: fresh DeepCopy(defaults) + RecursiveMerge of delta.
--   MUI1/raw: wholesale WipeTable + copy + MigrateOldPositions.
--   Always preserves Profiles/WelcomeSeen/WhatsNewSeen.
--   Fires InitializeSettings → InitModules("PROFILE_IMPORT") →
--   RefreshAllUnitFrames → ApplyCooldownManagerSettings →
--   MoverRegistry.ApplyAllPositions (+ two deferred at 0.3s/0.6s).
-- READS:  MidnightUISettings, MidnightUI_Settings.DEFAULTS,
--         MidnightUI_MoverRegistry.StringToPoint
-- WRITES: MidnightUISettings (full wipe and rebuild on import)
-- FUNCTIONS:
--   DecodeProfileString(text)     → table|nil, err|nil
--   WipeTable(t)                  — clear all keys
--   MigrateOldPositions(data)     — legacy position arrays → movers strings
--   WriteMoverPositionsToSubtables(data) — movers → subtable positions
--   ApplyProfileTable(profileTable)      → ok, err
--   M.ImportProfileString(text)          → ok, err
--   M.ImportProfileFromKey(key)          → ok, err
-- ============================================================
local function DecodeProfileString(text)
    if not text or text == "" then return nil, "empty" end
    local trimmed = strgsub(text, "%s+", "")

    local mui2ver, mui2payload = strmatch(trimmed, "^MUI2:([^:]+):(.+)$")
    if mui2ver and mui2payload then
        local decoded = Base64Decode(mui2payload)
        if not decoded or decoded == "" then return nil, "decode_failed" end
        local tbl, err = SafeDeserialize(decoded)
        if not tbl then return nil, err or "parse_failed" end
        tbl._sourceVersion = mui2ver
        tbl._format = "MUI2"
        return tbl
    end

    if strsub(trimmed, 1, 5) == "MUI1:" then
        local payload = strsub(trimmed, 6)
        local decoded = Base64Decode(payload)
        if not decoded or decoded == "" then return nil, "decode_failed" end
        local tbl, err = SafeDeserialize(decoded)
        if not tbl then return nil, err or "parse_failed" end
        tbl._format = "MUI1"
        return tbl
    end

    if strsub(trimmed, 1, 1) == "{" then
        local tbl, err = SafeDeserialize(trimmed)
        if not tbl then return nil, err or "parse_failed" end
        tbl._format = "raw"
        return tbl
    end

    return nil, "unrecognized_format"
end

local function WipeTable(t)
    if not t then return end
    if wipe then wipe(t); return end
    for k in pairs(t) do t[k] = nil end
end

local function DebugPrint(msg)
    Log("[IMPORT] " .. tostring(msg))
end

local function MigrateOldPositions(data)
    if type(data) ~= "table" then return end
    if not data.movers then data.movers = {} end

    local MIGRATION_MAP = {
        { path = {"PlayerFrame", "position"}, key = "PlayerFrame" },
        { path = {"TargetFrame", "position"}, key = "TargetFrame" },
        { path = {"FocusFrame", "position"}, key = "FocusFrame" },
        { path = {"RaidFrames", "position"}, key = "RaidFrames" },
        { path = {"PartyFrames", "position"}, key = "PartyFrames" },
        { path = {"MainTankFrames", "position"}, key = "MainTankFrames" },
        { path = {"Messenger", "position"}, key = "Messenger" },
        { path = {"ConsumableBars", "position"}, key = "ConsumableBars" },
        { path = {"Minimap", "position"}, key = "Minimap" },
        { path = {"InterfaceMenu", "position"}, key = "InterfaceMenu" },
        { path = {"XPBar", "position"}, key = "XPBar" },
        { path = {"RepBar", "position"}, key = "RepBar" },
        { path = {"PetBar", "position"}, key = "PetBar" },
        { path = {"StanceBar", "position"}, key = "StanceBar" },
        { path = {"PetFrame", "position"}, key = "PetFrame" },
        { path = {"PlayerFrame", "auras", "position"}, key = "PlayerAuras" },
        { path = {"PlayerFrame", "debuffs", "position"}, key = "PlayerDebuffs" },
        { path = {"TargetFrame", "auras", "position"}, key = "TargetAuras" },
        { path = {"TargetFrame", "debuffs", "position"}, key = "TargetDebuffs" },
        { path = {"TargetFrame", "targetOfTarget", "position"}, key = "TargetOfTarget" },
        { path = {"Combat", "dispelTrackingPosition"}, key = "DispelTracking" },
        { path = {"CastBars", "player", "position"}, key = "CastBar_player" },
        { path = {"CastBars", "target", "position"}, key = "CastBar_target" },
        { path = {"CastBars", "focus", "position"}, key = "CastBar_focus" },
    }
    for i = 1, 8 do
        MIGRATION_MAP[#MIGRATION_MAP + 1] = {
            path = {"ActionBars", "bar" .. i, "position"},
            key = "ActionBar" .. i,
        }
    end

    for _, entry in ipairs(MIGRATION_MAP) do
        if not data.movers[entry.key] then
            local val = data
            for _, seg in ipairs(entry.path) do
                if type(val) == "table" then val = val[seg] else val = nil; break end
            end
            if type(val) == "table" and #val >= 4 then
                local posStr
                if #val >= 5 and type(val[4]) == "number" and type(val[5]) == "number" then
                    posStr = tostring(val[1]) .. "," .. tostring(val[2]) .. ","
                        .. tostring(val[3]) .. ","
                        .. string.format("%.2f", val[4]) .. ","
                        .. string.format("%.2f", val[5])
                else
                    posStr = tostring(val[1]) .. ",UIParent,"
                        .. tostring(val[2]) .. ","
                        .. string.format("%.2f", val[3]) .. ","
                        .. string.format("%.2f", val[4])
                end
                data.movers[entry.key] = posStr
            end
        end
    end
end

local function WriteMoverPositionsToSubtables(data)
    if type(data) ~= "table" or not data.movers then return end

    local MR = _G.MidnightUI_MoverRegistry
    if not MR or not MR.StringToPoint then return end

    local WRITE_MAP = {
        PlayerFrame    = { "PlayerFrame", "position" },
        TargetFrame    = { "TargetFrame", "position" },
        FocusFrame     = { "FocusFrame", "position" },
        PetFrame       = { "PetFrame", "position" },
        RaidFrames     = { "RaidFrames", "position" },
        PartyFrames    = { "PartyFrames", "position" },
        MainTankFrames = { "MainTankFrames", "position" },
        Messenger      = { "Messenger", "position" },
        ConsumableBars = { "ConsumableBars", "position" },
        Minimap        = { "Minimap", "position" },
        InterfaceMenu  = { "InterfaceMenu", "position" },
        PlayerAuras    = { "PlayerFrame", "auras", "position" },
        PlayerDebuffs  = { "PlayerFrame", "debuffs", "position" },
        TargetAuras    = { "TargetFrame", "auras", "position" },
        TargetDebuffs  = { "TargetFrame", "debuffs", "position" },
        TargetOfTarget = { "TargetFrame", "targetOfTarget", "position" },
        DispelTracking = { "Combat", "dispelTrackingPosition" },
    }
    for i = 1, 8 do
        WRITE_MAP["ActionBar" .. i] = { "ActionBars", "bar" .. i, "position" }
    end
    WRITE_MAP["PetBar"]    = { "PetBar", "position" }
    WRITE_MAP["StanceBar"] = { "StanceBar", "position" }

    for key, posStr in pairs(data.movers) do
        local mapping = WRITE_MAP[key]
        if mapping then
            local point, anchorName, relPoint, x, y = MR.StringToPoint(posStr)
            if point then
                local target = data
                for i = 1, #mapping - 1 do
                    local seg = mapping[i]
                    if not target[seg] then target[seg] = {} end
                    target = target[seg]
                end
                target[mapping[#mapping]] = { point, relPoint, x, y }
            end
        end
    end
end

local function ApplyProfileTable(profileTable)
    if type(profileTable) ~= "table" then return false, "invalid_table" end
    DebugPrint("--- ApplyProfileTable START ---")

    local s = EnsureStore()
    local preserved = {
        Profiles = s.Profiles,
        WelcomeSeen = s.WelcomeSeen,
        WhatsNewSeen = s.WhatsNewSeen,
    }

    local importFormat = profileTable._format or "unknown"
    local sourceVersion = profileTable._sourceVersion or profileTable._exportVersion or "unknown"
    DebugPrint("Format: " .. importFormat .. "  Source version: " .. tostring(sourceVersion))

    profileTable._format = nil
    profileTable._sourceVersion = nil
    profileTable._exportVersion = nil

    local defaults = GetDefaults()

    if importFormat == "MUI2" and defaults then
        DebugPrint("MUI2 delta import: merging onto fresh defaults")
        local fresh = DeepCopy(defaults)
        if not fresh then fresh = {} end
        if profileTable.movers then
            fresh.movers = DeepCopy(profileTable.movers)
            profileTable.movers = nil
        end
        RecursiveMerge(fresh, profileTable)
        WipeTable(s)
        for k, v in pairs(fresh) do s[k] = v end
    elseif importFormat == "MUI1" or importFormat == "raw" then
        DebugPrint("Legacy import: wholesale replace + position migration")
        WipeTable(s)
        for k, v in pairs(profileTable) do
            s[k] = DeepCopy(v)
        end
        MigrateOldPositions(s)
    else
        DebugPrint("Unknown format: wholesale replace")
        WipeTable(s)
        for k, v in pairs(profileTable) do
            s[k] = DeepCopy(v)
        end
    end

    s.Profiles = preserved.Profiles or {}
    s.WelcomeSeen = preserved.WelcomeSeen or {}
    s.WhatsNewSeen = preserved.WhatsNewSeen or {}

    s._version = ADDON_VERSION

    DebugPrint("Settings applied. Running InitializeSettings...")
    if _G.MidnightUI_Settings and _G.MidnightUI_Settings.InitializeSettings then
        _G.MidnightUI_Settings.InitializeSettings()
    end

    WriteMoverPositionsToSubtables(s)

    DebugPrint("Running InitModules(PROFILE_IMPORT)...")
    if _G.MidnightUI_Core and _G.MidnightUI_Core.InitModules then
        _G.MidnightUI_Core.InitModules("PROFILE_IMPORT")
    end

    if _G.MidnightUI_Settings and _G.MidnightUI_Settings.ApplyStoredKeybinds then
        _G.MidnightUI_Settings.ApplyStoredKeybinds()
    end

    DebugPrint("Running RefreshAllUnitFrames...")
    if _G.MidnightUI_RefreshAllUnitFrames then
        _G.MidnightUI_RefreshAllUnitFrames()
    end

    DebugPrint("Running ApplyCooldownManagerSettings...")
    if _G.MidnightUI_ApplyCooldownManagerSettings then
        _G.MidnightUI_ApplyCooldownManagerSettings()
    end

    DebugPrint("Applying positions...")
    if _G.MidnightUI_MoverRegistry and _G.MidnightUI_MoverRegistry.ApplyAllPositions then
        _G.MidnightUI_MoverRegistry.ApplyAllPositions()
    end

    if C_Timer and C_Timer.After then
        C_Timer.After(0.3, function()
            DebugPrint("Deferred position apply (0.3s)...")
            if _G.MidnightUI_MoverRegistry and _G.MidnightUI_MoverRegistry.ApplyAllPositions then
                _G.MidnightUI_MoverRegistry.ApplyAllPositions()
            end
        end)
        C_Timer.After(0.6, function()
            DebugPrint("Deferred position apply (0.6s)...")
            if _G.MidnightUI_MoverRegistry and _G.MidnightUI_MoverRegistry.ApplyAllPositions then
                _G.MidnightUI_MoverRegistry.ApplyAllPositions()
            end
        end)
    end

    DebugPrint("--- ApplyProfileTable DONE ---")
    return true
end

function M.ImportProfileString(text)
    local tbl, err = DecodeProfileString(text)
    if not tbl then
        Log("Import failed: " .. SafeToString(err))
        return false, err
    end
    local ok, applyErr = ApplyProfileTable(tbl)
    if not ok then
        Log("Import apply failed: " .. SafeToString(applyErr))
        return false, applyErr
    end
    M.SaveCurrentProfile("import")
    Log("Import successful")
    return true
end

function M.ImportProfileFromKey(key)
    local s = EnsureStore()
    local entry = s.Profiles and s.Profiles[key]
    if not entry or not entry.data then
        Log("Import missing entry for key=" .. SafeToString(key))
        return false, "missing_entry"
    end
    local ok, err = M.ImportProfileString(entry.data)
    if not ok then
        Log("Import from key failed: " .. SafeToString(err))
    end
    return ok, err
end


-- ============================================================
-- SECTION: Per-Character Profile Store
-- SaveCurrentProfile() snapshots the current settings via
-- ExportProfileString() and stores it under the "Name-Realm" key
-- in MidnightUISettings.Profiles with metadata (class, level,
-- reason, timestamps). Called on login/logout and after successful
-- import so the slot always reflects the latest state.
-- GetProfileOptions() returns a sorted list of all stored profiles
-- suitable for a dropdown UI. Excludes the current character's key
-- (to avoid offering "import yourself"). Sorted newest-first.
-- READS:  MidnightUISettings.Profiles, UnitName, GetRealmName,
--         UnitClass, UnitLevel, time, date
-- WRITES: MidnightUISettings.Profiles
-- FUNCTIONS:
--   M.SaveCurrentProfile(reason)     — upsert profile for current char
--   M.GetProfileOptions(excludeKey)  → sorted list of {key,label,createdAt}
-- ============================================================
function M.SaveCurrentProfile(reason)
    local s = EnsureStore()
    local key = M.GetCharacterKey()
    local name = (UnitName and UnitName("player")) or "Unknown"
    local realm = (GetRealmName and GetRealmName()) or "UnknownRealm"
    local classTag = (select(2, UnitClass and UnitClass("player"))) or nil
    local level = (UnitLevel and UnitLevel("player")) or nil
    local now = time()
    local entry = s.Profiles[key] or {}
    if not entry.createdAt then entry.createdAt = now end
    entry.updatedAt = now
    entry.name = name
    entry.realm = realm
    entry.classTag = classTag
    entry.level = level
    entry.reason = reason
    entry.data = M.ExportProfileString()
    s.Profiles[key] = entry
end

-- ============================================================
-- SECTION: Stale Profile Pruning
-- Removes per-character profile snapshots that have not been
-- updated in PROFILE_STALE_AGE seconds (default 180 days). The
-- current character's entry is always preserved. Called at
-- PLAYER_LOGIN after SaveCurrentProfile("login") so the fresh
-- entry is never a candidate for removal.
-- READS:  MidnightUISettings.Profiles
-- WRITES: MidnightUISettings.Profiles (removes stale keys)
-- FUNCTIONS:
--   M.PruneStaleProfiles(maxAgeSeconds)  → count removed
-- ============================================================
local PROFILE_STALE_AGE = 180 * 24 * 60 * 60

function M.PruneStaleProfiles(maxAgeSeconds)
    local s = EnsureStore()
    local maxAge = tonumber(maxAgeSeconds) or PROFILE_STALE_AGE
    local cutoff = time() - maxAge
    local currentKey = M.GetCharacterKey()
    local removed = 0
    for key, entry in pairs(s.Profiles or {}) do
        if key ~= currentKey and type(entry) == "table" then
            local touched = entry.updatedAt or entry.createdAt or 0
            if touched < cutoff then
                s.Profiles[key] = nil
                removed = removed + 1
            end
        end
    end
    if removed > 0 then
        Log("Pruned " .. tostring(removed) .. " stale profile(s) older than " .. tostring(math.floor(maxAge / 86400)) .. " days")
    end
    return removed
end

function M.GetProfileOptions(excludeKey)
    local s = EnsureStore()
    local list = {}
    for key, entry in pairs(s.Profiles or {}) do
        if key ~= excludeKey and entry and entry.data then
            local label = SafeToString(entry.name or key)
            if entry.realm then label = label .. " - " .. SafeToString(entry.realm) end
            local created = entry.createdAt and date("%b %d, %Y", entry.createdAt) or "Unknown"
            label = label .. "  Created " .. created
            list[#list + 1] = { key = key, label = label, createdAt = entry.createdAt or 0 }
        end
    end
    table.sort(list, function(a, b) return (a.createdAt or 0) > (b.createdAt or 0) end)
    return list
end


-- ============================================================
-- SECTION: Welcome + WhatsNew Seen Flags
-- Simple per-character boolean/version flags stored under the
-- "Name-Realm" key in MidnightUISettings.WelcomeSeen and
-- MidnightUISettings.WhatsNewSeen. Used by Welcome.lua to decide
-- whether to show the welcome panel or the "what's new" panel
-- on first login for each character and each version.
-- READS:  MidnightUISettings.WelcomeSeen, .WhatsNewSeen
-- WRITES: MidnightUISettings.WelcomeSeen, .WhatsNewSeen
-- FUNCTIONS:
--   M.MarkWelcomeSeen()              — mark welcome as seen for this char
--   M.HasSeenWelcome()               → bool
--   M.MarkWhatsNewSeen(version)      — mark specific version as seen
--   M.HasSeenWhatsNew(version)       → bool
-- ============================================================
function M.MarkWelcomeSeen()
    local s = EnsureStore()
    local key = M.GetCharacterKey()
    s.WelcomeSeen[key] = true
end

function M.HasSeenWelcome()
    local s = EnsureStore()
    local key = M.GetCharacterKey()
    return s.WelcomeSeen[key] == true
end

function M.MarkWhatsNewSeen(version)
    local s = EnsureStore()
    local key = M.GetCharacterKey()
    s.WhatsNewSeen[key] = version
end

function M.HasSeenWhatsNew(version)
    local s = EnsureStore()
    local key = M.GetCharacterKey()
    return s.WhatsNewSeen[key] == version
end


-- ============================================================
-- SECTION: Event Listener — Login/Logout Profile Save + Companion Bridge
-- PLAYER_LOGIN: check MidnightUISettings.PendingProfileImport (set by
-- the Companion web app); if present, apply and clear it. Then call
-- SaveCurrentProfile("login") to snapshot current settings.
-- PLAYER_LOGOUT: SaveCurrentProfile("logout") so the profile always
-- reflects the session's final state.
-- READS:  MidnightUISettings.PendingProfileImport
-- WRITES: MidnightUISettings.PendingProfileImport (clears it),
--         MidnightUISettings.Profiles (via SaveCurrentProfile)
-- FUNCTIONS:
--   OnProfileEvent(_, event) [upvalue] — PLAYER_LOGIN + PLAYER_LOGOUT handler
-- ============================================================
local function OnProfileEvent(_, event)
    if event == "PLAYER_LOGIN" then
        local pending = _G.MidnightUISettings and _G.MidnightUISettings.PendingProfileImport
        if type(pending) == "string" and (pending:sub(1, 5) == "MUI1:" or pending:sub(1, 5) == "MUI2:") then
            Log("Applying pending profile import from Companion")
            _G.MidnightUISettings.PendingProfileImport = nil
            local ok, err = M.ImportProfileString(pending)
            if not ok then
                Log("Pending profile import failed: " .. SafeToString(err))
            end
        end
        M.SaveCurrentProfile("login")
        M.PruneStaleProfiles()
    elseif event == "PLAYER_LOGOUT" then
        M.SaveCurrentProfile("logout")
    end
end

local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_LOGIN")
f:RegisterEvent("PLAYER_LOGOUT")
f:SetScript("OnEvent", OnProfileEvent)
