local addonName, ns = ...

local MM = {}
ns.MM = MM
_G.MurlocMinder = MM

local ADDON_NAME = addonName or "MurlocMinder"
local DevInfoModule = ns.DevInfoModule
local TITLE_VERSION_FALLBACK = "0.1.11"
local ICON_MURLOC = "Interface\\Icons\\INV_Misc_Head_Murloc_01"
local MINIMAP_TOOLTIP_COLORS = {
    hunger = { 0.38, 0.92, 0.57 },
    joy = { 0.35, 0.72, 1.0 },
    energy = { 0.99, 0.94, 0.62 },
    cleanliness = { 0.76, 0.58, 0.95 },
    hatch = { 0.96, 0.42, 0.72 },
}
local NEED_POINTS_SCALE = 10
local NEED_POINTS_MAX = 1000
local ACHIEVEMENT_DEFINITIONS = {
    ["MM-GEN-STORE-001"] = {
        title = "Window Shopper",
    },
    ["MM-TRAIT-PAIR-001"] = {
        title = "Anchored and Napping",
        pairKey = "lazy_stubborn",
    },
    ["MM-TRAIT-PAIR-002"] = {
        title = "Hall Monitor Duo",
        pairKey = "loyal_responsible",
    },
}

local TRAIT_PAIR_ACHIEVEMENT_BY_PAIR_KEY = {}
for achievementID, definition in pairs(ACHIEVEMENT_DEFINITIONS) do
    local pairKey = type(definition) == "table" and tostring(definition.pairKey or "") or ""
    if pairKey ~= "" then
        TRAIT_PAIR_ACHIEVEMENT_BY_PAIR_KEY[pairKey] = achievementID
    end
end

local defaults = {
    profile = {
        welcomeSeen = false,
        minimap = {
            hide = false,
            angle = 225,
        },
        frame = {
            point = "CENTER",
            x = 0,
            y = 0,
            width = 1120,
            height = 700,
            scale = 1.0,
        },
        ui = {
            showTracker = true,
            trackerMode = "full",
            trackerChallengesCollapsed = false,
            trackerScale = 1.0,
        },
        store = {
            shells = 120,
            inventory = {
                kelpSnack = 3,
                tideWorm = 1,
                bubbleBiscuit = 0,
                reefJerky = 0,
                tideFeast = 0,
            },
            customization = {
                selectedPage = "habitats",
                activeHabitatBackground = "auto",
                habitatOwned = {
                    auto = true,
                },
            },
        },
        pet = {
            name = "Mrrgl",
            stage = "egg",
            ageMinutes = 0,
            hunger = 850,
            joy = 750,
            energy = 900,
            cleanliness = 800,
            hatchProgress = 8,
            personality = "Unknown",
        },
        activityLog = {},
        achievements = {
            unlocked = {},
        },
        dev = {
            showDevTab = false,
        },
    },
}

local function DeepCopy(src)
    if type(src) ~= "table" then
        return src
    end

    local dest = {}
    for key, value in pairs(src) do
        dest[key] = DeepCopy(value)
    end
    return dest
end

local function MergeDefaults(dest, src)
    for key, value in pairs(src) do
        if type(value) == "table" then
            if type(dest[key]) ~= "table" then
                dest[key] = {}
            end
            MergeDefaults(dest[key], value)
        elseif dest[key] == nil then
            dest[key] = value
        end
    end
end

local function TrimString(str)
    return str and str:match("^%s*(.-)%s*$") or ""
end

local function CanonicalTraitPairKey(primary, secondary)
    local first = string.lower(TrimString(tostring(primary or "")))
    local second = string.lower(TrimString(tostring(secondary or "")))
    if first == "" or second == "" or first == second then
        return nil
    end
    if first < second then
        return first .. "_" .. second
    end
    return second .. "_" .. first
end

-- Sound profiles
-- Keep these centralized so action/autonomy assignments are easy to tune.
local CARE_ACTION_SOUND_POOLS = {
    play = {
        { index = 13 },
        { index = 15 },
        { index = 16 },
        { index = 17 },
        { index = 19 },
    },
    feed = {
        { index = 119 },
        { index = 120 },
        { index = 121 },
        { index = 122 },
        { index = 123 },
    },
    clean = {
        { index = 227 },
        { index = 228 },
        { index = 229 },
        { index = 230 },
        { index = 231 },
        { index = 232 },
    },
    rest = {
        { index = 121 },
        { index = 213 },
        { index = 215 },
        { index = 217 },
        { index = 218 },
        { index = 219 },
        { index = 220 },
        { index = 221 },
        { index = 222 },
        { index = 223 },
        { index = 224 },
        { index = 225 },
    },
}

local AUTONOMOUS_SOUND_POOLS = {
    positive = {
        { index = 6 },
        { index = 9 },
        { index = 10 },
        { index = 11 },
        { index = 12 },
        { index = 174 },
        { index = 126 },
    },
    negative = {
        { index = 1 },
        { index = 2 },
        { index = 4 },
        { index = 14 },
        { index = 20 },
        { index = 21 },
        { index = 111 },
        { index = 113 },
        { index = 115 },
        { index = 116 },
        { index = 117 },
        { index = 123 },
    },
}

local UI_SOUND_PROFILE = {
    button = { "IG_MAINMENU_OPTION_CHECKBOX_ON", "U_CHAT_SCROLL_BUTTON" },
    popup_open = { "IG_MAINMENU_OPEN", "IG_CHARACTER_INFO_OPEN" },
    popup_close = { "IG_MAINMENU_CLOSE", "GS_TITLE_OPTION_OK" },
    purchase = { "LOOT_WINDOW_COIN_SOUND", "GS_TITLE_OPTION_OK" },
    error = { "IG_MAINMENU_OPTION_CHECKBOX_OFF", "IG_ABILITY_PAGE_TURN" },
}

local function IsCVarEnabled(name, defaultValue)
    if type(GetCVar) ~= "function" then
        return defaultValue == true
    end
    local ok, value = pcall(GetCVar, name)
    if (not ok) or value == nil then
        return defaultValue == true
    end
    if type(value) == "number" then
        return value ~= 0
    end
    local text = tostring(value)
    if text == "" then
        return defaultValue == true
    end
    text = string.lower(text)
    return text == "1" or text == "true" or text == "yes" or text == "on"
end

function MM:IsAddonSFXEnabled()
    return IsCVarEnabled("Sound_EnableAllSound", true) and IsCVarEnabled("Sound_EnableSFX", true)
end

function MM:IsMainPanelOpen()
    return self.frame and self.frame.IsShown and self.frame:IsShown()
end

local function BuildSoundPathCandidates(path)
    local raw = tostring(path or "")
    if raw == "" then
        return {}
    end

    local list = {}
    local seen = {}
    local function push(candidate)
        local text = tostring(candidate or "")
        if text == "" or seen[text] then
            return
        end
        seen[text] = true
        list[#list + 1] = text
    end

    push(raw)

    local asForward = raw:gsub("\\", "/")
    local asBack = raw:gsub("/", "\\")
    push(asForward)
    push(asBack)

    local forwardLower = string.lower(asForward)
    if forwardLower:match("^sound/") then
        push("Sound/" .. asForward:sub(7))
        push("Sound\\" .. asBack:sub(7))
    elseif forwardLower:match("^sound\\\\") then
        push("Sound\\" .. asBack:sub(7))
        push("Sound/" .. asForward:sub(7))
    elseif not forwardLower:match("^interface/") and not forwardLower:match("^interface\\\\") then
        push("sound/" .. asForward)
        push("sound\\" .. asBack)
        push("Sound/" .. asForward)
        push("Sound\\" .. asBack)
    end

    return list
end

local function DidSoundPlay(ok, willPlay, soundHandle)
    if not ok then
        return false
    end
    if willPlay == true then
        return true
    end
    if type(willPlay) == "number" and willPlay > 0 then
        return true
    end
    if type(soundHandle) == "number" and soundHandle > 0 then
        return true
    end
    return false
end

local function BuildChannelCandidates(preferredChannel)
    local channels = {}
    local seen = {}
    local seenNil = false
    local function push(channel)
        if channel == nil then
            if seenNil then
                return
            end
            seenNil = true
        else
            if seen[channel] then
                return
            end
            seen[channel] = true
        end
        channels[#channels + 1] = channel
    end

    push(preferredChannel)
    push(nil)
    push("SFX")
    push("Master")
    return channels
end

local function TryPlaySoundKitVariants(id, preferredChannel)
    local numericID = tonumber(id)
    if not numericID or numericID <= 0 then
        return false
    end

    local channels = BuildChannelCandidates(preferredChannel)
    local soundFunctions = {}
    if type(PlaySound) == "function" then
        soundFunctions[#soundFunctions + 1] = PlaySound
    end
    if C_Sound and type(C_Sound.PlaySound) == "function" and C_Sound.PlaySound ~= PlaySound then
        soundFunctions[#soundFunctions + 1] = C_Sound.PlaySound
    end
    if type(PlaySoundKitID) == "function" then
        soundFunctions[#soundFunctions + 1] = PlaySoundKitID
    end

    for _, fn in ipairs(soundFunctions) do
        for _, channel in ipairs(channels) do
            local ok, willPlay, soundHandle
            if channel ~= nil then
                ok, willPlay, soundHandle = pcall(fn, numericID, channel)
            else
                ok, willPlay, soundHandle = pcall(fn, numericID)
            end
            if DidSoundPlay(ok, willPlay, soundHandle) then
                return true
            end
        end
    end

    return false
end

local function TryPlaySoundFileVariants(path, id, channel)
    if type(PlaySoundFile) ~= "function" then
        return false
    end

    local channels = BuildChannelCandidates(channel)
    local numericID = tonumber(id)
    if numericID and numericID > 0 then
        for _, tryChannel in ipairs(channels) do
            local ok, willPlay, soundHandle
            if tryChannel ~= nil then
                ok, willPlay, soundHandle = pcall(PlaySoundFile, numericID, tryChannel)
            else
                ok, willPlay, soundHandle = pcall(PlaySoundFile, numericID)
            end
            if DidSoundPlay(ok, willPlay, soundHandle) then
                return true
            end
        end
    end

    for _, candidate in ipairs(BuildSoundPathCandidates(path)) do
        for _, tryChannel in ipairs(channels) do
            local ok, willPlay, soundHandle
            if tryChannel ~= nil then
                ok, willPlay, soundHandle = pcall(PlaySoundFile, candidate, tryChannel)
            else
                ok, willPlay, soundHandle = pcall(PlaySoundFile, candidate)
            end
            if DidSoundPlay(ok, willPlay, soundHandle) then
                return true
            end
        end
    end

    return false
end

function MM:GetSoundEntryByAssignmentIndex(index)
    local idx = tonumber(index)
    if not idx or idx < 1 then
        return nil
    end
    if type(self.GetDevMurlocSoundEntries) ~= "function" then
        return nil
    end
    local catalog = self:GetDevMurlocSoundEntries()
    local entry = type(catalog) == "table" and catalog[idx] or nil
    if type(entry) ~= "table" then
        return nil
    end
    local out = {
        id = tonumber(entry.id),
        mode = tostring(entry.mode or "file"),
    }
    if out.mode == "file" then
        out.path = tostring(entry.key or entry.path or entry.display or "")
    end
    return out
end

function MM:ResolveAssignedSoundEntry(assignedEntry)
    if type(assignedEntry) ~= "table" then
        local id = tonumber(assignedEntry)
        if id and id > 0 then
            return { id = id, mode = "kit", path = nil }
        end
        return nil
    end

    local fromIndex = self:GetSoundEntryByAssignmentIndex(assignedEntry.index)
    if fromIndex then
        return fromIndex
    end

    local resolved = {
        id = tonumber(assignedEntry.id),
        mode = tostring(assignedEntry.mode or ""),
        path = tostring(assignedEntry.path or ""),
    }
    if resolved.mode == "" then
        resolved.mode = (resolved.path ~= "" and "file") or "kit"
    end
    if resolved.id and resolved.id > 0 then
        return resolved
    end
    if resolved.path ~= "" then
        return resolved
    end
    return nil
end

function MM:PlayAddonSound(soundID, mode, path)
    local id = tonumber(soundID)
    if not self:IsAddonSFXEnabled() then
        return false
    end

    local entryMode = mode or ((path and path ~= "") and "file" or "kit")
    local played
    if entryMode == "file" then
        played = TryPlaySoundFileVariants(path, id, "SFX")
        if played then
            return true
        end
    end

    played = TryPlaySoundKitVariants(id, "SFX")
    if not played then
        played = TryPlaySoundFileVariants(path, id, "SFX")
    end
    return played
end

function MM:GetCareActionSoundPool(actionKey)
    local key = tostring(actionKey or "")
    if key == "egg_soothe" then
        key = "play"
    elseif key == "egg_warm" then
        key = "rest"
    elseif key == "egg_clean" then
        key = "clean"
    end
    return CARE_ACTION_SOUND_POOLS[key]
end

function MM:PlayCareActionButtonSound(actionKey)
    local pool = self:GetCareActionSoundPool(actionKey)
    if type(pool) ~= "table" or #pool == 0 then
        return false
    end
    local entry = pool[math.random(1, #pool)]
    local sound = self:ResolveAssignedSoundEntry(entry)
    if type(sound) ~= "table" then
        return false
    end
    return self:PlayAddonSound(sound.id, sound.mode, sound.path)
end

function MM:GetAutonomousSoundPool(polarity)
    local key = (tostring(polarity or "") == "negative") and "negative" or "positive"
    return AUTONOMOUS_SOUND_POOLS[key]
end

function MM:PlayAutonomousActionSound(polarity)
    if not self:IsMainPanelOpen() then
        return false
    end
    local pool = self:GetAutonomousSoundPool(polarity)
    if type(pool) ~= "table" or #pool == 0 then
        return false
    end
    local index = math.random(1, #pool)
    local entry = pool[index]
    local sound = self:ResolveAssignedSoundEntry(entry)
    if type(sound) ~= "table" then
        return false
    end
    return self:PlayAddonSound(sound.id, sound.mode, sound.path)
end

function MM:PlayDevPreviewSound(soundID, mode, path)
    local id = tonumber(soundID)
    if (not id or id <= 0) and tostring(path or "") == "" then
        return false
    end

    local entryMode = mode or ((tostring(path or "") ~= "") and "file" or "kit")
    local played
    if entryMode == "file" then
        played = TryPlaySoundFileVariants(path, id, "Master")
        if played then
            return true
        end
    end

    played = TryPlaySoundKitVariants(id, "Master")
    if not played then
        played = TryPlaySoundFileVariants(path, id, "Master")
    end
    return played
end

local function ResolveSoundKitIDFromSpec(spec)
    if type(spec) == "number" then
        return spec
    end
    if type(spec) ~= "string" or type(SOUNDKIT) ~= "table" then
        return nil
    end
    local soundID = tonumber(SOUNDKIT[spec])
    if soundID and soundID > 0 then
        return soundID
    end
    return nil
end

function MM:PlayUISound(kind)
    if not self:IsAddonSFXEnabled() then
        return false
    end
    local profile = UI_SOUND_PROFILE[tostring(kind or "")] or UI_SOUND_PROFILE.button
    if type(profile) ~= "table" then
        return false
    end
    for _, spec in ipairs(profile) do
        local soundID = ResolveSoundKitIDFromSpec(spec)
        if soundID and TryPlaySoundKitVariants(soundID, "SFX") then
            return true
        end
    end
    return false
end

function MM:Print(text)
    local msg = tostring(text or "")
    if DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then
        DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99Murloc Minder:|r " .. msg)
    else
        print("Murloc Minder: " .. msg)
    end
end

local function EnsureCopyBlockFrame(self)
    if self.copyBlockFrame and self.copyBlockFrame.EditBox and self.copyBlockFrame.TitleText then
        return self.copyBlockFrame
    end
    if type(CreateFrame) ~= "function" or not UIParent then
        return nil
    end

    local frame = CreateFrame("Frame", "MurlocMinderCopyBlockFrame", UIParent, "BackdropTemplate")
    frame:SetSize(760, 460)
    frame:SetPoint("CENTER")
    frame:SetFrameStrata("DIALOG")
    frame:SetToplevel(true)
    frame:EnableMouse(true)
    frame:SetMovable(true)
    frame:RegisterForDrag("LeftButton")
    frame:SetClampedToScreen(true)
    frame:SetScript("OnDragStart", function(f)
        f:StartMoving()
    end)
    frame:SetScript("OnDragStop", function(f)
        f:StopMovingOrSizing()
    end)
    frame:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
        edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
        tile = true,
        tileSize = 32,
        edgeSize = 32,
        insets = { left = 8, right = 8, top = 8, bottom = 8 },
    })

    local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    title:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -16)
    title:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -44, -16)
    title:SetJustifyH("LEFT")
    title:SetText("Telemetry")
    frame.TitleText = title

    local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
    close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -5, -5)
    close:SetScript("OnClick", function()
        frame:Hide()
        if self.PlayUISound then
            self:PlayUISound("popup_close")
        end
    end)

    local scroll = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", frame, "TOPLEFT", 18, -44)
    scroll:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -38, 44)

    local edit = CreateFrame("EditBox", nil, scroll)
    edit:SetMultiLine(true)
    edit:SetAutoFocus(false)
    edit:SetFontObject(ChatFontNormal)
    edit:SetWidth(680)
    edit:SetJustifyH("LEFT")
    edit:SetJustifyV("TOP")
    edit:SetTextInsets(2, 2, 2, 2)
    edit:SetScript("OnEscapePressed", function()
        frame:Hide()
        if self.PlayUISound then
            self:PlayUISound("popup_close")
        end
    end)
    edit:SetScript("OnTextChanged", function(box)
        box:GetParent():UpdateScrollChildRect()
    end)
    scroll:SetScrollChild(edit)
    frame.EditBox = edit

    local selectButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
    selectButton:SetSize(100, 22)
    selectButton:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -14, 12)
    selectButton:SetText("Select All")
    selectButton:SetScript("OnClick", function()
        edit:SetFocus()
        edit:HighlightText()
    end)

    local closeButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
    closeButton:SetSize(90, 22)
    closeButton:SetPoint("RIGHT", selectButton, "LEFT", -8, 0)
    closeButton:SetText("Close")
    closeButton:SetScript("OnClick", function()
        frame:Hide()
        if self.PlayUISound then
            self:PlayUISound("popup_close")
        end
    end)

    frame:Hide()
    self.copyBlockFrame = frame
    return frame
end

function MM:ShowCopyableTextBlock(title, text)
    local content = tostring(text or "")
    if content == "" then
        self:Print("Nothing to copy.")
        return false
    end

    local frame = EnsureCopyBlockFrame(self)
    if not frame then
        self:Print(content)
        return false
    end

    frame.TitleText:SetText(tostring(title or "Murloc Minder"))
    frame.EditBox:SetText(content)
    frame.EditBox:SetCursorPosition(0)
    frame:Show()
    frame.EditBox:SetFocus()
    frame.EditBox:HighlightText()
    if self.PlayUISound then
        self:PlayUISound("popup_open")
    end
    return true
end

function MM:EnsureDevInfoModule()
    if self.devInfoModule then
        return self.devInfoModule
    end
    if type(DevInfoModule) ~= "table" or type(DevInfoModule.Create) ~= "function" then
        return nil
    end

    self.devInfoModule = DevInfoModule:Create(self, {
        addon_name = ADDON_NAME,
        frame_prefix = ADDON_NAME,
        media_root = "Interface\\AddOns\\" .. ADDON_NAME .. "\\Templates\\Media\\Social\\",
        sound_player = function(sound_key)
            if self.PlayUISound then
                self:PlayUISound(sound_key)
            end
        end,
        print = function(text)
            self:Print(text)
        end,
    })
    return self.devInfoModule
end

function MM:ShowCopyableSocialLink(entry)
    local module = self:EnsureDevInfoModule()
    if not module or type(module.ShowCopyableSocialLink) ~= "function" then
        return self:ShowCopyableTextBlock("Link", "")
    end
    return module:ShowCopyableSocialLink(entry)
end

function MM:GetAddonVersionText()
    local function normalize(version)
        local text = tostring(version or "")
        local major, minor, patch, suffix = text:match("^(%d+)%.(%d+)%.(%d+)([A-Za-z]?)$")
        if major then
            return string.format("%s.%s.%s%s", major, minor, patch, suffix or "")
        end
        return ""
    end

    local version = ""
    if type(GetAddOnMetadata) == "function" then
        version = normalize(GetAddOnMetadata(ADDON_NAME, "Version"))
        if version == "" then
            version = normalize(GetAddOnMetadata("MurlocMinder", "Version"))
        end
    end
    if version == "" and C_AddOns and type(C_AddOns.GetAddOnMetadata) == "function" then
        version = normalize(C_AddOns.GetAddOnMetadata(ADDON_NAME, "Version"))
        if version == "" then
            version = normalize(C_AddOns.GetAddOnMetadata("MurlocMinder", "Version"))
        end
    end
    if version == "" and self and self.frame and self.frame.TitleText and self.frame.TitleText.GetText then
        version = normalize((self.frame.TitleText:GetText() or ""):match("v(%d+%.%d+%.%d+[A-Za-z]?)"))
    end
    if version == "" then
        version = normalize(TITLE_VERSION_FALLBACK)
    end
    if version == "" then
        version = "0.1.11"
    end
    return version
end

function MM:GetNeedPointScale()
    return NEED_POINTS_SCALE
end

function MM:GetNeedMaxValue()
    return NEED_POINTS_MAX
end

function MM:NeedPercentToPoints(value)
    return (tonumber(value) or 0) * NEED_POINTS_SCALE
end

function MM:NeedPointsToPercent(value)
    return (tonumber(value) or 0) / NEED_POINTS_SCALE
end

function MM:ClampNeedValue(value)
    return self:Clamp(tonumber(value) or 0, 0, NEED_POINTS_MAX)
end

function MM:FormatNeedPercent(value, forceOneDecimal)
    local pct = self:NeedPointsToPercent(value)
    pct = math.floor((pct * 10) + 0.5) / 10
    if forceOneDecimal then
        return string.format("%.1f%%", pct)
    end
    local whole = math.floor(pct + 0.0001)
    if math.abs(pct - whole) < 0.001 then
        return string.format("%d%%", whole)
    end
    return string.format("%.1f%%", pct)
end

function MM:GetAchievementState()
    self.db = self.db or {}
    self.db.profile = self.db.profile or {}
    self.db.profile.achievements = self.db.profile.achievements or {}
    local achievements = self.db.profile.achievements
    achievements.unlocked = achievements.unlocked or {}
    return achievements
end

function MM:IsAchievementUnlocked(achievementID)
    local id = tostring(achievementID or "")
    if id == "" then
        return false
    end
    local achievements = self:GetAchievementState()
    return achievements.unlocked[id] ~= nil
end

function MM:GetAchievementTitle(achievementID)
    local id = tostring(achievementID or "")
    local definition = ACHIEVEMENT_DEFINITIONS[id]
    if type(definition) == "table" then
        local title = TrimString(tostring(definition.title or ""))
        if title ~= "" then
            return title
        end
    end
    return id
end

function MM:UnlockAchievement(achievementID, context)
    local id = tostring(achievementID or "")
    if id == "" then
        return false
    end
    local achievements = self:GetAchievementState()
    if achievements.unlocked[id] ~= nil then
        return false
    end

    local title = self:GetAchievementTitle(id)
    achievements.unlocked[id] = {
        id = id,
        title = title,
        unlockedAt = time() or 0,
        context = context,
    }

    self:PushActivity(
        string.format("Achievement unlocked: %s", title),
        { source = "system", skipAchievementCheck = true }
    )
    return true
end

function MM:EvaluateTraitPairAchievements()
    local pet = (type(self.GetPet) == "function" and self:GetPet()) or (self.db and self.db.profile and self.db.profile.pet) or nil
    if type(pet) ~= "table" then
        return false
    end

    local pairKey = CanonicalTraitPairKey(pet.personality, pet.personalitySecondary)
    if not pairKey then
        return false
    end

    local achievementID = TRAIT_PAIR_ACHIEVEMENT_BY_PAIR_KEY[pairKey]
    if not achievementID then
        return false
    end

    return self:UnlockAchievement(achievementID, { trigger = "trait_pair", pairKey = pairKey })
end

function MM:HandleAchievementActivityTrigger(message, resolvedMeta)
    local text = string.lower(TrimString(tostring(message or "")))
    if text == "opened full store panel." then
        self:UnlockAchievement("MM-GEN-STORE-001", { trigger = "activity_message", source = resolvedMeta and resolvedMeta.source or nil })
    end
    self:EvaluateTraitPairAchievements()
end

local function GetTooltipColor(key)
    local color = MINIMAP_TOOLTIP_COLORS[key] or { 1, 1, 1 }
    return color[1], color[2], color[3]
end

function MM:ShowTelemetryReport(chatNotice)
    if not self.BuildDrainTelemetryReport then
        self:Print("Telemetry report is unavailable.")
        return false
    end

    local report = self:BuildDrainTelemetryReport()
    if self:ShowCopyableTextBlock("Murloc Minder Drain Telemetry", report) then
        if chatNotice then
            self:Print("Telemetry copied to popup. Use Select All, then Ctrl+C.")
        end
        return true
    end

    self:Print("Telemetry report:")
    self:Print(report)
    return false
end

local function GetLauncherPriority()
    local priority = ""
    if type(GetAddOnMetadata) == "function" then
        priority = tostring(GetAddOnMetadata(ADDON_NAME, "X-KagrokLauncherPriority") or "")
        if priority == "" then
            priority = tostring(GetAddOnMetadata("MurlocMinder", "X-KagrokLauncherPriority") or "")
        end
    end
    if priority == "" and C_AddOns and type(C_AddOns.GetAddOnMetadata) == "function" then
        priority = tostring(C_AddOns.GetAddOnMetadata(ADDON_NAME, "X-KagrokLauncherPriority") or "")
        if priority == "" then
            priority = tostring(C_AddOns.GetAddOnMetadata("MurlocMinder", "X-KagrokLauncherPriority") or "")
        end
    end
    return tonumber(priority) or 100
end

function MM:AddMinimapLauncherTooltip(tooltip)
    local pet = (self.GetPet and self:GetPet()) or (self.db and self.db.profile and self.db.profile.pet) or {}
    local petName = ""
    if type(self.GetPetDisplayName) == "function" then
        petName = tostring(self:GetPetDisplayName() or "")
    else
        petName = TrimString(tostring(pet.name or ""))
    end

    tooltip:SetText("Murloc Minder")
    if petName ~= "" then
        tooltip:AddLine(petName, 1.0, 0.82, 0.1)
    end
    tooltip:AddLine("Left Click: Toggle main panel", 1, 1, 1)
    tooltip:AddLine("Right Click: Options", 1, 1, 1)
    tooltip:AddLine("Drag: Move along minimap", 0.65, 0.85, 1.0)
    tooltip:AddLine(" ")

    if pet and pet.stage == "egg" then
        local hatchR, hatchG, hatchB = GetTooltipColor("hatch")
        tooltip:AddDoubleLine("Hatch Progress", string.format("%d%%", math.floor((tonumber(pet.hatchProgress) or 0) + 0.5)), 1, 1, 1, hatchR, hatchG, hatchB)
        return
    end

    local hungerR, hungerG, hungerB = GetTooltipColor("hunger")
    local joyR, joyG, joyB = GetTooltipColor("joy")
    local energyR, energyG, energyB = GetTooltipColor("energy")
    local cleanR, cleanG, cleanB = GetTooltipColor("cleanliness")
    tooltip:AddDoubleLine("Hunger", self:FormatNeedPercent(pet.hunger), 1, 1, 1, hungerR, hungerG, hungerB)
    tooltip:AddDoubleLine("Joy", self:FormatNeedPercent(pet.joy), 1, 1, 1, joyR, joyG, joyB)
    tooltip:AddDoubleLine("Energy", self:FormatNeedPercent(pet.energy), 1, 1, 1, energyR, energyG, energyB)
    tooltip:AddDoubleLine("Cleanliness", self:FormatNeedPercent(pet.cleanliness), 1, 1, 1, cleanR, cleanG, cleanB)
end

function MM:BuildSharedLauncherMenuItems()
    self.db.profile.ui = self.db.profile.ui or {}
    local trackerVisible = self.db.profile.ui.showTracker ~= false
    local panelOpen = self:IsMainPanelOpen()

    return {
        {
            text = panelOpen and "Hide main panel" or "Open main panel",
            func = function()
                if self.ToggleMainFrame then
                    self:ToggleMainFrame()
                end
            end,
        },
        {
            text = trackerVisible and "Hide tracker" or "Show tracker",
            func = function()
                self.db.profile.ui.showTracker = not trackerVisible
                if self.RefreshUI then
                    self:RefreshUI(true)
                end
            end,
        },
        {
            text = "Telemetry",
            func = function()
                self:ShowTelemetryReport(false)
            end,
        },
    }
end

function MM:BuildSharedLauncherSharedItems()
    return {
        {
            key = "developer_info",
            text = "Developer Info",
            order = 100,
            func = function()
                self:ShowDeveloperInfoPanel()
            end,
        },
    }
end

function MM:EnsureSharedLauncher()
    if self.sharedLauncher and self._sharedLauncherRegistered then
        return self.sharedLauncher
    end

    local launcher = ns.KagrokSharedLauncher
    if type(launcher) ~= "table" or type(launcher.RegisterAddon) ~= "function" then
        return nil
    end

    self.db.profile.minimap = self.db.profile.minimap or {}
    self.sharedLauncher = launcher
    launcher:RegisterAddon({
        id = ADDON_NAME,
        addon_name = ADDON_NAME,
        name = "Murloc Minder",
        priority = GetLauncherPriority(),
        icon = ICON_MURLOC,
        shared_icon = "Interface\\AddOns\\" .. ADDON_NAME .. "\\Templates\\Media\\Launcher\\kagrok_full.png",
        is_enabled = function()
            return not (self.db and self.db.profile and self.db.profile.minimap and self.db.profile.minimap.hide == true)
        end,
        left_click = function()
            if self.ToggleMainFrame then
                self:ToggleMainFrame()
            end
        end,
        tooltip = function(_, tooltip)
            self:AddMinimapLauncherTooltip(tooltip)
        end,
        menu_items = function()
            return self:BuildSharedLauncherMenuItems()
        end,
        shared_items = function()
            return self:BuildSharedLauncherSharedItems()
        end,
    })
    self._sharedLauncherRegistered = true
    return launcher
end

function MM:UpdateMinimapButtonPosition()
    local launcher = self:EnsureSharedLauncher()
    if launcher and launcher.UpdateButtonPosition then
        launcher:UpdateButtonPosition()
    end
end

function MM:UpdateMinimapButtonDrag()
    local launcher = self:EnsureSharedLauncher()
    if launcher and launcher.UpdateButtonDrag then
        launcher:UpdateButtonDrag()
    end
end

function MM:UpdateMinimapButtonVisibility()
    local launcher = self:EnsureSharedLauncher()
    if launcher and launcher.Refresh then
        launcher:Refresh()
    end
end

function MM:HideMinimapContextMenu()
    local launcher = self:EnsureSharedLauncher()
    if launcher and launcher.HideContextMenu then
        launcher:HideContextMenu()
    end
end

function MM:ShowMinimapContextMenu(owner)
    local launcher = self:EnsureSharedLauncher()
    if launcher and launcher.ShowContextMenu then
        launcher:ShowContextMenu(owner)
    end
end

function MM:EnsureMinimapButton()
    local launcher = self:EnsureSharedLauncher()
    if launcher and launcher.EnsureButton then
        return launcher:EnsureButton()
    end
    return nil
end

function MM:ShowDeveloperInfoPanel()
    local module = self:EnsureDevInfoModule()
    if module and type(module.ShowPanel) == "function" then
        return module:ShowPanel()
    end
    return false
end

local function CreateOptionsCheck(parent, label, initial, onClick)
    local button = CreateFrame("CheckButton", nil, parent, "InterfaceOptionsCheckButtonTemplate")
    button.Text:SetText(label)
    button:SetChecked(initial == true)
    button:SetScript("OnClick", function(self)
        if onClick then
            onClick(self:GetChecked() == true)
        end
    end)
    return button
end

local function CreateOptionsSlider(parent, label, minValue, maxValue, step, value, onChanged, lowText, highText)
    local holder = CreateFrame("Frame", nil, parent, "MinimalSliderWithSteppersTemplate")
    local slider = holder and holder.Slider
    if holder and slider then
        holder:SetFrameLevel((parent:GetFrameLevel() or 1) + 2)
        slider:EnableMouse(true)
        slider:SetMinMaxValues(minValue, maxValue)
        slider:SetValueStep(step)
        slider:SetObeyStepOnDrag(true)
        holder._min, holder._max, holder._step = minValue, maxValue, step
        holder.Text = holder.Text or holder:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
        holder.Text:ClearAllPoints()
        holder.Text:SetPoint("BOTTOM", holder, "TOP", 0, 2)
        if holder.Low and holder.High then
            holder.Low:ClearAllPoints()
            holder.Low:SetPoint("TOPLEFT", holder, "BOTTOMLEFT", 0, -8)
            holder.Low:SetText(lowText or minValue)
            holder.High:ClearAllPoints()
            holder.High:SetPoint("TOPRIGHT", holder, "BOTTOMRIGHT", 0, -8)
            holder.High:SetText(highText or maxValue)
        end

        local function UpdateLabel(rawValue)
            local rounded = math.floor((tonumber(rawValue) or 0) + 0.5)
            if holder.Text then
                holder.Text:SetText(string.format("%s: %d%%", label, rounded))
            end
        end

        slider:SetScript("OnValueChanged", function(_, rawValue)
            local rounded = math.floor((tonumber(rawValue) or 0) + 0.5)
            UpdateLabel(rounded)
            if onChanged then
                onChanged(rounded)
            end
        end)

        local function Nudge(delta)
            local current = slider:GetValue() or value
            local nextValue = math.min(holder._max, math.max(holder._min, current + (delta * holder._step)))
            slider:SetValue(nextValue)
        end

        if holder.DecrementButton then
            holder.DecrementButton:SetScript("OnClick", function()
                Nudge(-1)
            end)
        end
        if holder.IncrementButton then
            holder.IncrementButton:SetScript("OnClick", function()
                Nudge(1)
            end)
        end
        holder.SetEnabled = holder.SetEnabled or function(self, enabled)
            if self.Slider then
                if enabled then
                    self.Slider:Enable()
                else
                    self.Slider:Disable()
                end
                self.Slider:EnableMouse(enabled)
            end
            if self.DecrementButton then
                if enabled then
                    self.DecrementButton:Enable()
                else
                    self.DecrementButton:Disable()
                end
            end
            if self.IncrementButton then
                if enabled then
                    self.IncrementButton:Enable()
                else
                    self.IncrementButton:Disable()
                end
            end
            self:SetAlpha(enabled and 1 or 0.4)
        end
        holder.SetValue = holder.SetValue or function(self, nextValue)
            if self.Slider then
                self.Slider:SetValue(nextValue)
            end
        end

        slider:SetValue(value)
        UpdateLabel(value)
        return holder
    end

    local legacy = CreateFrame("Slider", nil, parent, "OptionsSliderTemplate")
    legacy:SetFrameLevel((parent:GetFrameLevel() or 1) + 2)
    legacy:EnableMouse(true)
    legacy:SetMinMaxValues(minValue, maxValue)
    legacy:SetValueStep(step)
    legacy:SetObeyStepOnDrag(true)
    legacy._min, legacy._max, legacy._step = minValue, maxValue, step
    if legacy.SetThumbTexture then
        legacy:SetThumbTexture("Interface\\Buttons\\UI-SliderBar-Button-Horizontal")
    end
    legacy.Text = legacy.Text or legacy:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    legacy.Text:ClearAllPoints()
    legacy.Text:SetPoint("BOTTOM", legacy, "TOP", 0, 2)
    if legacy.Low and legacy.High then
        legacy.Low:ClearAllPoints()
        legacy.Low:SetPoint("TOPLEFT", legacy, "BOTTOMLEFT", 0, -8)
        legacy.Low:SetText(lowText or minValue)
        legacy.High:ClearAllPoints()
        legacy.High:SetPoint("TOPRIGHT", legacy, "BOTTOMRIGHT", 0, -8)
        legacy.High:SetText(highText or maxValue)
    end

    local function UpdateLabel(rawValue)
        local rounded = math.floor((tonumber(rawValue) or 0) + 0.5)
        if legacy.Text then
            legacy.Text:SetText(string.format("%s: %d%%", label, rounded))
        end
    end

    legacy:SetScript("OnValueChanged", function(_, rawValue)
        local rounded = math.floor((tonumber(rawValue) or 0) + 0.5)
        UpdateLabel(rounded)
        if onChanged then
            onChanged(rounded)
        end
    end)

    local function Nudge(delta)
        local current = legacy:GetValue() or value
        local nextValue = math.min(legacy._max, math.max(legacy._min, current + (delta * legacy._step)))
        legacy:SetValue(nextValue)
    end

    local left = CreateFrame("Button", nil, legacy, "UIPanelButtonTemplate")
    left:SetSize(18, 18)
    left:SetText("<")
    left:SetPoint("RIGHT", legacy, "LEFT", -4, 0)
    left:SetScript("OnClick", function()
        Nudge(-1)
    end)
    legacy._left = left

    local right = CreateFrame("Button", nil, legacy, "UIPanelButtonTemplate")
    right:SetSize(18, 18)
    right:SetText(">")
    right:SetPoint("LEFT", legacy, "RIGHT", 4, 0)
    right:SetScript("OnClick", function()
        Nudge(1)
    end)
    legacy._right = right
    legacy.SetEnabled = legacy.SetEnabled or function(self, enabled)
        if enabled then
            self:Enable()
        else
            self:Disable()
        end
        self:EnableMouse(enabled)
        if self._left then
            if enabled then
                self._left:Enable()
            else
                self._left:Disable()
            end
        end
        if self._right then
            if enabled then
                self._right:Enable()
            else
                self._right:Disable()
            end
        end
        self:SetAlpha(enabled and 1 or 0.4)
    end

    legacy:SetValue(value)
    UpdateLabel(value)
    return legacy
end

local function CreateMainActionButton(parent, text, onClick)
    local button
    if WOW_PROJECT_ID == WOW_PROJECT_MAINLINE then
        local ok, modern = pcall(CreateFrame, "Button", nil, parent, "UIMenuButtonStretchTemplate")
        if ok and modern then
            button = modern
            button:SetHeight(24)
            if button.SetNormalFontObject then
                button:SetNormalFontObject("GameFontNormalSmall")
            end
            if button.SetHighlightFontObject then
                button:SetHighlightFontObject("GameFontHighlightSmall")
            end
            if button.SetDisabledFontObject then
                button:SetDisabledFontObject("GameFontDisableSmall")
            end
        end
    end
    if not button then
        button = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
        button:SetHeight(22)
    end
    button:SetText(text or "")
    button:SetScript("OnClick", onClick)
    return button
end

local function InitLegacyScrollFrame(scrollFrame, panExtent)
    if not scrollFrame then
        return
    end
    local scrollBar = scrollFrame.ScrollBar
    if (not scrollBar) and scrollFrame.GetName then
        scrollBar = _G[(scrollFrame:GetName() or "") .. "ScrollBar"]
    end
    if ScrollUtil and ScrollUtil.InitScrollFrameWithScrollBar and scrollBar and scrollBar.RegisterCallback then
        ScrollUtil.InitScrollFrameWithScrollBar(scrollFrame, scrollBar)
        if panExtent and scrollFrame.SetPanExtent then
            scrollFrame:SetPanExtent(panExtent)
        end
        scrollFrame:EnableMouseWheel(true)
        return
    end

    scrollFrame:EnableMouseWheel(true)
    if scrollBar and not scrollFrame:GetScript("OnMouseWheel") then
        scrollFrame:SetScript("OnMouseWheel", function(self, delta)
            local range = self:GetVerticalScrollRange() or 0
            local current = self:GetVerticalScroll() or 0
            local step = panExtent or 30
            local nextValue = math.max(0, math.min(range, current - ((delta or 0) * step)))
            self:SetVerticalScroll(nextValue)
        end)
    end
end

local function CreateOptionsSection(parent, label, x, y, width)
    local frame = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    frame:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y)
    frame:SetWidth(width)
    frame:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = false,
        edgeSize = 12,
        insets = { left = 4, right = 4, top = 4, bottom = 4 },
    })
    frame:SetBackdropColor(0.05, 0.05, 0.07, 0.90)
    frame:SetBackdropBorderColor(0.20, 0.20, 0.24, 0.90)

    frame.header = frame:CreateTexture(nil, "ARTWORK")
    frame.header:SetColorTexture(1, 1, 1, 0.04)
    frame.header:SetPoint("TOPLEFT", frame, "TOPLEFT", 4, -4)
    frame.header:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -4, -4)
    frame.header:SetHeight(24)

    frame.title = frame:CreateFontString(nil, "ARTWORK", "GameFontNormal")
    frame.title:SetPoint("TOPLEFT", frame, "TOPLEFT", 12, -10)
    frame.title:SetText(label)

    frame.pad = 12
    frame.innerY = -36
    return frame
end

local function FinishOptionsSection(frame, extraBottom)
    local padding = extraBottom or 12
    frame:SetHeight(math.abs(frame.innerY) + padding)
    return frame:GetHeight()
end

local function AddOptionsSectionNote(frame, text, x, y, width)
    local note = frame:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
    note:SetPoint("TOPLEFT", frame, "TOPLEFT", x or frame.pad, y or frame.innerY)
    note:SetWidth(width or (frame:GetWidth() - ((frame.pad or 12) * 2)))
    note:SetJustifyH("LEFT")
    note:SetSpacing(2)
    note:SetText(text or "")
    return note
end

function MM:BuildOptionsPanel()
    if self.optionsPanel then
        return self.optionsPanel
    end

    local panel = CreateFrame("Frame", "MurlocMinderOptionsPanel")
    panel.name = "Murloc Minder"
    self.optionsPanel = panel

    local title = panel:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge")
    title:SetPoint("TOPLEFT", panel, "TOPLEFT", 16, -16)
    title:SetText("Murloc Minder")

    local desc = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
    desc:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -6)
    desc:SetWidth(760)
    desc:SetJustifyH("LEFT")
    desc:SetSpacing(2)
    desc:SetText("Adjust tracker visibility, minimap visibility, interface scale, tracker scale, and murloc deletion settings.")

    local openPanelButton = CreateMainActionButton(panel, "Open main panel", function()
        if self.ShowMainFrame then
            self:ShowMainFrame()
        end
    end)
    openPanelButton:SetSize(128, 24)
    openPanelButton:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -16, -14)

    if type(self.EnsureDevInfoModule) == "function" then
        local module = self:EnsureDevInfoModule()
        if module and type(module.AttachButton) == "function" then
            panel.DevInfoButton = module:AttachButton(panel, {
                point = "RIGHT",
                relative_to = openPanelButton,
                relative_point = "LEFT",
                x = -8,
                y = 0,
                width = 96,
                height = 24,
                text = "Dev Info",
            })
        end
    end

    local scroll = CreateFrame("ScrollFrame", nil, panel, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", desc, "BOTTOMLEFT", -4, -8)
    scroll:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -26, 16)

    local content = CreateFrame("Frame", nil, scroll)
    content:SetPoint("TOPLEFT", scroll, "TOPLEFT", 0, 0)
    content:SetPoint("RIGHT", scroll, "RIGHT", 0, 0)
    content:SetSize(820, 420)
    scroll:SetScrollChild(content)
    scroll:HookScript("OnSizeChanged", function(_, width)
        if content and width and width > 0 then
            content:SetWidth(width - 2)
        end
    end)
    InitLegacyScrollFrame(scroll, 28)

    local layoutPadding = 12
    local sectionGap = 3
    local contentWidth = 820
    local innerWidth = contentWidth - (layoutPadding * 2)
    local leftWidth = math.floor((innerWidth - sectionGap) * 0.42) - 35
    local rightWidth = leftWidth
    local leftX = layoutPadding
    local rightX = leftX + leftWidth + sectionGap
    local fullWidth = leftWidth + rightWidth + sectionGap
    local cursorY = -6
    local updatingUI = true

    local interfaceSection = CreateOptionsSection(content, "Interface", leftX, cursorY, leftWidth)
    local trackerSection = CreateOptionsSection(content, "Tracker", rightX, cursorY, rightWidth)

    local showMinimapCheck = CreateOptionsCheck(interfaceSection, "Show Minimap button", true, function(value)
        if updatingUI then
            return
        end
        self.db.profile.minimap = self.db.profile.minimap or {}
        self.db.profile.minimap.hide = not value
        if self.UpdateMinimapButtonVisibility then
            self:UpdateMinimapButtonVisibility()
        end
        if panel.RefreshControls then
            panel:RefreshControls()
        end
        self:RefreshUI(true)
    end)
    showMinimapCheck:SetPoint("TOPLEFT", interfaceSection, "TOPLEFT", interfaceSection.pad, interfaceSection.innerY)
    interfaceSection.innerY = interfaceSection.innerY - 34

    local uiScaleSlider = CreateOptionsSlider(interfaceSection, "UI Scale", 70, 130, 5, 100, function(value)
        if updatingUI then
            return
        end
        if self.ApplyMainFrameScale then
            self:ApplyMainFrameScale((tonumber(value) or 100) / 100)
        end
        if panel.RefreshControls then
            panel:RefreshControls()
        end
    end, "70%", "130%")
    uiScaleSlider:SetPoint("TOPLEFT", interfaceSection, "TOPLEFT", interfaceSection.pad - 8, interfaceSection.innerY - 4)
    interfaceSection.innerY = interfaceSection.innerY - 74

    local interfaceNote = AddOptionsSectionNote(
        interfaceSection,
        "Adjust the main panel size without changing your saved panel position.",
        interfaceSection.pad,
        interfaceSection.innerY,
        leftWidth - (interfaceSection.pad * 2)
    )
    interfaceSection.innerY = interfaceSection.innerY - ((interfaceNote:GetStringHeight() or 18) + 10)

    local showTrackerCheck = CreateOptionsCheck(trackerSection, "Show tracker", true, function(value)
        if updatingUI then
            return
        end
        self.db.profile.ui = self.db.profile.ui or {}
        self.db.profile.ui.showTracker = value
        if panel.RefreshControls then
            panel:RefreshControls()
        end
        self:RefreshUI(true)
    end)
    showTrackerCheck:SetPoint("TOPLEFT", trackerSection, "TOPLEFT", trackerSection.pad, trackerSection.innerY)
    trackerSection.innerY = trackerSection.innerY - 34

    local trackerScaleSlider = CreateOptionsSlider(trackerSection, "Tracker Scale", 70, 130, 5, 100, function(value)
        if updatingUI then
            return
        end
        if self.ApplyTrackerScale then
            self:ApplyTrackerScale((tonumber(value) or 100) / 100)
        end
        if panel.RefreshControls then
            panel:RefreshControls()
        end
    end, "70%", "130%")
    trackerScaleSlider:SetPoint("TOPLEFT", trackerSection, "TOPLEFT", trackerSection.pad - 8, trackerSection.innerY - 4)
    trackerSection.innerY = trackerSection.innerY - 74

    local trackerNote = AddOptionsSectionNote(
        trackerSection,
        "Scale the tracker uniformly for all tracker modes.",
        trackerSection.pad,
        trackerSection.innerY,
        rightWidth - (trackerSection.pad * 2)
    )
    trackerSection.innerY = trackerSection.innerY - ((trackerNote:GetStringHeight() or 18) + 10)

    local rowHeight = math.max(FinishOptionsSection(interfaceSection), FinishOptionsSection(trackerSection))
    interfaceSection:SetHeight(rowHeight)
    trackerSection:SetHeight(rowHeight)
    cursorY = cursorY - rowHeight - sectionGap

    local managementSection = CreateOptionsSection(content, "Murloc Management", leftX, cursorY, fullWidth)

    local deleteButton = CreateMainActionButton(managementSection, "Delete This Murloc", function()
        if self.ShowDeleteMurlocPrompt then
            self:ShowDeleteMurlocPrompt()
        end
    end)
    deleteButton:SetSize(168, 24)
    deleteButton:SetPoint("TOPLEFT", managementSection, "TOPLEFT", managementSection.pad, managementSection.innerY - 2)
    managementSection.innerY = managementSection.innerY - 38

    local deleteNote = AddOptionsSectionNote(managementSection, "", managementSection.pad, managementSection.innerY, fullWidth - (managementSection.pad * 2))
    managementSection.innerY = managementSection.innerY - 24

    local managementHelp = AddOptionsSectionNote(
        managementSection,
        "Type the confirmation phrase exactly as shown to delete the current murloc.",
        managementSection.pad,
        managementSection.innerY,
        fullWidth - (managementSection.pad * 2)
    )
    managementSection.innerY = managementSection.innerY - ((managementHelp:GetStringHeight() or 18) + 10)
    FinishOptionsSection(managementSection)

    content:SetHeight(math.abs(cursorY) + managementSection:GetHeight() + 24)

    panel.ScrollFrame = scroll
    panel.ScrollContent = content
    panel.ShowMinimapCheck = showMinimapCheck
    panel.ShowTrackerCheck = showTrackerCheck
    panel.UIScaleSlider = uiScaleSlider
    panel.TrackerScaleSlider = trackerScaleSlider
    panel.DeleteButton = deleteButton
    panel.DeleteNote = deleteNote
    panel.OpenPanelButton = openPanelButton

    panel.RefreshControls = function()
        if updatingUI then
            return
        end
        updatingUI = true

        self.db.profile.ui = self.db.profile.ui or {}
        self.db.profile.minimap = self.db.profile.minimap or {}

        local frameScale = tonumber((self.NormalizeMainFrameScale and self:NormalizeMainFrameScale(self.db.profile.frame.scale)) or self.db.profile.frame.scale or 1.0) or 1.0
        local trackerScale = tonumber((self.NormalizeTrackerScale and self:NormalizeTrackerScale(self.db.profile.ui.trackerScale)) or self.db.profile.ui.trackerScale or 1.0) or 1.0
        local deletePhrase = ""
        if self.GetDeleteConfirmationPhrase then
            deletePhrase = self:GetDeleteConfirmationPhrase()
        else
            local pet = self.GetPet and self:GetPet() or {}
            local petName = TrimString(pet and pet.name or "")
            if petName ~= "" then
                deletePhrase = petName
            elseif pet and tostring(pet.stage or "") == "egg" then
                deletePhrase = "the egg"
            else
                deletePhrase = "unnamed murloc"
            end
        end

        if showMinimapCheck then
            showMinimapCheck:SetChecked(not (self.db.profile.minimap.hide == true))
        end
        if showTrackerCheck then
            showTrackerCheck:SetChecked(self.db.profile.ui.showTracker ~= false)
        end
        if uiScaleSlider and uiScaleSlider.SetValue then
            uiScaleSlider:SetValue(math.floor((frameScale * 100) + 0.5))
        end
        if trackerScaleSlider and trackerScaleSlider.SetValue then
            trackerScaleSlider:SetValue(math.floor((trackerScale * 100) + 0.5))
        end
        if deleteNote then
            deleteNote:SetText(string.format("Confirmation phrase: '%s'", deletePhrase))
        end

        updatingUI = false
    end

    updatingUI = false
    panel:SetScript("OnShow", function()
        if panel.RefreshControls then
            panel:RefreshControls()
        end
    end)
    panel:RefreshControls()

    return panel
end

function MM:RegisterSharedSettings()
    if self._sharedSettingsRegistered then
        return true
    end

    local launcher = ns.KagrokSharedLauncher
    if type(launcher) ~= "table" or type(launcher.RegisterSettingsPage) ~= "function" then
        return nil
    end

    launcher:RegisterSettingsPage({
        id = ADDON_NAME,
        addon_name = ADDON_NAME,
        name = "Murloc Minder",
        priority = GetLauncherPriority(),
        get_panel = function()
            return self:BuildOptionsPanel()
        end,
        on_registered = function(category)
            self.optionsCategory = category
            self.optionsCategoryName = "Murloc Minder"
        end,
    })

    self._sharedSettingsRegistered = true
    return true
end

function MM:OpenOptions()
    local launcher = ns.KagrokSharedLauncher
    if type(launcher) == "table" and type(launcher.OpenSettings) == "function" then
        return launcher:OpenSettings(ADDON_NAME)
    end
    return false
end

function MM:RegisterChatCommand(command, handler)
    local token = "MURLOCMINDER_" .. command:gsub("[^%w]", ""):upper()
    local slashKey = "SLASH_" .. token .. "1"
    _G[slashKey] = "/" .. command

    SlashCmdList[token] = function(msg)
        if type(handler) == "function" then
            handler(self, msg)
            return
        end

        if type(handler) == "string" and type(self[handler]) == "function" then
            self[handler](self, msg)
        end
    end
end

function MM:ScheduleRepeatingTimer(method, interval)
    local callback
    if type(method) == "string" then
        callback = function()
            if type(self[method]) == "function" then
                self[method](self)
            end
        end
    else
        callback = function()
            method(self)
        end
    end

    return C_Timer.NewTicker(interval, callback)
end

function MM:CancelTimer(timer)
    if timer and timer.Cancel then
        timer:Cancel()
    end
end

function MM:ResetProfile()
    MurlocMinderDB = DeepCopy(defaults)
    self.db = MurlocMinderDB
end

function MM:OnInitialize()
    if type(MurlocMinderDB) ~= "table" then
        MurlocMinderDB = DeepCopy(defaults)
    else
        MergeDefaults(MurlocMinderDB, defaults)
    end

    self.db = MurlocMinderDB

    self:RegisterChatCommand("murlocminder", "HandleSlash")
    self:RegisterChatCommand("mm", "HandleSlash")
    self:RegisterChatCommand("mminder", "HandleSlash")
    self:RegisterChatCommand("murloc", "HandleSlash")
    self:RegisterChatCommand("mmdev", "HandleDevSlash")

    self:InitializePetState()
    self:InitializeUI()
    if self.EvaluateTraitPairAchievements then
        self:EvaluateTraitPairAchievements()
    end
    if self.db and self.db.profile and self.db.profile.welcomeSeen ~= true and self.ShowMainFrame then
        self:ShowMainFrame()
    end
    self:SetupLauncher()
end

function MM:HandleDevSlash()
    self.db.profile.dev = self.db.profile.dev or {}
    local enabled = not (self.db.profile.dev.showDevTab == true)
    self.db.profile.dev.showDevTab = enabled

    if self.UpdateDevTabVisibility then
        self:UpdateDevTabVisibility()
    end
    if self.RefreshUI then
        self:RefreshUI(true)
    end

    self:Print(enabled and "Dev tab enabled." or "Dev tab hidden.")
end

function MM:OnEnable()
    self:StartNeedsDecay()
end

function MM:OnDisable()
    if self.decayTimer then
        self:CancelTimer(self.decayTimer)
        self.decayTimer = nil
    end

    if self.hopTimer then
        self:CancelTimer(self.hopTimer)
        self.hopTimer = nil
    end
end

function MM:HandleSlash(input)
    local cmd = TrimString((input or ""):lower())
    local primary, rest = cmd:match("^(%S+)%s*(.-)$")
    primary = primary or ""
    rest = rest or ""

    if cmd == "" or cmd == "open" or cmd == "show" then
        self:ShowMainFrame()
        return
    end

    if cmd == "toggle" then
        self:ToggleMainFrame()
        return
    end

    if cmd == "hide" then
        self:HideMainFrame()
        return
    end

    if cmd == "reset" then
        self:ResetProfile()
        self:InitializePetState(true)
        self:ResetMainFramePosition()
        self:RefreshUI(true)
        self:Print("Murloc Minder profile reset.")
        return
    end

    if primary == "creset" then
        local slot = tonumber((rest or ""):match("^(%d+)"))
        if not slot or slot < 1 or slot > 4 then
            self:Print("Usage: /mm creset <1-4>")
            return
        end
        if self.DevCompleteChallengeSlot then
            local ok, msg = self:DevCompleteChallengeSlot(slot)
            if msg and msg ~= "" then
                self:Print(msg)
            end
            if ok and self.RefreshUI then
                self:RefreshUI(true)
            end
            return
        end
    end

    if primary == "telemetry" or primary == "telem" then
        self:ShowTelemetryReport(true)
        return
    end

    if primary == "devinfo" then
        self:ShowDeveloperInfoPanel()
        return
    end

    if primary == "options" or primary == "config" or primary == "settings" then
        self:OpenOptions()
        return
    end

    self:Print("Commands: /murlocminder open|hide|toggle|reset|creset <1-4>|telemetry|options|devinfo")
end

function MM:SetupLauncher()
    self:RegisterSharedSettings()
    self:EnsureMinimapButton()
    self:UpdateMinimapButtonVisibility()

    if type(LibStub) ~= "function" then
        return
    end

    local ldb = LibStub("LibDataBroker-1.1", true)
    local icon = LibStub("LibDBIcon-1.0", true)
    if ldb and not self.dataObject then
        self.dataObject = ldb:NewDataObject("MurlocMinder", {
            type = "launcher",
            text = "Murloc Minder",
            icon = ICON_MURLOC,
            OnClick = function(_, button)
                if button == "RightButton" then
                    self:ShowMinimapContextMenu(Minimap or UIParent)
                    return
                end
                if self.ShowMainFrame then
                    self:ShowMainFrame()
                end
            end,
            OnTooltipShow = function(tooltip)
                tooltip:AddLine("Murloc Minder")
                tooltip:AddLine("Left Click: Open main panel", 1, 1, 1)
                tooltip:AddLine("Right Click: Options", 1, 1, 1)
                tooltip:AddLine("Slash: /murlocminder", 0.6, 0.85, 1)
            end,
        })
    end

    if icon and icon.Hide then
        pcall(function()
            icon:Hide("MurlocMinder")
        end)
    end
end

local function NormalizeActivityMeta(meta)
    if type(meta) == "table" then
        local source = type(meta.source) == "string" and TrimString(meta.source) or ""
        local tone = type(meta.tone) == "string" and TrimString(meta.tone) or ""
        local need = type(meta.need) == "string" and TrimString(meta.need) or ""
        if source == "" and meta.benign == true then
            source = "autonomous"
        end
        return {
            source = source ~= "" and source or nil,
            tone = tone ~= "" and tone or nil,
            need = need ~= "" and need or nil,
        }
    end
    if meta == true then
        return { source = "autonomous" }
    end
    if type(meta) == "string" then
        local source = TrimString(meta)
        if source ~= "" then
            return { source = source }
        end
    end
    return {}
end

local function CollapseAdjacentDuplicateWords(text)
    local out = tostring(text or "")
    if out == "" then
        return out
    end

    local passes = 0
    while passes < 8 do
        local changed = false
        out = out:gsub("([%a][%a']*)(%s+)([%a][%a']*)", function(a, sep, b)
            if string.lower(a) == string.lower(b) then
                changed = true
                return a
            end
            return a .. sep .. b
        end)
        if not changed then
            break
        end
        passes = passes + 1
    end
    return out
end

function MM:GetActivityEntryLine(entry)
    if type(entry) == "table" then
        local line = TrimString(tostring(entry.line or ""))
        if line ~= "" then
            return line
        end

        local timestamp = TrimString(tostring(entry.timestamp or ""))
        local message = TrimString(tostring(entry.message or ""))
        if message ~= "" then
            if timestamp ~= "" then
                return string.format("[%s] %s", timestamp, message)
            end
            return message
        end
        return ""
    end

    if entry == nil then
        return ""
    end
    return tostring(entry)
end

function MM:PushActivity(text, meta)
    local skipAchievementCheck = type(meta) == "table" and meta.skipAchievementCheck == true
    local resolvedMeta = NormalizeActivityMeta(meta)
    local message = TrimString(tostring(text or ""))
    if (resolvedMeta.source == "autonomous" or resolvedMeta.source == "benign") and message ~= "" then
        message = CollapseAdjacentDuplicateWords(message)
    end
    if message == "" then
        return
    end

    self.db.profile.activityLog = self.db.profile.activityLog or {}
    self.db.profile.activityHistory = self.db.profile.activityHistory or {}
    local log = self.db.profile.activityLog
    local history = self.db.profile.activityHistory
    local timestamp = date("%H:%M")
    local line = string.format("[%s] %s", timestamp, message)
    local entry = {
        line = line,
        message = message,
        timestamp = timestamp,
        source = resolvedMeta.source,
        tone = resolvedMeta.tone,
        need = resolvedMeta.need,
    }

    table.insert(log, entry)
    table.insert(history, entry)

    while #log > 10 do
        table.remove(log, 1)
    end
    while #history > 5000 do
        table.remove(history, 1)
    end

    if not skipAchievementCheck and type(self.HandleAchievementActivityTrigger) == "function" then
        self:HandleAchievementActivityTrigger(message, resolvedMeta)
    end

    self:RefreshUI()
end

function MM:GetActivityText()
    local log = self.db.profile.activityLog or {}
    if #log == 0 then
        return "No activity yet. Hatch your first murloc egg."
    end

    local lines = {}
    for _, entry in ipairs(log) do
        local line = self:GetActivityEntryLine(entry)
        if line ~= "" then
            lines[#lines + 1] = line
        end
    end
    return table.concat(lines, "\n")
end

function MM:Clamp(value, minV, maxV)
    if value < minV then
        return minV
    end
    if value > maxV then
        return maxV
    end
    return value
end

function MurlocMinder_AddonCompartmentOnClick()
    local addon = _G.MurlocMinder
    if addon and addon.ToggleMainFrame then
        addon:ToggleMainFrame()
    end
end

function MurlocMinder_AddonCompartmentOnEnter(owner)
    local tooltipOwner = owner
    if type(tooltipOwner) ~= "table" or not tooltipOwner.IsObjectType then
        tooltipOwner = UIParent
    end
    GameTooltip:SetOwner(tooltipOwner, "ANCHOR_LEFT")
    GameTooltip:SetText("Murloc Minder")
    GameTooltip:AddLine("Open your murloc habitat", 1, 1, 1)
    GameTooltip:AddLine("Slash: /murlocminder", 0.6, 0.85, 1)
    GameTooltip:Show()
end

function MurlocMinder_AddonCompartmentOnLeave()
    GameTooltip:Hide()
end

local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("ADDON_LOADED")
eventFrame:RegisterEvent("PLAYER_LOGIN")
eventFrame:RegisterEvent("PLAYER_LOGOUT")

eventFrame:SetScript("OnEvent", function(_, event, arg1)
    if event == "ADDON_LOADED" and arg1 == addonName then
        MM:OnInitialize()
    elseif event == "PLAYER_LOGIN" then
        MM:OnEnable()
    elseif event == "PLAYER_LOGOUT" then
        MM:OnDisable()
    end
end)
