local _, ns = ...
local MythicTools = ns.MythicTools

function MythicTools:CSVCell(value)
    if value == nil then
        return ""
    end

    if type(value) == "boolean" then
        value = value and "true" or "false"
    else
        value = tostring(value)
    end

    value = value:gsub("\r\n", "\n"):gsub("\r", "\n")
    if value:find("[\",\n]") then
        value = "\"" .. value:gsub("\"", "\"\"") .. "\""
    end

    return value
end

function MythicTools:CSVRow(values, count)
    local cells = {}
    local limit = count or #values
    for index = 1, limit do
        cells[index] = self:CSVCell(values[index])
    end
    return table.concat(cells, ",")
end

local function EscapeJSONString(value)
    value = tostring(value or "")
    value = value:gsub("\\", "\\\\")
        :gsub("\"", "\\\"")
        :gsub("\n", "\\n")
        :gsub("\r", "\\r")
        :gsub("\t", "\\t")
    return "\"" .. value .. "\""
end

local function EncodeJSON(value, depth)
    depth = depth or 0
    if depth > 8 then
        return "null"
    end

    local valueType = type(value)
    if valueType == "nil" then
        return "null"
    elseif valueType == "boolean" then
        return value and "true" or "false"
    elseif valueType == "number" then
        return tostring(value)
    elseif valueType == "string" then
        return EscapeJSONString(value)
    elseif valueType ~= "table" then
        return EscapeJSONString(tostring(value))
    end

    local isArray = true
    local maxIndex = 0
    local count = 0
    for key in pairs(value) do
        count = count + 1
        if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then
            isArray = false
            break
        end
        if key > maxIndex then
            maxIndex = key
        end
    end
    if isArray and maxIndex ~= count then
        isArray = false
    end

    local parts = {}
    if isArray then
        for index = 1, maxIndex do
            parts[#parts + 1] = EncodeJSON(value[index], depth + 1)
        end
        return "[" .. table.concat(parts, ",") .. "]"
    end

    local keys = {}
    for key in pairs(value) do
        keys[#keys + 1] = tostring(key)
    end
    table.sort(keys)
    for _, key in ipairs(keys) do
        parts[#parts + 1] = EscapeJSONString(key) .. ":" .. EncodeJSON(value[key], depth + 1)
    end
    return "{" .. table.concat(parts, ",") .. "}"
end

local function DecodeJSON(text)
    if type(text) ~= "string" then
        return nil, "Import text is empty."
    end

    local index = 1
    local length = #text

    local function SkipWhitespace()
        while index <= length do
            local char = text:sub(index, index)
            if char ~= " " and char ~= "\n" and char ~= "\r" and char ~= "\t" then
                break
            end
            index = index + 1
        end
    end

    local function ParseString()
        if text:sub(index, index) ~= "\"" then
            return nil, "Expected string."
        end
        index = index + 1
        local parts = {}
        while index <= length do
            local char = text:sub(index, index)
            if char == "\"" then
                index = index + 1
                return table.concat(parts)
            elseif char == "\\" then
                local escaped = text:sub(index + 1, index + 1)
                if escaped == "\"" or escaped == "\\" or escaped == "/" then
                    parts[#parts + 1] = escaped
                    index = index + 2
                elseif escaped == "n" then
                    parts[#parts + 1] = "\n"
                    index = index + 2
                elseif escaped == "r" then
                    parts[#parts + 1] = "\r"
                    index = index + 2
                elseif escaped == "t" then
                    parts[#parts + 1] = "\t"
                    index = index + 2
                elseif escaped == "b" or escaped == "f" then
                    index = index + 2
                elseif escaped == "u" then
                    parts[#parts + 1] = "?"
                    index = index + 6
                else
                    return nil, "Invalid string escape."
                end
            else
                parts[#parts + 1] = char
                index = index + 1
            end
        end
        return nil, "Unterminated string."
    end

    local ParseValue

    local function ParseArray()
        local result = {}
        index = index + 1
        SkipWhitespace()
        if text:sub(index, index) == "]" then
            index = index + 1
            return result
        end
        while index <= length do
            local value, err = ParseValue()
            if err then return nil, err end
            result[#result + 1] = value
            SkipWhitespace()
            local char = text:sub(index, index)
            if char == "]" then
                index = index + 1
                return result
            elseif char ~= "," then
                return nil, "Expected comma or array end."
            end
            index = index + 1
        end
        return nil, "Unterminated array."
    end

    local function ParseObject()
        local result = {}
        index = index + 1
        SkipWhitespace()
        if text:sub(index, index) == "}" then
            index = index + 1
            return result
        end
        while index <= length do
            SkipWhitespace()
            local key, err = ParseString()
            if err then return nil, err end
            SkipWhitespace()
            if text:sub(index, index) ~= ":" then
                return nil, "Expected object key separator."
            end
            index = index + 1
            local value
            value, err = ParseValue()
            if err then return nil, err end
            result[key] = value
            SkipWhitespace()
            local char = text:sub(index, index)
            if char == "}" then
                index = index + 1
                return result
            elseif char ~= "," then
                return nil, "Expected comma or object end."
            end
            index = index + 1
        end
        return nil, "Unterminated object."
    end

    local function ParseNumber()
        local startIndex = index
        while index <= length and text:sub(index, index):match("[%d%+%-%e%E%.]") do
            index = index + 1
        end
        local value = tonumber(text:sub(startIndex, index - 1))
        if value == nil then
            return nil, "Invalid number."
        end
        return value
    end

    ParseValue = function()
        SkipWhitespace()
        local char = text:sub(index, index)
        if char == "\"" then
            return ParseString()
        elseif char == "{" then
            return ParseObject()
        elseif char == "[" then
            return ParseArray()
        elseif text:sub(index, index + 3) == "true" then
            index = index + 4
            return true
        elseif text:sub(index, index + 4) == "false" then
            index = index + 5
            return false
        elseif text:sub(index, index + 3) == "null" then
            index = index + 4
            return nil
        elseif char ~= "" then
            return ParseNumber()
        end
        return nil, "Unexpected end of JSON."
    end

    local value, err = ParseValue()
    if err then
        return nil, err
    end
    SkipWhitespace()
    if index <= length then
        return nil, "Unexpected text after JSON."
    end
    return value
end

local COMPACT_BACKUP_FORMAT = 2
local EXPORT_CHUNK_SIZE = 60000
local DeepCopy

local RUN_EXPORT_KEYS = {
    i = "runId",
    st = "startTime",
    et = "endTime",
    o = "ownerCharacter",
    d = "dungeonName",
    cm = "mapChallengeModeID",
    m = "mapID",
    s = "season",
    l = "level",
    r = "result",
    ot = "onTime",
    u = "keystoneUpgradeLevels",
    pr = "practiceRun",
    tm = "timeMS",
    tl = "timeLimitSeconds",
    td = "totalDeaths",
    dp = "deathPenaltySeconds",
    old = "oldOverallDungeonScore",
    new = "newOverallDungeonScore",
    ss = "statsSource",
    su = "statsUnavailable",
    sn = "statsNote",
    ro = "roster",
    tx = "textureFileID",
    bg = "backgroundTextureFileID",
}

local RUN_EXPORT_ORDER = {
    "i", "st", "et", "o", "d", "cm", "m", "s", "l", "r", "ot", "u", "pr",
    "tm", "tl", "td", "dp", "old", "new", "ss", "su", "sn", "ro", "tx", "bg",
}

local PLAYER_STAT_EXPORT_KEYS = {
    sh = "shortName",
    c = "classFilename",
    sp = "specID",
    si = "specIconID",
    spn = "specName",
    ro = "role",
    sc = "score",
    ps = "previousScore",
    il = "itemLevel",
    dmg = "damage",
    da = "damageActiveSeconds",
    heal = "healing",
    ha = "healingActiveSeconds",
    int = "interrupts",
    dis = "dispels",
    av = "avoidableDamage",
    de = "deaths",
    rt = "rating",
    loot = "loot",
}

local PLAYER_STAT_EXPORT_ORDER = {
    "sh", "c", "sp", "si", "spn", "ro", "sc", "ps", "il", "dmg", "da", "heal",
    "ha", "int", "dis", "av", "de", "rt", "loot",
}

local function IsEmptyTable(value)
    if type(value) ~= "table" then
        return false
    end
    return next(value) == nil
end

local function CompactMappedTable(source, keys, order)
    if type(source) ~= "table" then
        return nil
    end

    local compact = {}
    for _, compactKey in ipairs(order) do
        local sourceKey = keys[compactKey]
        local value = source[sourceKey]
        if value ~= nil and not IsEmptyTable(value) then
            compact[compactKey] = DeepCopy(value)
        end
    end
    return compact
end

local function ExpandMappedTable(source, keys)
    if type(source) ~= "table" then
        return {}
    end

    local expanded = {}
    for compactKey, sourceKey in pairs(keys) do
        local value = source[compactKey]
        if value ~= nil then
            expanded[sourceKey] = DeepCopy(value)
        end
    end
    return expanded
end

local function CompactPlayerStats(playerStats)
    if type(playerStats) ~= "table" then
        return nil
    end

    local compactStats = {}
    for fullName, stat in pairs(playerStats) do
        if type(stat) == "table" then
            compactStats[fullName] = CompactMappedTable(stat, PLAYER_STAT_EXPORT_KEYS, PLAYER_STAT_EXPORT_ORDER)
        end
    end
    return IsEmptyTable(compactStats) and nil or compactStats
end

local function ExpandPlayerStats(playerStats)
    if type(playerStats) ~= "table" then
        return {}
    end

    local expandedStats = {}
    for fullName, stat in pairs(playerStats) do
        if type(stat) == "table" then
            expandedStats[fullName] = ExpandMappedTable(stat, PLAYER_STAT_EXPORT_KEYS)
        end
    end
    return expandedStats
end

local function BuildExportChunks(payloadText)
    local chunks = {}
    payloadText = tostring(payloadText or "")
    local total = math.max(1, math.ceil(#payloadText / EXPORT_CHUNK_SIZE))
    for index = 1, total do
        local startIndex = (index - 1) * EXPORT_CHUNK_SIZE + 1
        chunks[index] = ("SMHCHUNK:%d:%d:%s"):format(index, total, payloadText:sub(startIndex, startIndex + EXPORT_CHUNK_SIZE - 1))
    end
    return chunks
end

local function ParseExportChunk(payloadText)
    if type(payloadText) ~= "string" then
        return nil
    end

    payloadText = payloadText:gsub("^%s+", ""):gsub("%s+$", "")
    local indexText, totalText, chunkText = payloadText:match("^SMHCHUNK:(%d+):(%d+):(.+)$")
    local index = tonumber(indexText)
    local total = tonumber(totalText)
    if not (index and total and chunkText and index >= 1 and index <= total) then
        return nil
    end
    return index, total, chunkText
end

function DeepCopy(value)
    if type(value) ~= "table" then
        return value
    end
    local copy = {}
    for key, child in pairs(value) do
        copy[DeepCopy(key)] = DeepCopy(child)
    end
    return copy
end

local function GetImportRunSignature(run)
    if type(run) ~= "table" then
        return nil
    end
    return table.concat({
        tostring(run.startTime or ""),
        tostring(run.endTime or ""),
        tostring(run.ownerCharacter or ""),
        tostring(run.dungeonName or ""),
        tostring(run.level or ""),
    }, "|")
end

local function GetExportRuns(owner, filtered)
    if filtered and owner.GetFilteredRuns then
        return owner:GetFilteredRuns()
    end

    local runs = {}
    for index, run in ipairs(owner.db and owner.db.runs or {}) do
        runs[index] = run
    end
    return runs
end

function MythicTools:BuildRawCSVExport(runs)
    local columns = {
        "run_id", "start_time", "start_datetime", "end_time", "end_datetime",
        "owner_character", "dungeon_name", "map_challenge_mode_id", "map_id",
        "season", "level", "result", "timed_upgrade", "time_ms",
        "time_limit_seconds", "total_deaths", "death_penalty_seconds",
        "old_overall_dungeon_score", "new_overall_dungeon_score",
        "stats_source", "stats_unavailable", "stats_note",
        "player_name", "player_short_name", "class", "spec_id", "spec_name",
        "role", "score", "previous_score", "item_level", "damage", "dps",
        "healing", "hps", "interrupts", "dispels", "avoidable_damage",
        "player_deaths", "rating", "loot",
    }
    local columnCount = #columns
    local rows = {self:CSVRow(columns, columnCount)}

    for _, run in ipairs(runs or (self.db and self.db.runs) or {}) do
        if type(run) == "table" then
            local roster = self:NormalizeRunRoster(run)
            local timedUpgrade = self:GetRunResult(run) == "timed" and tonumber(run.keystoneUpgradeLevels) or nil
            if timedUpgrade and timedUpgrade <= 0 then
                timedUpgrade = nil
            end

            if #roster == 0 then
                rows[#rows + 1] = self:CSVRow({
                    run.runId, run.startTime, self:FormatDateTime(run.startTime),
                    run.endTime, self:FormatDateTime(run.endTime), run.ownerCharacter,
                    run.dungeonName, run.mapChallengeModeID, run.mapID, run.season,
                    run.level, self:GetRunResult(run), timedUpgrade, run.timeMS,
                    run.timeLimitSeconds, run.totalDeaths, run.deathPenaltySeconds,
                    run.oldOverallDungeonScore, run.newOverallDungeonScore,
                    run.statsSource, run.statsUnavailable and true or false, run.statsNote,
                }, columnCount)
            else
                for _, fullName in ipairs(roster) do
                    local stat = run.playerStats and run.playerStats[fullName] or {}
                    local dps = stat.damage ~= nil and self:GetPlayerDPS(run, stat) or nil
                    local hps = stat.healing ~= nil and self:GetPlayerHPS(run, stat) or nil
                    rows[#rows + 1] = self:CSVRow({
                        run.runId, run.startTime, self:FormatDateTime(run.startTime),
                        run.endTime, self:FormatDateTime(run.endTime), run.ownerCharacter,
                        run.dungeonName, run.mapChallengeModeID, run.mapID, run.season,
                        run.level, self:GetRunResult(run), timedUpgrade, run.timeMS,
                        run.timeLimitSeconds, run.totalDeaths, run.deathPenaltySeconds,
                        run.oldOverallDungeonScore, run.newOverallDungeonScore,
                        run.statsSource, run.statsUnavailable and true or false, run.statsNote,
                        fullName, stat.shortName or self:GetShortName(fullName),
                        stat.classFilename, stat.specID, stat.specName, stat.role,
                        stat.score, stat.previousScore, stat.itemLevel, stat.damage, dps,
                        stat.healing, hps, stat.interrupts, stat.dispels,
                        stat.avoidableDamage, stat.deaths, stat.rating,
                        type(stat.loot) == "table" and table.concat(stat.loot, " | ") or nil,
                    }, columnCount)
                end
            end
        end
    end

    return table.concat(rows, "\n")
end

function MythicTools:BuildFilteredCSVExport()
    return self:BuildRawCSVExport(GetExportRuns(self, true))
end

function MythicTools:BuildFullJSONExport()
    return EncodeJSON({
        addon = "Sky Mythic History",
        version = self.version,
        exportedAt = time and time() or nil,
        nextRunId = self.db and self.db.nextRunId or nil,
        runs = self.db and self.db.runs or {},
        playerNotes = self.db and self.db.playerNotes or {},
        ownedCharacters = self.db and self.db.ownedCharacters or {},
    })
end

function MythicTools:BuildCompactBackupTable()
    local compactRuns = {}
    for index, run in ipairs(self.db and self.db.runs or {}) do
        local compactRun = CompactMappedTable(run, RUN_EXPORT_KEYS, RUN_EXPORT_ORDER) or {}
        compactRun.ps = CompactPlayerStats(run.playerStats)
        compactRuns[index] = compactRun
    end

    return {
        a = "SMH",
        f = COMPACT_BACKUP_FORMAT,
        v = self.version,
        t = time and time() or nil,
        n = self.db and self.db.nextRunId or nil,
        r = compactRuns,
        p = self.db and self.db.playerNotes or {},
        o = self.db and self.db.ownedCharacters or {},
    }
end

function MythicTools:BuildCompactBackupExport()
    return EncodeJSON(self:BuildCompactBackupTable())
end

function MythicTools:ExpandCompactBackupPayload(payload)
    if type(payload) ~= "table" or payload.a ~= "SMH" or tonumber(payload.f) ~= COMPACT_BACKUP_FORMAT then
        return payload
    end

    local runs = {}
    for index, compactRun in ipairs(payload.r or {}) do
        local run = ExpandMappedTable(compactRun, RUN_EXPORT_KEYS)
        run.playerStats = ExpandPlayerStats(compactRun.ps)
        runs[index] = run
    end

    return {
        addon = "Sky Mythic History",
        version = payload.v,
        exportedAt = payload.t,
        nextRunId = payload.n,
        runs = runs,
        playerNotes = payload.p or {},
        ownedCharacters = payload.o or {},
    }
end

function MythicTools:BuildMarkdownSummaryExport()
    local runs = GetExportRuns(self, true)
    local lines = {
        "# Sky Mythic History Export",
        "",
        ("Generated: %s"):format(self:FormatDateTime(time and time() or nil)),
        ("Runs: %d"):format(#runs),
        "",
        "| Dungeon | Level | Result | Time | Deaths | Date |",
        "|---|---:|---|---|---:|---|",
    }

    for _, run in ipairs(runs) do
        lines[#lines + 1] = ("| %s | +%d | %s | %s | %d | %s |"):format(
            tostring(run.dungeonName or "Unknown"):gsub("|", "\\|"),
            tonumber(run.level) or 0,
            tostring(self:GetRunResult(run) or "unknown"),
            self:FormatDurationMS(run.timeMS),
            tonumber(run.totalDeaths) or 0,
            self:FormatDateTime(run.endTime)
        )
    end

    return table.concat(lines, "\n")
end

function MythicTools:GetExportFormatOptions()
    return {
        {value = "raw_csv", label = "CSV: all runs"},
        {value = "filtered_csv", label = "CSV: current filters"},
    }
end

function MythicTools:NormalizeExportFormat(format)
    if format == "filtered_csv" then
        return "filtered_csv"
    end

    return "raw_csv"
end

function MythicTools:BuildExportPayload(format)
    if format == "backup" then
        local backupText = self:BuildCompactBackupExport()
        local info = {
            rawLength = #backupText,
        }
        if #backupText > EXPORT_CHUNK_SIZE then
            info.chunks = BuildExportChunks(backupText)
            return "Compact Backup Export", info.chunks[1], info
        end
        return "Compact Backup Export", backupText, info
    elseif format == "filtered_csv" then
        return "Filtered CSV Export", self:BuildFilteredCSVExport()
    elseif format == "legacy_json" or format == "json" then
        return "Legacy JSON Export", self:BuildFullJSONExport()
    elseif format == "markdown" then
        return "Markdown Summary Export", self:BuildMarkdownSummaryExport()
    end

    return "Raw CSV Export", self:BuildRawCSVExport()
end

function MythicTools:ImportBackupPayload(payloadText, mode)
    local chunkIndex, chunkTotal, chunkText = ParseExportChunk(payloadText)
    if chunkIndex then
        self.runtime = self.runtime or {}
        local importChunks = self.runtime.importChunks
        if not importChunks or importChunks.total ~= chunkTotal then
            importChunks = {
                total = chunkTotal,
                chunks = {},
                count = 0,
            }
            self.runtime.importChunks = importChunks
        end

        if not importChunks.chunks[chunkIndex] then
            importChunks.count = (importChunks.count or 0) + 1
        end
        importChunks.chunks[chunkIndex] = chunkText

        if importChunks.count < importChunks.total then
            return true, ("Imported backup part %d/%d. Paste the next part."):format(importChunks.count, importChunks.total)
        end

        local parts = {}
        for index = 1, importChunks.total do
            if not importChunks.chunks[index] then
                return true, ("Waiting for backup part %d/%d."):format(index, importChunks.total)
            end
            parts[index] = importChunks.chunks[index]
        end
        self.runtime.importChunks = nil
        payloadText = table.concat(parts)
    end

    local payload, err = DecodeJSON(payloadText)
    if not payload then
        return false, err or "Could not parse backup text."
    end
    payload = self:ExpandCompactBackupPayload(payload)
    if type(payload) ~= "table" or type(payload.runs) ~= "table" then
        return false, "This does not look like a Sky Mythic History backup."
    end

    mode = mode == "replace" and "replace" or "merge"
    self.db.runs = type(self.db.runs) == "table" and self.db.runs or {}
    self.db.playerNotes = type(self.db.playerNotes) == "table" and self.db.playerNotes or {}
    self.db.ownedCharacters = type(self.db.ownedCharacters) == "table" and self.db.ownedCharacters or {}

    local importedRuns = 0
    if mode == "replace" then
        self.db.runs = DeepCopy(payload.runs or {})
        importedRuns = #self.db.runs
        self.db.playerNotes = DeepCopy(payload.playerNotes or {})
        self.db.ownedCharacters = DeepCopy(payload.ownedCharacters or {})
    else
        local existing = {}
        for _, run in ipairs(self.db.runs) do
            local signature = GetImportRunSignature(run)
            if signature then
                existing[signature] = true
            end
        end

        for _, run in ipairs(payload.runs or {}) do
            if type(run) == "table" then
                local signature = GetImportRunSignature(run)
                if not signature or not existing[signature] then
                    local importedRun = DeepCopy(run)
                    importedRun.runId = self.db.nextRunId or 1
                    self.db.nextRunId = importedRun.runId + 1
                    table.insert(self.db.runs, importedRun)
                    importedRuns = importedRuns + 1
                    if signature then
                        existing[signature] = true
                    end
                end
            end
        end

        for fullName, note in pairs(payload.playerNotes or {}) do
            if type(note) == "string" and note ~= "" then
                self.db.playerNotes[fullName] = note
            end
        end
        for fullName, enabled in pairs(payload.ownedCharacters or {}) do
            if enabled then
                self.db.ownedCharacters[fullName] = true
            end
        end
    end

    local nextRunId = tonumber(payload.nextRunId) or tonumber(self.db.nextRunId) or 1
    for _, run in ipairs(self.db.runs or {}) do
        run.runId = tonumber(run.runId) or nextRunId
        nextRunId = math.max(nextRunId, run.runId + 1)
    end
    self.db.nextRunId = nextRunId

    table.sort(self.db.runs, function(left, right)
        return (tonumber(left and left.endTime) or 0) > (tonumber(right and right.endTime) or 0)
    end)

    if self.TrimRunsToLimit then
        self:TrimRunsToLimit()
    end
    if self.RebuildPlayerIndex then
        self:RebuildPlayerIndex()
    end
    if self.RefreshAllViews then
        self:RefreshAllViews()
    elseif self.RefreshUI then
        self:RefreshUI()
    end

    return true, ("Imported %d run%s."):format(importedRuns, importedRuns == 1 and "" or "s")
end
