local _, addonTable = ...
local WipedQoL = addonTable.WipedQoL
local TRANSLATE = addonTable.TRANSLATE or function(msg) return msg end

local COMBAT_POTION_ITEMS = addonTable.COMBAT_POTION_ITEMS or {}
local DEFAULT_ITEM_ICON = 134400
local MAX_MACRO_BODY_LENGTH = 255
local requestedItemLoads = {}

local COMMANDS = {
    use = true,
    benutzen = true,
    cast = true,
}

local function NormalizeText(value)
    value = tostring(value or ""):lower()
    value = value:gsub("\r", "")
    value = value:gsub("|c%x%x%x%x%x%x%x%x", "")
    value = value:gsub("|r", "")
    value = value:gsub("%s+", " ")
    value = value:gsub("^%s+", ""):gsub("%s+$", "")
    return value
end

local function IsSecretValue(value)
    return issecretvalue and issecretvalue(value) == true
end

local function NotifyCombatPotionDataUpdated()
    if WipedQoL.db and WipedQoL.RefreshCombatPotionMacroPreview then
        WipedQoL:RefreshCombatPotionMacroPreview()
    end
    if WipedQoL.RefreshCombatPotionUpgradeDialog then
        WipedQoL:RefreshCombatPotionUpgradeDialog()
    end
end

local function ResolveCatalogItemData(item)
    if type(item) ~= "table" or not tonumber(item.id) then
        return
    end

    local itemID = tonumber(item.id)
    local name
    local icon

    if C_Item and C_Item.GetItemNameByID then
        local ok, value = pcall(C_Item.GetItemNameByID, itemID)
        if ok and not IsSecretValue(value) then
            name = value
        end
    end
    if C_Item and C_Item.GetItemIconByID then
        local ok, value = pcall(C_Item.GetItemIconByID, itemID)
        if ok and not IsSecretValue(value) then
            icon = value
        end
    end
    if (not name or not icon) and C_Item and C_Item.GetItemInfo then
        local ok, resolvedName, _, _, _, _, _, _, _, _, resolvedIcon =
            pcall(C_Item.GetItemInfo, itemID)
        if ok then
            if not name and not IsSecretValue(resolvedName) then
                name = resolvedName
            end
            if not icon and not IsSecretValue(resolvedIcon) then
                icon = resolvedIcon
            end
        end
    end

    if name then
        item.name = name
    end
    if icon then
        item.icon = icon
    end

    if item.name and item.icon then
        return
    end
    if requestedItemLoads[itemID] or not Item or not Item.CreateFromItemID then
        return
    end

    requestedItemLoads[itemID] = true
    local itemObject = Item:CreateFromItemID(itemID)
    itemObject:ContinueOnItemLoad(function()
        local loadedName = itemObject:GetItemName()
        local loadedIcon = itemObject:GetItemIcon()
        if loadedName and not IsSecretValue(loadedName) then
            item.name = loadedName
        end
        if loadedIcon and not IsSecretValue(loadedIcon) then
            item.icon = loadedIcon
        end
        NotifyCombatPotionDataUpdated()
    end)
end

local function EnsureCombatPotionCatalogData()
    for _, item in ipairs(COMBAT_POTION_ITEMS) do
        ResolveCatalogItemData(item)
    end
end

local function AddNameLookup(nameLookup, name, item)
    local normalized = NormalizeText(name)
    if normalized == "" then
        return
    end

    local entries = nameLookup[normalized]
    if not entries then
        entries = {}
        nameLookup[normalized] = entries
    end
    for _, existing in ipairs(entries) do
        if existing.id == item.id then
            return
        end
    end
    entries[#entries + 1] = item
end

local function GetCombatPotionLookups()
    local nameLookup = {}
    local idLookup = {}

    EnsureCombatPotionCatalogData()
    for _, item in ipairs(COMBAT_POTION_ITEMS) do
        item.id = tonumber(item.id)
        if item.id then
            idLookup[item.id] = item
        end

        if item.name then
            AddNameLookup(nameLookup, item.name, item)
        end
        for _, name in ipairs(item.legacyNames or {}) do
            AddNameLookup(nameLookup, name, item)
        end
    end

    return nameLookup, idLookup
end

local function GetCombatPotionTargetKey(item)
    if type(item) ~= "table" then
        return nil
    end
    return tostring(item.family or "") .. ":" .. tostring(item.form or "")
end

local function GetCombatPotionTargetIDs(item)
    local ids = {}
    local targetKey = GetCombatPotionTargetKey(item)
    if not targetKey then
        return ids
    end

    for _, candidate in ipairs(COMBAT_POTION_ITEMS) do
        if candidate.currentSeason == true
            and GetCombatPotionTargetKey(candidate) == targetKey
            and tonumber(candidate.id)
        then
            ids[#ids + 1] = tonumber(candidate.id)
        end
    end
    return ids
end

local function GetMacroPotionReference(itemID, idLookup)
    itemID = tonumber(itemID)
    local item = itemID and idLookup and idLookup[itemID]
    if item then
        ResolveCatalogItemData(item)
        if type(item.name) == "string" and item.name ~= "" then
            return item.name
        end
        if type(item.legacyNames) == "table"
            and type(item.legacyNames[1]) == "string"
            and item.legacyNames[1] ~= ""
        then
            return item.legacyNames[1]
        end
    end
    return string.format("Item %d", itemID or 0)
end

local function ExtractItemId(text)
    text = tostring(text or "")
    return tonumber(text:match("item:(%d+)"))
        or tonumber(text:match("^%s*(%d+)%s*$"))
end

local function ParseMacroCommandLine(line)
    local command, argument = tostring(line or ""):match("^%s*/([^%s]+)%s+(.+)$")
    if not command or not argument then
        return nil, nil
    end

    command = NormalizeText(command)
    if not COMMANDS[command] then
        return nil, nil
    end

    argument = argument:gsub("^%s+", ""):gsub("%s+$", "")

    return command, argument
end

local function ParseCommentedMacroCommandLine(line)
    local prefix, commandLine = tostring(line or ""):match("^(%s*)%-%-%s*(/.+)$")
    if not commandLine then
        return nil, nil, nil
    end

    local command, argument = ParseMacroCommandLine(commandLine)
    return command, argument, prefix .. commandLine
end

local function GetClauseItemText(clause)
    local rest = tostring(clause or "")
    while true do
        local whitespace, condition, remaining = rest:match("^(%s*)(%b[])(.*)$")
        if not condition then
            break
        end
        rest = remaining
    end
    return rest:gsub("^%s+", ""):gsub("%s+$", "")
end

local function CreateCombatPotionMatch(item, sourceText)
    return {
        id = item.id,
        candidateIDs = { item.id },
        name = item.name or sourceText or string.format("Item %d", item.id),
        icon = item.icon or DEFAULT_ITEM_ICON,
        sourceText = sourceText,
    }
end

local function CreateNamedCombatPotionMatch(entries, sourceText)
    local candidateIDs = {}
    local seen = {}
    local representative = entries and entries[1]
    for _, item in ipairs(entries or {}) do
        if item.id and not seen[item.id] then
            candidateIDs[#candidateIDs + 1] = item.id
            seen[item.id] = true
        end
    end

    return {
        candidateIDs = candidateIDs,
        name = representative and (representative.name or sourceText) or sourceText,
        icon = representative and (representative.icon or DEFAULT_ITEM_ICON) or DEFAULT_ITEM_ICON,
        sourceText = sourceText,
    }
end

local function FindCombatPotionMatches(argument, nameLookup, idLookup)
    local matches = {}
    local source = tostring(argument or "") .. ";"
    for clause in source:gmatch("(.-);") do
        local itemText = GetClauseItemText(clause)
        local itemID = ExtractItemId(itemText)
        local item = itemID and idLookup[itemID]
        if item then
            matches[#matches + 1] = CreateCombatPotionMatch(item, itemText)
        else
            local entries = nameLookup[NormalizeText(itemText)]
            if entries and #entries > 0 then
                matches[#matches + 1] = CreateNamedCombatPotionMatch(entries, itemText)
            end
        end
    end
    return matches
end

local function FindCombatPotionMatch(argument, nameLookup, idLookup)
    return FindCombatPotionMatches(argument, nameLookup, idLookup)[1]
end

local function MatchCombatPotionArgument(argument, nameLookup, idLookup)
    return FindCombatPotionMatch(argument, nameLookup, idLookup)
end

local function GetPotionMatchKey(match)
    if tonumber(match and match.id) then
        return "id:" .. tostring(tonumber(match.id))
    end

    local ids = {}
    for _, itemID in ipairs(match and match.candidateIDs or {}) do
        itemID = tonumber(itemID)
        if itemID then
            ids[#ids + 1] = itemID
        end
    end
    table.sort(ids)
    if #ids > 0 then
        return "candidates:" .. table.concat(ids, ",")
    end
    return "name:" .. NormalizeText(match and match.sourceText)
end

local function GetBodyFingerprint(body)
    return tostring(body or ""):gsub("\r", "")
end

local function AddMacroPreviewItem(
    items,
    scope,
    index,
    name,
    icon,
    body,
    command,
    argument,
    paused,
    potionMatches,
    potionOccurrences,
    presentPotionIDs
)
    items[#items + 1] = {
        scope = scope,
        index = index,
        name = name or TRANSLATE("Unnamed macro"),
        icon = icon,
        bodyFingerprint = GetBodyFingerprint(body),
        command = command,
        argument = argument,
        paused = paused == true,
        combatPotion = potionMatches[1],
        combatPotions = potionMatches,
        combatPotionOccurrences = potionOccurrences,
        presentPotionIDs = presentPotionIDs,
    }
end

local function ScanMacroRange(items, scope, firstIndex, lastIndex, nameLookup, idLookup)
    if not GetMacroInfo then
        return
    end

    for index = firstIndex, lastIndex do
        local name, icon, body = GetMacroInfo(index)
        if name and not body and GetMacroBody then
            body = GetMacroBody(index)
        end
        if body then
            local potionMatches = {}
            local potionOccurrences = {}
            local seenMatches = {}
            local firstCommand
            local firstArgument
            local hasActive = false
            local scanSource = tostring(body)
            if scanSource:sub(-1) ~= "\n" then
                scanSource = scanSource .. "\n"
            end
            local lineNumber = 0
            for line in scanSource:gmatch("([^\n]*)\n") do
                lineNumber = lineNumber + 1
                local command, argument = ParseMacroCommandLine(line)
                local lineMatches = command and FindCombatPotionMatches(argument, nameLookup, idLookup) or {}
                if #lineMatches > 0 then
                    firstCommand = firstCommand or command
                    firstArgument = firstArgument or argument
                    hasActive = true
                    for _, potionMatch in ipairs(lineMatches) do
                        potionMatch.lineNumber = lineNumber
                        potionMatch.lineText = line
                        potionMatch.paused = false
                        potionOccurrences[#potionOccurrences + 1] = potionMatch
                        local key = GetPotionMatchKey(potionMatch)
                        if not seenMatches[key] then
                            seenMatches[key] = true
                            potionMatches[#potionMatches + 1] = potionMatch
                        end
                    end
                end

                command, argument = ParseCommentedMacroCommandLine(line)
                lineMatches = command and FindCombatPotionMatches(argument, nameLookup, idLookup) or {}
                if #lineMatches > 0 then
                    firstCommand = firstCommand or command
                    firstArgument = firstArgument or argument
                    for _, potionMatch in ipairs(lineMatches) do
                        potionMatch.lineNumber = lineNumber
                        potionMatch.lineText = line
                        potionMatch.paused = true
                        potionOccurrences[#potionOccurrences + 1] = potionMatch
                        local key = GetPotionMatchKey(potionMatch)
                        if not seenMatches[key] then
                            seenMatches[key] = true
                            potionMatches[#potionMatches + 1] = potionMatch
                        end
                    end
                end
            end

            if #potionMatches > 0 then
                local presentPotionIDs = {}
                local seenPresentIDs = {}
                for itemIDText in tostring(body):lower():gmatch("item:(%d+)") do
                    local itemID = tonumber(itemIDText)
                    if itemID and idLookup[itemID] and not seenPresentIDs[itemID] then
                        seenPresentIDs[itemID] = true
                        presentPotionIDs[#presentPotionIDs + 1] = itemID
                    end
                end
                for _, potionMatch in ipairs(potionMatches) do
                    local itemID = tonumber(potionMatch.id)
                    if itemID and not seenPresentIDs[itemID] then
                        seenPresentIDs[itemID] = true
                        presentPotionIDs[#presentPotionIDs + 1] = itemID
                    end
                    for _, candidateItemID in ipairs(potionMatch.candidateIDs or {}) do
                        candidateItemID = tonumber(candidateItemID)
                        if candidateItemID
                            and idLookup[candidateItemID]
                            and not seenPresentIDs[candidateItemID]
                        then
                            seenPresentIDs[candidateItemID] = true
                            presentPotionIDs[#presentPotionIDs + 1] = candidateItemID
                        end
                    end
                end
                table.sort(presentPotionIDs)

                AddMacroPreviewItem(
                    items,
                    scope,
                    index,
                    name,
                    icon,
                    body,
                    firstCommand,
                    firstArgument,
                    not hasActive,
                    potionMatches,
                    potionOccurrences,
                    presentPotionIDs
                )
            end
        end
    end
end

local function EditMacroBody(index, body)
    if not EditMacro or not GetMacroInfo then
        return false
    end

    local name, icon, _, isLocal = GetMacroInfo(index)
    if not name then
        return false
    end

    EditMacro(index, name, icon, body, isLocal)
    return true
end

local function UpdateCombatPotionMacroBody(body, comment, nameLookup, idLookup)
    local changed = false
    local lines = {}
    local source = tostring(body or "")
    if source:sub(-1) ~= "\n" then
        source = source .. "\n"
    end

    for line in source:gmatch("([^\n]*)\n") do
        local newLine = line
        if comment then
            local command, argument = ParseMacroCommandLine(line)
            if command and MatchCombatPotionArgument(argument, nameLookup, idLookup) then
                newLine = "--" .. line
                changed = true
            end
        else
            local command, argument, uncommentedLine = ParseCommentedMacroCommandLine(line)
            if command and MatchCombatPotionArgument(argument, nameLookup, idLookup) then
                newLine = uncommentedLine
                changed = true
            end
        end

        lines[#lines + 1] = newLine
    end

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

local function BuildCandidateIDSet(potionMatch)
    local result = {}
    if type(potionMatch) ~= "table" then
        return result
    end
    if tonumber(potionMatch.id) then
        result[tonumber(potionMatch.id)] = true
    end
    for _, itemID in ipairs(potionMatch.candidateIDs or {}) do
        itemID = tonumber(itemID)
        if itemID then
            result[itemID] = true
        end
    end
    return result
end

local function CandidateIDSetsEqual(left, right)
    local leftSet = BuildCandidateIDSet(left)
    local rightSet = BuildCandidateIDSet(right)
    for itemID in pairs(leftSet) do
        if not rightSet[itemID] then
            return false
        end
    end
    for itemID in pairs(rightSet) do
        if not leftSet[itemID] then
            return false
        end
    end
    return next(leftSet) ~= nil
end

local function FindReplacementTarget(match, replacements)
    if type(match) ~= "table" then
        return nil
    end

    local matchID = tonumber(match.id)
    if matchID then
        for _, replacement in ipairs(replacements) do
            local source = replacement.sourcePotion
            if tonumber(source and source.id) == matchID then
                return replacement.targetItemID
            end
        end
    end

    if not matchID then
        for _, replacement in ipairs(replacements) do
            local source = replacement.sourcePotion
            if not tonumber(source and source.id) and CandidateIDSetsEqual(match, source) then
                return replacement.targetItemID
            end
        end
    end

    local matchCandidates = BuildCandidateIDSet(match)
    local uniqueTarget
    for _, replacement in ipairs(replacements) do
        local sourceID = tonumber(replacement.sourcePotion and replacement.sourcePotion.id)
        if sourceID and matchCandidates[sourceID] then
            if uniqueTarget and uniqueTarget ~= replacement.targetItemID then
                return nil
            end
            uniqueTarget = replacement.targetItemID
        end
    end
    return uniqueTarget
end

local function ReplacePotionClause(clause, replacements, nameLookup, idLookup)
    local conditionPrefix = ""
    local rest = tostring(clause or "")

    while true do
        local whitespace, condition, remaining = rest:match("^(%s*)(%b[])(.*)$")
        if not condition then
            break
        end
        conditionPrefix = conditionPrefix .. whitespace .. condition
        rest = remaining
    end

    local leading, itemText, trailing = rest:match("^(%s*)(.-)(%s*)$")
    local match = FindCombatPotionMatch(itemText, nameLookup, idLookup)
    local targetItemID = FindReplacementTarget(match, replacements)
    if not targetItemID then
        return clause, false
    end

    local replacement = conditionPrefix
        .. (leading or "")
        .. GetMacroPotionReference(targetItemID, idLookup)
        .. (trailing or "")
    return replacement, replacement ~= clause
end

local function ReplacePotionArgument(argument, replacements, nameLookup, idLookup)
    local parts = {}
    local changed = false
    local source = tostring(argument or "")
    local position = 1

    while true do
        local separatorStart, separatorEnd = source:find(";", position, true)
        local clauseEnd = separatorStart and (separatorStart - 1) or #source
        local clause = source:sub(position, clauseEnd)
        local replacement, clauseChanged =
            ReplacePotionClause(clause, replacements, nameLookup, idLookup)
        parts[#parts + 1] = replacement
        changed = changed or clauseChanged

        if not separatorStart then
            break
        end
        parts[#parts + 1] = ";"
        position = separatorEnd + 1
    end

    return table.concat(parts), changed
end

local function GetReplaceableLineParts(line)
    local prefix, command, spacing, argument =
        tostring(line or ""):match("^(%s*%-%-%s*/)([^%s]+)(%s+)(.+)$")
    if not command then
        prefix, command, spacing, argument =
            tostring(line or ""):match("^(%s*/)([^%s]+)(%s+)(.+)$")
    end
    if command and COMMANDS[NormalizeText(command)] then
        -- Always write the locale-neutral command when a potion reference changes.
        return prefix .. "use" .. spacing, argument, "command"
    end

    prefix, spacing, argument =
        tostring(line or ""):match("^(%s*#[Ss][Hh][Oo][Ww][Tt][Oo][Oo][Ll][Tt][Ii][Pp])(%s+)(.+)$")
    if not prefix then
        prefix, spacing, argument =
            tostring(line or ""):match("^(%s*%-%-%s*#[Ss][Hh][Oo][Ww][Tt][Oo][Oo][Ll][Tt][Ii][Pp])(%s+)(.+)$")
    end
    if prefix then
        return prefix .. spacing, argument, "tooltip"
    end
    return nil, nil, nil
end

local function ReplaceCombatPotionMacroBody(body, replacements, nameLookup, idLookup)
    if type(replacements) ~= "table" or #replacements == 0 then
        return tostring(body or ""), false
    end

    local changed = false
    local lines = {}
    local source = tostring(body or "")
    local hadTrailingNewline = source:sub(-1) == "\n"
    if not hadTrailingNewline then
        source = source .. "\n"
    end

    for line in source:gmatch("([^\n]*)\n") do
        local linePrefix, argument = GetReplaceableLineParts(line)
        local newLine = line
        if linePrefix and argument then
            local updatedArgument, argumentChanged =
                ReplacePotionArgument(argument, replacements, nameLookup, idLookup)
            if argumentChanged then
                newLine = linePrefix .. updatedArgument
                changed = true
            end
        end
        lines[#lines + 1] = newLine
    end

    local result = table.concat(lines, "\n")
    if hadTrailingNewline then
        result = result .. "\n"
    end
    return result, changed
end

local function ApplyCombatPotionMacroChanges(
    body,
    replacements,
    removedLineNumbers,
    additions,
    lastPotionLine,
    nameLookup,
    idLookup,
    lineOrder
)
    if type(lineOrder) == "table" then
        local replacementsByLine = {}
        for _, replacement in ipairs(replacements or {}) do
            local lineNumber = tonumber(replacement.lineNumber)
            if lineNumber then
                replacementsByLine[lineNumber] = replacementsByLine[lineNumber] or {}
                replacementsByLine[lineNumber][#replacementsByLine[lineNumber] + 1] = replacement
            end
        end

        local potionLineNumbers = {}
        for _, orderEntry in ipairs(lineOrder) do
            local lineNumber = tonumber(orderEntry.lineNumber)
            if lineNumber then
                potionLineNumbers[lineNumber] = true
            end
        end
        for lineNumber in pairs(removedLineNumbers or {}) do
            potionLineNumbers[tonumber(lineNumber)] = true
        end

        local changed = false
        local preparedLines = {}
        local source = tostring(body or ""):gsub("\r", "")
        local hadTrailingNewline = source:sub(-1) == "\n"
        if not hadTrailingNewline then
            source = source .. "\n"
        end

        local lineNumber = 0
        for line in source:gmatch("([^\n]*)\n") do
            lineNumber = lineNumber + 1
            local updatedLine = line
            local linePrefix, argument, lineType = GetReplaceableLineParts(line)
            local lineReplacements = replacementsByLine[lineNumber]
            if lineType == "tooltip" then
                lineReplacements = replacements
            end
            if lineReplacements and #lineReplacements > 0 and linePrefix and argument then
                local updatedArgument, argumentChanged =
                    ReplacePotionArgument(argument, lineReplacements, nameLookup, idLookup)
                if argumentChanged then
                    updatedLine = linePrefix .. updatedArgument
                    changed = true
                end
            end
            preparedLines[lineNumber] = updatedLine
        end

        local orderedLines = {}
        for _, orderEntry in ipairs(lineOrder) do
            local existingLineNumber = tonumber(orderEntry.lineNumber)
            local targetItemID = tonumber(orderEntry.targetItemID)
            if existingLineNumber then
                orderedLines[#orderedLines + 1] = preparedLines[existingLineNumber]
            elseif targetItemID then
                orderedLines[#orderedLines + 1] =
                    "/use " .. GetMacroPotionReference(targetItemID, idLookup)
            end
        end

        local output = {}
        local orderedIndex = 1
        for currentLineNumber, line in ipairs(preparedLines) do
            if potionLineNumbers[currentLineNumber] then
                if removedLineNumbers[currentLineNumber] then
                    changed = true
                else
                    local orderedLine = orderedLines[orderedIndex]
                    if orderedLine then
                        output[#output + 1] = orderedLine
                        changed = changed or orderedLine ~= line
                        orderedIndex = orderedIndex + 1
                    end
                end
            else
                output[#output + 1] = line
            end

            if tonumber(lastPotionLine) == currentLineNumber then
                while orderedIndex <= #orderedLines do
                    output[#output + 1] = orderedLines[orderedIndex]
                    orderedIndex = orderedIndex + 1
                    changed = true
                end
            end
        end

        while orderedIndex <= #orderedLines do
            output[#output + 1] = orderedLines[orderedIndex]
            orderedIndex = orderedIndex + 1
            changed = true
        end

        local result = table.concat(output, "\n")
        if hadTrailingNewline then
            result = result .. "\n"
        end
        return result, changed
    end

    local replacementsByLine = {}
    for _, replacement in ipairs(replacements or {}) do
        local lineNumber = tonumber(replacement.lineNumber)
        if lineNumber then
            replacementsByLine[lineNumber] = replacementsByLine[lineNumber] or {}
            replacementsByLine[lineNumber][#replacementsByLine[lineNumber] + 1] = replacement
        end
    end

    local changed = false
    local output = {}
    local source = tostring(body or ""):gsub("\r", "")
    local hadTrailingNewline = source:sub(-1) == "\n"
    if not hadTrailingNewline then
        source = source .. "\n"
    end

    local lineNumber = 0
    local insertedAdditions = false
    for line in source:gmatch("([^\n]*)\n") do
        lineNumber = lineNumber + 1
        if not removedLineNumbers[lineNumber] then
            local updatedLine = line
            local linePrefix, argument, lineType = GetReplaceableLineParts(line)
            local lineReplacements = replacementsByLine[lineNumber]
            if lineType == "tooltip" then
                lineReplacements = replacements
            end
            if lineReplacements and #lineReplacements > 0 then
                if linePrefix and argument then
                    local updatedArgument, argumentChanged =
                        ReplacePotionArgument(argument, lineReplacements, nameLookup, idLookup)
                    if argumentChanged then
                        updatedLine = linePrefix .. updatedArgument
                        changed = true
                    end
                end
            end
            output[#output + 1] = updatedLine
        else
            changed = true
        end

        if tonumber(lastPotionLine) == lineNumber then
            for _, targetItemID in ipairs(additions or {}) do
                output[#output + 1] =
                    "/use " .. GetMacroPotionReference(targetItemID, idLookup)
                changed = true
            end
            insertedAdditions = true
        end
    end

    if not insertedAdditions then
        for _, targetItemID in ipairs(additions or {}) do
            output[#output + 1] =
                "/use " .. GetMacroPotionReference(targetItemID, idLookup)
            changed = true
        end
    end

    local result = table.concat(output, "\n")
    if hadTrailingNewline then
        result = result .. "\n"
    end
    return result, changed
end

local function GetMacroBodyByIndex(index)
    if not GetMacroInfo then
        return nil
    end

    local name, _, body = GetMacroInfo(index)
    if name and not body and GetMacroBody then
        body = GetMacroBody(index)
    end

    return name, body
end

local function UpdateCombatPotionMacroLines(comment)
    if not GetMacroInfo then
        return 0
    end

    local nameLookup, idLookup = GetCombatPotionLookups()
    local numGlobal, numCharacter = 0, 0
    if GetNumMacros then
        numGlobal, numCharacter = GetNumMacros()
    end

    local maxGlobal = MAX_ACCOUNT_MACROS or 120
    local maxCharacter = MAX_CHARACTER_MACROS or 18
    local ranges = {
        { first = 1, last = math.min(tonumber(numGlobal) or 0, maxGlobal) },
        { first = maxGlobal + 1, last = maxGlobal + math.min(tonumber(numCharacter) or 0, maxCharacter) },
    }
    local changedMacros = 0

    for _, range in ipairs(ranges) do
        for index = range.first, range.last do
            local name, body = GetMacroBodyByIndex(index)

            if body then
                local updatedBody, changed = UpdateCombatPotionMacroBody(body, comment, nameLookup, idLookup)
                if name and changed and EditMacroBody(index, updatedBody) then
                    changedMacros = changedMacros + 1
                end
            end
        end
    end

    return changedMacros
end

function WipedQoL:GetCombatPotionMacroSettings()
    local modules = self.db and self.db.profile and self.db.profile.modules
    if not modules then
        return {}
    end

    if type(modules.combatPotionMacros) ~= "table" then
        modules.combatPotionMacros = {
            preview = {},
        }
    end

    local settings = modules.combatPotionMacros
    if type(settings.preview) ~= "table" then
        settings.preview = {}
    end

    return settings
end

local function GetCombatPotionQualityAtlas(item)
    if type(item) ~= "table" or not tonumber(item.id) then
        return nil
    end

    if C_TradeSkillUI and C_TradeSkillUI.GetItemCraftedQualityInfo then
        local ok, info = pcall(C_TradeSkillUI.GetItemCraftedQualityInfo, tonumber(item.id))
        if ok and type(info) == "table" and type(info.iconInventory) == "string" then
            return info.iconInventory
        end
    end

    local familyHasBronze = false
    for _, candidate in ipairs(COMBAT_POTION_ITEMS) do
        if candidate.family == item.family and candidate.quality == "bronze" then
            familyHasBronze = true
            break
        end
    end

    local fallbackTiers = familyHasBronze
        and { bronze = 1, silver = 2, gold = 3 }
        or { silver = 1, gold = 2 }
    local tier = fallbackTiers[item.quality]
    if tier then
        local prefix = familyHasBronze
            and "Professions-Icon-Quality-Tier"
            or "Professions-Icon-Quality-12-Tier"
        return prefix .. tostring(tier) .. "-Inv"
    end
    return nil
end

function WipedQoL:GetCombatPotionCatalog()
    EnsureCombatPotionCatalogData()

    local items = {}
    for _, item in ipairs(COMBAT_POTION_ITEMS) do
        if tonumber(item.id) then
            items[#items + 1] = {
                id = tonumber(item.id),
                name = item.name or (item.legacyNames and item.legacyNames[1])
                    or string.format("Item %d", tonumber(item.id)),
                icon = item.icon or DEFAULT_ITEM_ICON,
                currentSeason = item.currentSeason == true,
                family = item.family,
                form = item.form,
                quality = item.quality,
                qualityAtlas = GetCombatPotionQualityAtlas(item),
                count = C_Item and C_Item.GetItemCount
                    and (tonumber(C_Item.GetItemCount(item.id, false, false, false)) or 0)
                    or 0,
            }
        end
    end

    local formOrder = { normal = 1, fleeting = 2 }
    local qualityOrder = { silver = 1, gold = 2, bronze = 3, single = 4 }
    table.sort(items, function(left, right)
        if left.currentSeason ~= right.currentSeason then
            return left.currentSeason
        end
        local leftFamily = tostring(left.family or left.name or "")
        local rightFamily = tostring(right.family or right.name or "")
        if leftFamily ~= rightFamily then
            return leftFamily < rightFamily
        end
        local leftForm = formOrder[left.form] or 99
        local rightForm = formOrder[right.form] or 99
        if leftForm ~= rightForm then
            return leftForm < rightForm
        end
        local leftQuality = qualityOrder[left.quality] or 99
        local rightQuality = qualityOrder[right.quality] or 99
        if leftQuality ~= rightQuality then
            return leftQuality < rightQuality
        end
        if left.name ~= right.name then
            return left.name < right.name
        end
        return left.id < right.id
    end)

    return items
end

function WipedQoL:GetCombatPotionTargetCatalog()
    local catalog = self:GetCombatPotionCatalog()
    local groups = {}
    local groupsByKey = {}

    for _, item in ipairs(catalog) do
        if item.currentSeason then
            local key = GetCombatPotionTargetKey(item)
            local group = groupsByKey[key]
            if not group then
                group = {
                    id = item.id,
                    name = item.name,
                    icon = item.icon,
                    currentSeason = true,
                    family = item.family,
                    form = item.form,
                    count = 0,
                    variants = {},
                    variantIDs = {},
                }
                groupsByKey[key] = group
                groups[#groups + 1] = group
            end

            group.variants[#group.variants + 1] = item
            group.variantIDs[#group.variantIDs + 1] = item.id
            group.count = group.count + (tonumber(item.count) or 0)
        end
    end

    return groups
end

function WipedQoL:GetCombatPotionMacroPreviewItems()
    local settings = self:GetCombatPotionMacroSettings()
    return settings.preview or {}
end

function WipedQoL:ApplyCombatPotionMacroChanges(previewItem, changes)
    if type(previewItem) ~= "table" or type(changes) ~= "table" then
        return false, "invalid"
    end
    if self:IsInCombat() then
        return false, "combat"
    end

    local nameLookup, idLookup = GetCombatPotionLookups()
    local validLineNumbers = {}
    local lastPotionLine
    for _, occurrence in ipairs(previewItem.combatPotionOccurrences or {}) do
        local lineNumber = tonumber(occurrence.lineNumber)
        if lineNumber then
            validLineNumbers[lineNumber] = true
            lastPotionLine = math.max(lastPotionLine or lineNumber, lineNumber)
        end
    end

    local removedLineNumbers = {}
    for key, value in pairs(changes.removedLineNumbers or {}) do
        local lineNumber = value == true and tonumber(key) or tonumber(value)
        if lineNumber and validLineNumbers[lineNumber] then
            removedLineNumbers[lineNumber] = true
        end
    end

    local presentPotionIDs = {}
    for _, occurrence in ipairs(previewItem.combatPotionOccurrences or {}) do
        local lineNumber = tonumber(occurrence.lineNumber)
        if not (lineNumber and removedLineNumbers[lineNumber]) then
            local itemID = tonumber(occurrence.id)
            if itemID then
                presentPotionIDs[itemID] = true
            end
            for _, candidateItemID in ipairs(occurrence.candidateIDs or {}) do
                candidateItemID = tonumber(candidateItemID)
                if candidateItemID then
                    presentPotionIDs[candidateItemID] = true
                end
            end
        end
    end

    local selectedTargetIDs = {}
    local replacements = {}
    local additions = {}
    local function ValidateTarget(targetItemID)
        targetItemID = tonumber(targetItemID)
        local targetItem = targetItemID and idLookup[targetItemID]
        if not targetItem or targetItem.currentSeason ~= true then
            return nil, "invalidTarget"
        end

        for _, variantID in ipairs(GetCombatPotionTargetIDs(targetItem)) do
            if presentPotionIDs[variantID] then
                return nil, "targetAlreadyPresent"
            end
        end

        local targetKey = GetCombatPotionTargetKey(targetItem)
        if selectedTargetIDs[targetKey] then
            return nil, "duplicateTarget"
        end
        selectedTargetIDs[targetKey] = true
        return targetItemID
    end

    for _, replacement in ipairs(changes.replacements or {}) do
        local lineNumber = tonumber(replacement.lineNumber)
        if lineNumber and validLineNumbers[lineNumber] and not removedLineNumbers[lineNumber] then
            local targetItemID, reason =
                ValidateTarget(replacement.targetItemID or replacement.target)
            if not targetItemID then
                return false, reason
            end
            replacements[#replacements + 1] = {
                lineNumber = lineNumber,
                sourcePotion = replacement.sourcePotion or replacement.source,
                targetItemID = targetItemID,
            }
        end
    end

    for _, addition in ipairs(changes.additions or {}) do
        local targetItemID, reason = ValidateTarget(
            type(addition) == "table" and (addition.targetItemID or addition.target) or addition
        )
        if not targetItemID then
            return false, reason
        end
        additions[#additions + 1] = targetItemID
    end

    local lineOrder
    local orderChanged = false
    if type(changes.lineOrder) == "table" then
        local remainingLineNumbers = {}
        for lineNumber in pairs(validLineNumbers) do
            if not removedLineNumbers[lineNumber] then
                remainingLineNumbers[lineNumber] = true
            end
        end

        local additionIDs = {}
        for _, targetItemID in ipairs(additions) do
            additionIDs[targetItemID] = true
        end

        local seenLineNumbers = {}
        local seenAdditionIDs = {}
        lineOrder = {}
        for _, orderEntry in ipairs(changes.lineOrder) do
            local lineNumber = type(orderEntry) == "table" and tonumber(orderEntry.lineNumber)
            local targetItemID = type(orderEntry) == "table" and tonumber(orderEntry.targetItemID)
            if lineNumber and remainingLineNumbers[lineNumber] and not seenLineNumbers[lineNumber] then
                seenLineNumbers[lineNumber] = true
                lineOrder[#lineOrder + 1] = { lineNumber = lineNumber }
            elseif targetItemID and additionIDs[targetItemID] and not seenAdditionIDs[targetItemID] then
                seenAdditionIDs[targetItemID] = true
                lineOrder[#lineOrder + 1] = { targetItemID = targetItemID }
            else
                return false, "invalidOrder"
            end
        end

        for lineNumber in pairs(remainingLineNumbers) do
            if not seenLineNumbers[lineNumber] then
                return false, "invalidOrder"
            end
        end
        for targetItemID in pairs(additionIDs) do
            if not seenAdditionIDs[targetItemID] then
                return false, "invalidOrder"
            end
        end

        local defaultOrder = {}
        for lineNumber in pairs(remainingLineNumbers) do
            defaultOrder[#defaultOrder + 1] = { lineNumber = lineNumber }
        end
        table.sort(defaultOrder, function(left, right)
            return left.lineNumber < right.lineNumber
        end)
        for _, targetItemID in ipairs(additions) do
            defaultOrder[#defaultOrder + 1] = { targetItemID = targetItemID }
        end

        if #lineOrder ~= #defaultOrder then
            orderChanged = true
        else
            for index, orderEntry in ipairs(lineOrder) do
                local defaultEntry = defaultOrder[index]
                if orderEntry.lineNumber ~= defaultEntry.lineNumber
                    or orderEntry.targetItemID ~= defaultEntry.targetItemID
                then
                    orderChanged = true
                    break
                end
            end
        end
    end

    if #replacements == 0
        and not next(removedLineNumbers)
        and #additions == 0
        and not orderChanged
    then
        return false, "noSelection"
    end

    local macroIndex = self:ResolveCombatPotionMacroIndex(previewItem)
    if not macroIndex then
        return false, "macroChanged"
    end

    local _, body = GetMacroBodyByIndex(macroIndex)
    if not body then
        return false, "macroChanged"
    end

    local updatedBody, changed = ApplyCombatPotionMacroChanges(
        body,
        replacements,
        removedLineNumbers,
        additions,
        lastPotionLine,
        nameLookup,
        idLookup,
        lineOrder
    )
    if not changed then
        return false, "noChange"
    end
    if #updatedBody > MAX_MACRO_BODY_LENGTH then
        return false, "tooLong"
    end
    if not EditMacroBody(macroIndex, updatedBody) then
        return false, "editFailed"
    end

    self:RefreshCombatPotionMacroPreview()
    return true
end

function WipedQoL:ReplaceCombatPotionsInMacro(previewItem, replacements)
    if type(previewItem) ~= "table" or type(replacements) ~= "table" then
        return false, "invalid"
    end
    if self:IsInCombat() then
        return false, "combat"
    end

    local _, idLookup = GetCombatPotionLookups()
    local presentPotionIDs = {}
    for _, itemID in ipairs(previewItem.presentPotionIDs or {}) do
        itemID = tonumber(itemID)
        if itemID then
            presentPotionIDs[itemID] = true
        end
    end
    for _, source in ipairs(previewItem.combatPotions or {}) do
        local itemID = tonumber(source.id)
        if itemID then
            presentPotionIDs[itemID] = true
        end
        for _, candidateItemID in ipairs(source.candidateIDs or {}) do
            candidateItemID = tonumber(candidateItemID)
            if candidateItemID then
                presentPotionIDs[candidateItemID] = true
            end
        end
    end
    local normalizedReplacements = {}
    local selectedTargetIDs = {}
    for _, replacement in ipairs(replacements) do
        local sourcePotion = replacement.sourcePotion or replacement.source
        local targetItemID = tonumber(replacement.targetItemID or replacement.target)
        if type(sourcePotion) == "table" and targetItemID then
            local targetItem = idLookup[targetItemID]
            if not targetItem or targetItem.currentSeason ~= true then
                return false, "invalidTarget"
            end

            for _, variantID in ipairs(GetCombatPotionTargetIDs(targetItem)) do
                if presentPotionIDs[variantID] then
                    return false, "targetAlreadyPresent"
                end
            end

            local targetKey = GetCombatPotionTargetKey(targetItem)
            if selectedTargetIDs[targetKey] then
                return false, "duplicateTarget"
            end
            selectedTargetIDs[targetKey] = true
            normalizedReplacements[#normalizedReplacements + 1] = {
                sourcePotion = sourcePotion,
                targetItemID = targetItemID,
            }
        end
    end
    if #normalizedReplacements == 0 then
        return false, "noSelection"
    end

    local macroIndex = self:ResolveCombatPotionMacroIndex(previewItem)
    if not macroIndex then
        return false, "macroChanged"
    end

    local _, body = GetMacroBodyByIndex(macroIndex)
    if not body then
        return false, "macroChanged"
    end

    local nameLookup
    nameLookup, idLookup = GetCombatPotionLookups()
    local updatedBody, changed = ReplaceCombatPotionMacroBody(
        body,
        normalizedReplacements,
        nameLookup,
        idLookup
    )
    if not changed then
        return false, "noChange"
    end
    if #updatedBody > MAX_MACRO_BODY_LENGTH then
        return false, "tooLong"
    end
    if not EditMacroBody(macroIndex, updatedBody) then
        return false, "editFailed"
    end

    self:RefreshCombatPotionMacroPreview()
    return true
end

function WipedQoL:ReplaceCombatPotionInMacro(previewItem, targetItemID)
    return self:ReplaceCombatPotionsInMacro(previewItem, {
        {
            sourcePotion = previewItem and previewItem.combatPotion,
            targetItemID = targetItemID,
        },
    })
end

function WipedQoL:GetCombatPotionMacroActiveState()
    local items = self:GetCombatPotionMacroPreviewItems()
    if #items == 0 then
        return false, false, false
    end

    local hasActive = false
    local hasPaused = false
    for _, item in ipairs(items) do
        if item.paused == true then
            hasPaused = true
        else
            hasActive = true
        end
    end

    return hasActive and not hasPaused, true, hasActive and hasPaused
end

function WipedQoL:RefreshCombatPotionMacroPreview()
    local settings = self:GetCombatPotionMacroSettings()
    local items = {}
    local nameLookup, idLookup = GetCombatPotionLookups()
    local numGlobal, numCharacter = 0, 0

    if GetNumMacros then
        numGlobal, numCharacter = GetNumMacros()
    end

    local maxGlobal = MAX_ACCOUNT_MACROS or 120
    local maxCharacter = MAX_CHARACTER_MACROS or 18

    ScanMacroRange(items, TRANSLATE("General"), 1, math.min(tonumber(numGlobal) or 0, maxGlobal), nameLookup, idLookup)
    ScanMacroRange(items, TRANSLATE("Character"), maxGlobal + 1, maxGlobal + math.min(tonumber(numCharacter) or 0, maxCharacter), nameLookup, idLookup)

    settings.preview = items

    if self.RefreshCombatPotionMacroPreviewUI then
        self:RefreshCombatPotionMacroPreviewUI()
    end

    if self.RefreshDatapanelCombatPotion then
        self:RefreshDatapanelCombatPotion(true)
    end
end

function WipedQoL:PauseCombatPotionInMacros()
    UpdateCombatPotionMacroLines(true)
    self:RefreshCombatPotionMacroPreview()
end

function WipedQoL:UnpauseCombatPotionInMacros()
    UpdateCombatPotionMacroLines(false)
    self:RefreshCombatPotionMacroPreview()
end

function WipedQoL:SetCombatPotionMacroPaused(index, paused)
    index = tonumber(index)
    if not index then
        return
    end

    local nameLookup, idLookup = GetCombatPotionLookups()
    local _, body = GetMacroBodyByIndex(index)
    if not body then
        return
    end

    local updatedBody, changed = UpdateCombatPotionMacroBody(body, paused == true, nameLookup, idLookup)
    if changed then
        EditMacroBody(index, updatedBody)
    end

    self:RefreshCombatPotionMacroPreview()
end

function WipedQoL:ResolveCombatPotionMacroIndex(previewItem)
    if type(previewItem) ~= "table" or not GetMacroInfo then
        return nil
    end

    local expectedName = previewItem.name
    local expectedBody = previewItem.bodyFingerprint
    local index = tonumber(previewItem.index)
    if index then
        local name, _, body = GetMacroInfo(index)
        if name and not body and GetMacroBody then
            body = GetMacroBody(index)
        end

        if name == expectedName and GetBodyFingerprint(body) == expectedBody then
            return index
        end
    end

    self:RefreshCombatPotionMacroPreview()

    local items = self:GetCombatPotionMacroPreviewItems()
    for _, item in ipairs(items or {}) do
        if item.name == expectedName and item.bodyFingerprint == expectedBody then
            return tonumber(item.index)
        end
    end

    return nil
end

local function MacroSelectorDataMatches(elementData, index, localIndex, name)
    if not elementData then
        return false
    end

    if elementData == index or elementData == localIndex then
        return true
    end

    if type(elementData) == "table" then
        for _, key in ipairs({ "index", "macroIndex", "selectionIndex", "macroID", "id" }) do
            local value = tonumber(elementData[key])
            if value and (value == index or value == localIndex) then
                return true
            end
        end

        for _, key in ipairs({ "name", "macroName", "text" }) do
            if name and elementData[key] == name then
                return true
            end
        end
    end

    return false
end

local function ScrollToMacroSelectorButton(index, localIndex, name)
    local selector = _G.MacroFrame and _G.MacroFrame.MacroSelector
    local scrollBox = selector and selector.ScrollBox
    if not scrollBox then
        return
    end

    local dataIndex
    if scrollBox.FindElementDataIndexByPredicate then
        dataIndex = scrollBox:FindElementDataIndexByPredicate(function(elementData)
            return MacroSelectorDataMatches(elementData, index, localIndex, name)
        end)
    end

    dataIndex = tonumber(dataIndex)
    if dataIndex then
        if scrollBox.ScrollToElementDataIndex then
            scrollBox:ScrollToElementDataIndex(dataIndex, 0.5)
        elseif scrollBox.ScrollToNearest then
            scrollBox:ScrollToNearest(dataIndex)
        end
    elseif scrollBox.ScrollToNearest then
        scrollBox:ScrollToNearest(localIndex or index)
    elseif scrollBox.ScrollToElementDataIndex then
        scrollBox:ScrollToElementDataIndex(localIndex or index, 0.5)
    end
end

local function NormalizeCurrentMacroFrameText()
    local frame = _G.MacroFrame
    local editBox = _G.MacroFrameText
    local selectedIndex = frame and tonumber(frame.selectedMacro or frame.selectedMacroIndex)
    if not editBox or not editBox.SetText or not selectedIndex then
        return
    end

    local body = select(3, GetMacroInfo(selectedIndex))
    if not body and GetMacroBody then
        body = GetMacroBody(selectedIndex)
    end

    editBox:SetText(body or "")
    if frame then
        frame.textChanged = nil
    end
end

local function GetMacroFrameLocalIndex(index)
    local maxGlobal = MAX_ACCOUNT_MACROS or 120
    return index > maxGlobal and (index - maxGlobal) or index
end

local function GetMacroFrameTabID(index)
    local maxGlobal = MAX_ACCOUNT_MACROS or 120
    return index > maxGlobal and 2 or 1
end

local function SelectBlizzardMacroTab(index)
    local tabID = GetMacroFrameTabID(index)
    local frame = _G.MacroFrame

    if frame and frame.ChangeTab then
        frame.textChanged = nil
        frame:ChangeTab(tabID)
        frame.textChanged = nil
        return
    end

    local tab = tabID == 2 and _G.MacroFrameTab2 or _G.MacroFrameTab1
    if tab and tab.Click then
        tab:Click()
        if frame then
            frame.textChanged = nil
        end
        return
    end

    if PanelTemplates_SetTab and frame then
        PanelTemplates_SetTab(frame, tabID)
        frame.textChanged = nil
    end
end

local function SelectBlizzardMacro(index)
    local localIndex = GetMacroFrameLocalIndex(index)
    local name = GetMacroInfo and GetMacroInfo(index)
    local frame = _G.MacroFrame

    NormalizeCurrentMacroFrameText()

    if frame and frame.SelectMacro then
        frame.textChanged = nil
        frame:SelectMacro(localIndex, true)
        frame.textChanged = nil
        return true
    end

    ScrollToMacroSelectorButton(index, localIndex, name)

    if _G.MacroFrame then
        _G.MacroFrame.selectedMacro = index
        _G.MacroFrame.selectedMacroIndex = index
        _G.MacroFrame.selectedMacroName = name
    end

    if MacroFrame_Update then
        MacroFrame_Update()
        return true
    end

    if MacroFrame_UpdateButtons then
        MacroFrame_UpdateButtons()
        return true
    end

    return false
end

function WipedQoL:OpenBlizzardMacro(index)
    index = tonumber(index)
    if not index or not GetMacroInfo or not GetMacroInfo(index) then
        return
    end

    if self:IsInCombat() then
        return
    end

    if C_AddOns and C_AddOns.LoadAddOn then
        pcall(C_AddOns.LoadAddOn, "Blizzard_MacroUI")
    elseif LoadAddOn then
        pcall(LoadAddOn, "Blizzard_MacroUI")
    end

    if ShowMacroFrame then
        ShowMacroFrame()
    elseif MacroFrame_LoadUI then
        MacroFrame_LoadUI()
        if ShowMacroFrame then
            ShowMacroFrame()
        end
    end

    local function Select()
        SelectBlizzardMacroTab(index)
        SelectBlizzardMacro(index)
    end

    Select()
    if C_Timer and C_Timer.After then
        C_Timer.After(0, Select)
        C_Timer.After(0.05, Select)
        C_Timer.After(0.15, Select)
    end
end

function WipedQoL:OpenCombatPotionMacro(index)
    self:OpenBlizzardMacro(index)
end
