-- MoonHub Studio bridge. Allow HTTP requests in Studio settings before use.
local HttpService = game:GetService("HttpService")
local Selection = game:GetService("Selection")
local RunService = game:GetService("RunService")
local ServerStorage = game:GetService("ServerStorage")

local PLUGIN_VERSION = "1.6.0" -- keep in sync with the plugin-vX.Y.Z release tag
local API_BASE = "https://moonhub.dev/api"
local SITE_URL = "https://moonhub.dev"
local DOCS_URL = "https://js-bro-s.github.io/moonhub-studio-bridge/"
local CATEGORIES = {"FPS Viewmodel", "Melee & Combat", "Locomotion", "Emote", "Cinematic"}
local RIGS = {"R6", "R15", "FPS Arms", "Custom"}
local ACCENT = Color3.fromRGB(230, 105, 52)
local OK_COLOR = Color3.fromRGB(110, 190, 120)
local ERR_COLOR = Color3.fromRGB(240, 110, 90)
local GAP, RADIUS = 8, UDim.new(0, 5)

local toolbar = plugin:CreateToolbar("MoonHub")
local toggle = toolbar:CreateButton("MoonHub", "Open MoonHub animation bridge", "rbxassetid://6031075931")
toggle.ClickableWhenViewportHidden = true
local widgetInfo = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Float, true, false, 380, 510, 360, 480)
local widget = plugin:CreateDockWidgetPluginGui("MoonHubBridge_v1", widgetInfo)
widget.Title = "MoonHub · Animation bridge v" .. PLUGIN_VERSION
widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
toggle.Click:Connect(function() widget.Enabled = not widget.Enabled end)

local function getSetting(name, fallback)
    local value = plugin:GetSetting("MoonHub_" .. name)
    if value == nil then return fallback end
    return value
end
local function setSetting(name, value)
    plugin:SetSetting("MoonHub_" .. name, value)
end

local state = {
    apiKey = getSetting("ApiKey", ""),
    category = getSetting("Category", "Locomotion"),
    rig = getSetting("Rig", "R15"),
    username = nil,
}

-- Theme: every themed property is re-applied on ThemeChanged.
local themed = {}
local function guideColor(guide, modifier)
    return settings().Studio.Theme:GetColor(guide, modifier or Enum.StudioStyleGuideModifier.Default)
end
local function theme(instance, property, guide, modifier)
    table.insert(themed, {instance, property, guide, modifier})
    instance[property] = guideColor(guide, modifier)
    return instance
end
settings().Studio.ThemeChanged:Connect(function()
    for _, entry in ipairs(themed) do
        entry[1][entry[2]] = guideColor(entry[3], entry[4])
    end
end)

local function corner(parent)
    local item = Instance.new("UICorner"); item.CornerRadius = RADIUS; item.Parent = parent
end
local function padding(parent, all)
    local item = Instance.new("UIPadding")
    item.PaddingTop = UDim.new(0, all); item.PaddingBottom = UDim.new(0, all)
    item.PaddingLeft = UDim.new(0, all); item.PaddingRight = UDim.new(0, all); item.Parent = parent
end
local function stack(parent, gap)
    local item = Instance.new("UIListLayout")
    item.Padding = UDim.new(0, gap or GAP); item.SortOrder = Enum.SortOrder.LayoutOrder; item.Parent = parent
end

-- Shell: header 40, tab bar 32, body, status bar 30.
local HEADER, TABS, STATUS = 40, 32, 30
local root = Instance.new("Frame")
root.Name = "Root"; root.Size = UDim2.fromScale(1, 1); root.BorderSizePixel = 0; root.Parent = widget
theme(root, "BackgroundColor3", Enum.StudioStyleGuideColor.MainBackground)

local header = Instance.new("Frame")
header.Size = UDim2.new(1, 0, 0, HEADER); header.BorderSizePixel = 0; header.Parent = root
theme(header, "BackgroundColor3", Enum.StudioStyleGuideColor.Titlebar)
local title = Instance.new("TextLabel")
title.BackgroundTransparency = 1; title.Size = UDim2.new(1, -90, 1, 0); title.Position = UDim2.fromOffset(14, 0)
title.Font = Enum.Font.GothamBold; title.TextSize = 13; title.TextXAlignment = Enum.TextXAlignment.Left
title.Text = "MOONHUB  /  STUDIO BRIDGE  v" .. PLUGIN_VERSION; title.TextColor3 = ACCENT; title.Parent = header
local keyDot = Instance.new("TextLabel")
keyDot.BackgroundTransparency = 1; keyDot.Size = UDim2.new(0, 80, 1, 0); keyDot.Position = UDim2.new(1, -90, 0, 0)
keyDot.Font = Enum.Font.Gotham; keyDot.TextSize = 12; keyDot.TextXAlignment = Enum.TextXAlignment.Right; keyDot.Parent = header

local tabBar = Instance.new("Frame")
tabBar.Size = UDim2.new(1, 0, 0, TABS); tabBar.Position = UDim2.fromOffset(0, HEADER)
tabBar.BorderSizePixel = 0; tabBar.Parent = root
theme(tabBar, "BackgroundColor3", Enum.StudioStyleGuideColor.Titlebar)
local tabLayout = Instance.new("UIListLayout")
tabLayout.FillDirection = Enum.FillDirection.Horizontal; tabLayout.SortOrder = Enum.SortOrder.LayoutOrder
tabLayout.Parent = tabBar

local statusBar = Instance.new("TextButton")
statusBar.Size = UDim2.new(1, 0, 0, STATUS); statusBar.Position = UDim2.new(0, 0, 1, -STATUS)
statusBar.BorderSizePixel = 0; statusBar.AutoButtonColor = false; statusBar.Font = Enum.Font.Gotham; statusBar.TextSize = 12
statusBar.TextXAlignment = Enum.TextXAlignment.Left; statusBar.TextTruncate = Enum.TextTruncate.AtEnd
statusBar.Text = ""; statusBar.Parent = root
theme(statusBar, "BackgroundColor3", Enum.StudioStyleGuideColor.Titlebar)

local function setStatus(message, failed)
    statusBar.Text = "   " .. message
    statusBar.TextColor3 = failed and ERR_COLOR or OK_COLOR
end
statusBar.Activated:Connect(function() statusBar.Text = "" end)
setStatus("Ready. Click this bar to dismiss messages.", false)

local function refreshKeyDot()
    local hasKey = state.apiKey ~= ""
    keyDot.Text = state.username and ("● " .. state.username) or (hasKey and "● key set" or "○ no key")
    keyDot.TextColor3 = hasKey and OK_COLOR or ERR_COLOR
end
refreshKeyDot()

local tabs, tabButtons = {}, {}
local function selectTab(name)
    for tabName, frame in pairs(tabs) do frame.Visible = tabName == name end
    for tabName, tabButton in pairs(tabButtons) do
        local selected = tabName == name
        tabButton.TextColor3 = selected and ACCENT or guideColor(Enum.StudioStyleGuideColor.SubText)
        tabButton.Font = selected and Enum.Font.GothamBold or Enum.Font.Gotham
    end
end
local function addTab(name, order)
    local tabButton = Instance.new("TextButton")
    tabButton.Size = UDim2.new(1 / 3, 0, 1, 0); tabButton.BackgroundTransparency = 1
    tabButton.TextSize = 12; tabButton.Text = name; tabButton.LayoutOrder = order; tabButton.Parent = tabBar
    tabButton.Activated:Connect(function() selectTab(name) end)
    tabButtons[name] = tabButton
    local body = Instance.new("ScrollingFrame")
    body.Name = name; body.Visible = false; body.BorderSizePixel = 0; body.BackgroundTransparency = 1
    body.Position = UDim2.fromOffset(0, HEADER + TABS); body.Size = UDim2.new(1, 0, 1, -(HEADER + TABS + STATUS))
    body.AutomaticCanvasSize = Enum.AutomaticSize.Y; body.CanvasSize = UDim2.new()
    body.ScrollBarThickness = 6; body.Parent = root
    padding(body, 14); stack(body)
    tabs[name] = body
    return body
end

-- Widgets (parent is a tab body).
local order = 0
local function nextOrder() order += 1; return order end
local function label(parent, text, height, small)
    local item = Instance.new("TextLabel")
    item.BackgroundTransparency = 1; item.Size = UDim2.new(1, 0, 0, height or 20); item.LayoutOrder = nextOrder()
    item.Font = Enum.Font.GothamMedium; item.TextSize = small and 11 or 13; item.TextWrapped = true
    item.TextXAlignment = Enum.TextXAlignment.Left; item.TextYAlignment = Enum.TextYAlignment.Top
    item.Text = text; item.Parent = parent
    theme(item, "TextColor3", small and Enum.StudioStyleGuideColor.SubText or Enum.StudioStyleGuideColor.MainText)
    return item
end
local function field(parent, placeholder, value)
    local item = Instance.new("TextBox")
    item.Size = UDim2.new(1, 0, 0, 34); item.LayoutOrder = nextOrder(); item.BorderSizePixel = 0
    item.ClearTextOnFocus = false; item.Font = Enum.Font.Code; item.TextSize = 13; item.TextXAlignment = Enum.TextXAlignment.Left
    item.PlaceholderText = placeholder; item.Text = value or ""; item.Parent = parent
    theme(item, "BackgroundColor3", Enum.StudioStyleGuideColor.InputFieldBackground)
    theme(item, "TextColor3", Enum.StudioStyleGuideColor.MainText)
    theme(item, "PlaceholderColor3", Enum.StudioStyleGuideColor.SubText)
    corner(item); padding(item, 8)
    return item
end
local function button(parent, text, accent)
    local item = Instance.new("TextButton")
    item.Size = UDim2.new(1, 0, 0, accent and 40 or 34); item.LayoutOrder = nextOrder(); item.BorderSizePixel = 0
    item.Font = Enum.Font.GothamBold; item.TextSize = 12; item.Text = text; item.Parent = parent
    if accent then
        item.BackgroundColor3 = ACCENT; item.TextColor3 = Color3.fromRGB(255, 255, 249)
    else
        theme(item, "BackgroundColor3", Enum.StudioStyleGuideColor.Button)
        theme(item, "TextColor3", Enum.StudioStyleGuideColor.ButtonText)
    end
    corner(item)
    return item
end
local function dropdown(parent, values, initial, changed)
    local holder = Instance.new("Frame")
    holder.BackgroundTransparency = 1; holder.Size = UDim2.new(1, 0, 0, 34); holder.AutomaticSize = Enum.AutomaticSize.Y
    holder.LayoutOrder = nextOrder(); holder.Parent = parent
    stack(holder, 2)
    local current = table.find(values, initial) or 1
    local head = button(holder, "", false)
    head.LayoutOrder = 0
    local options = {}
    local function set(index, silent)
        current = index
        head.Text = values[index] .. "  ▾"
        for _, option in ipairs(options) do option.Visible = false end
        if not silent then changed(values[index]) end
    end
    for index, value in ipairs(values) do
        local option = button(holder, value, false)
        option.LayoutOrder = index; option.Visible = false
        option.Activated:Connect(function() set(index) end)
        options[index] = option
    end
    head.Activated:Connect(function()
        local open = not options[1].Visible
        for _, option in ipairs(options) do option.Visible = open end
    end)
    set(current, true)
    return function(value) set(table.find(values, value) or 1, true) end
end
local function card(parent)
    local frame = Instance.new("Frame")
    frame.Size = UDim2.new(1, 0, 0, 0); frame.AutomaticSize = Enum.AutomaticSize.Y; frame.BorderSizePixel = 0
    frame.LayoutOrder = nextOrder(); frame.Parent = parent
    theme(frame, "BackgroundColor3", Enum.StudioStyleGuideColor.InputFieldBackground)
    corner(frame); padding(frame, 10)
    local text = Instance.new("TextLabel")
    text.BackgroundTransparency = 1; text.Size = UDim2.new(1, 0, 0, 0); text.AutomaticSize = Enum.AutomaticSize.Y
    text.Font = Enum.Font.Gotham; text.TextSize = 12; text.TextWrapped = true; text.RichText = true
    text.TextXAlignment = Enum.TextXAlignment.Left; text.TextYAlignment = Enum.TextYAlignment.Top; text.Parent = frame
    theme(text, "TextColor3", Enum.StudioStyleGuideColor.MainText)
    return frame, text
end

local function findSequences(instance)
    local found = {}
    if instance:IsA("KeyframeSequence") then table.insert(found, instance) end
    for _, item in ipairs(instance:GetDescendants()) do
        if item:IsA("KeyframeSequence") then table.insert(found, item) end
    end
    return found
end
local function quaternion(cf)
    local axis, angle = cf:ToAxisAngle()
    local half = angle / 2
    local sinHalf = math.sin(half)
    return axis.X * sinHalf, axis.Y * sinHalf, axis.Z * sinHalf, math.cos(half)
end
local function transformValues(cf)
    local qx, qy, qz, qw = quaternion(cf)
    return {cf.X, cf.Y, cf.Z, qx, qy, qz, qw}
end
local function serializeSequence(sequence, sourceRoot)
    local frames = sequence:GetKeyframes()
    table.sort(frames, function(a, b) return a.Time < b.Time end)
    if #frames == 0 then error("Selected save has no keyframes.") end
    local byName, maxFrame, motors = {}, 0, {}
    for _, item in ipairs(sourceRoot:GetDescendants()) do
        if item:IsA("Motor6D") then
            local offsets = {c0 = transformValues(item.C0), c1 = transformValues(item.C1)}
            motors[item.Name] = offsets
            if item.Part1 then motors[item.Part1.Name] = offsets end
        end
    end
    for _, keyframe in ipairs(frames) do
        local frame = math.max(0, math.floor(keyframe.Time * 60 + 0.5))
        maxFrame = math.max(maxFrame, frame)
        for _, pose in ipairs(keyframe:GetDescendants()) do
            if pose:IsA("Pose") then
                local name = pose.Name
                local motorOffsets = motors[name] or {}
                byName[name] = byName[name] or { boneName = name, times = {}, values = {}, easing = {}, parent = pose.Parent:IsA("Pose") and pose.Parent.Name or nil, c0 = motorOffsets.c0, c1 = motorOffsets.c1 }
                local track = byName[name]
                local cf = pose.CFrame
                table.insert(track.times, frame)
                table.insert(track.easing, pose.EasingStyle.Name .. "/" .. pose.EasingDirection.Name)
                for _, value in ipairs(transformValues(cf)) do table.insert(track.values, value) end
            end
        end
    end
    local tracks = {}
    for _, track in pairs(byName) do table.insert(tracks, track) end
    table.sort(tracks, function(a, b) return a.boneName < b.boneName end)
    if #tracks == 0 then error("No animated poses found in selected save.") end
    return {schemaVersion = 1, rigType = state.rig, tracks = tracks}, maxFrame
end

-- Moon Animator 2 native saves (ServerStorage.MoonAnimator2Saves.<name>).
-- A save is a StringValue holding JSON ({Information, Items}) with one numbered child folder per item.
-- A "Rig" item holds one `_joint` folder per Motor6D: `_hier` ("Parent.Child"), `default` (the joint's C1) and
-- `_keyframes/<frame>/Values/0` (the animated C1). Easing lives in `_keyframes/<frame>/Eases/0/{Type,Params/Direction}`.
local MOON_EASE_STYLES = {Linear = "Linear", Constant = "Constant", Elastic = "Elastic", Cubic = "Cubic", Bounce = "Bounce"}
local MOON_EASE_DIRECTIONS = {In = "In", Out = "Out", InOut = "InOut"}

local function readMoonInfo(save)
    if not save:IsA("StringValue") or not string.find(save.Value, '"Items"', 1, true) then return nil end
    local ok, info = pcall(function() return HttpService:JSONDecode(save.Value) end)
    if ok and type(info) == "table" and type(info.Information) == "table" and type(info.Items) == "table" then return info end
    return nil
end

local function resolveMoonRig(path)
    if type(path) ~= "table" or type(path.InstanceNames) ~= "table" then return nil end
    local node = game
    for index = 2, #path.InstanceNames do
        node = node and node:FindFirstChild(path.InstanceNames[index])
    end
    if node and node ~= game then return node end
    return nil
end

-- Every Moon Animator rig save reachable from `instance`: the save itself, anything inside it, or a folder holding saves.
local function findMoonSaves(instance)
    local found, seen = {}, {}
    local function add(save)
        if seen[save] then return end
        local info = readMoonInfo(save)
        if not info then return end
        seen[save] = true
        local rigItems = 0
        for _, item in ipairs(info.Items) do
            if type(item) == "table" and type(item.Path) == "table" and item.Path.ItemType == "Rig" then rigItems += 1 end
        end
        for index, item in ipairs(info.Items) do
            local folder = save:FindFirstChild(tostring(index))
            if folder and type(item) == "table" and type(item.Path) == "table" and item.Path.ItemType == "Rig" and folder:FindFirstChild("Rig") then
                local names = item.Path.InstanceNames
                local name = save.Name
                if rigItems > 1 and type(names) == "table" and names[#names] then name = save.Name .. " · " .. tostring(names[#names]) end
                table.insert(found, {key = folder, name = name, moon = {save = save, info = info, index = index, path = item.Path}})
            end
        end
    end
    local node = instance
    while node and node ~= game do add(node); node = node.Parent end
    for _, item in ipairs(instance:GetDescendants()) do add(item) end
    return found
end

local function moonValue(folder)
    local direct = folder:FindFirstChild("0")
    if direct and direct:IsA("CFrameValue") then return direct.Value end
    for _, child in ipairs(folder:GetChildren()) do
        if child:IsA("CFrameValue") then return child.Value end
    end
    return nil
end

local function moonEasing(keyframe)
    local eases = keyframe:FindFirstChild("Eases")
    local ease = eases and (eases:FindFirstChild("0") or eases:GetChildren()[1])
    if not ease then return "Linear/In" end
    local typeValue = ease:FindFirstChild("Type")
    local params = ease:FindFirstChild("Params")
    local directionValue = params and params:FindFirstChild("Direction")
    local style = typeValue and typeValue:IsA("StringValue") and typeValue.Value or "Linear"
    local direction = directionValue and directionValue:IsA("StringValue") and directionValue.Value or "In"
    -- Styles Roblox poses lack (Sine, Quad, Back…) become Cubic, the closest smooth curve.
    return (MOON_EASE_STYLES[style] or "Cubic") .. "/" .. (MOON_EASE_DIRECTIONS[direction] or "In")
end

-- One entry per joint track: { boneName, parent, frames = {frame…}, transforms = {CFrame…}, eases = {…}, default }.
local function readMoonTracks(entry)
    local moon = entry.moon
    local rigFolder = moon.save[tostring(moon.index)]:FindFirstChild("Rig")
    if not rigFolder then error("Moon Animator save has no rig data.") end
    local tracks = {}
    for _, joint in ipairs(rigFolder:GetChildren()) do
        local hier, default, keys = joint:FindFirstChild("_hier"), joint:FindFirstChild("default"), joint:FindFirstChild("_keyframes")
        if hier and hier:IsA("StringValue") and default and default:IsA("CFrameValue") and keys then
            -- `_hier` is "Parent.Child", or just the joint's own name for a rig's top-level joints.
            local segments = string.split(hier.Value, ".")
            local boneName, parentName = segments[#segments], segments[#segments - 1]
            if boneName and boneName ~= "" then
                local points = {}
                for _, keyframe in ipairs(keys:GetChildren()) do
                    local frame = tonumber(keyframe.Name)
                    local values = keyframe:FindFirstChild("Values")
                    local animated = values and moonValue(values)
                    if frame and animated then
                        -- Moon Animator animates C1; the equivalent pose transform is C1animated⁻¹ · C1default.
                        table.insert(points, {frame = math.max(0, math.floor(frame + 0.5)), transform = animated:Inverse() * default.Value, ease = moonEasing(keyframe)})
                    end
                end
                table.sort(points, function(a, b) return a.frame < b.frame end)
                if #points > 0 then
                    table.insert(tracks, {boneName = boneName, parent = parentName ~= "" and parentName or nil, points = points})
                end
            end
        end
    end
    return tracks
end

local function describeMoon(entry)
    local tracks = readMoonTracks(entry)
    local frameSet, count, maxFrame, poses = {}, 0, 0, {}
    for _, track in ipairs(tracks) do
        poses[track.boneName] = true; count += 1
        for _, point in ipairs(track.points) do
            frameSet[point.frame] = true
            maxFrame = math.max(maxFrame, point.frame)
        end
    end
    local keyframes = 0
    for _ in pairs(frameSet) do keyframes += 1 end
    return keyframes, maxFrame, count, poses
end

local function serializeMoonSave(entry)
    local tracks = readMoonTracks(entry)
    if #tracks == 0 then error("No animated joints found in selected Moon Animator save.") end
    local rigModel = resolveMoonRig(entry.moon.path)
    local motors = {}
    if rigModel then
        for _, item in ipairs(rigModel:GetDescendants()) do
            if item:IsA("Motor6D") and item.Part1 then
                motors[item.Part1.Name] = {c0 = transformValues(item.C0), c1 = transformValues(item.C1)}
            end
        end
    end
    local out, maxFrame = {}, 0
    for _, track in ipairs(tracks) do
        local offsets = motors[track.boneName] or {}
        local item = {boneName = track.boneName, times = {}, values = {}, easing = {}, parent = track.parent, c0 = offsets.c0, c1 = offsets.c1}
        local previousEase = "Linear/In"
        for _, point in ipairs(track.points) do
            maxFrame = math.max(maxFrame, point.frame)
            table.insert(item.times, point.frame)
            -- Moon Animator stores a key's ease for the segment that leaves it; MoonHub stores it on the key the segment reaches.
            table.insert(item.easing, previousEase)
            previousEase = point.ease
            for _, value in ipairs(transformValues(point.transform)) do table.insert(item.values, value) end
        end
        table.insert(out, item)
    end
    table.sort(out, function(a, b) return a.boneName < b.boneName end)
    return {schemaVersion = 1, rigType = state.rig, tracks = out}, maxFrame
end

local function poseParent(name, rig)
    if name == "HumanoidRootPart" or name == "Root" then return nil end
    if rig == "R6" then
        if name == "Torso" then return "HumanoidRootPart" end
        if name == "Head" or name == "Left Arm" or name == "Right Arm" or name == "Left Leg" or name == "Right Leg" then return "Torso" end
        return "Torso"
    end
    if name == "LowerTorso" then return "HumanoidRootPart" end
    if name == "UpperTorso" then return "LowerTorso" end
    if name == "Head" or name == "LeftUpperArm" or name == "RightUpperArm" then return "UpperTorso" end
    if name == "LeftLowerArm" then return "LeftUpperArm" end
    if name == "RightLowerArm" then return "RightUpperArm" end
    if name == "LeftHand" then return "LeftLowerArm" end
    if name == "RightHand" then return "RightLowerArm" end
    if name == "LeftUpperLeg" or name == "RightUpperLeg" then return "LowerTorso" end
    if name == "LeftLowerLeg" then return "LeftUpperLeg" end
    if name == "RightLowerLeg" then return "RightUpperLeg" end
    if name == "LeftFoot" then return "LeftLowerLeg" end
    if name == "RightFoot" then return "RightLowerLeg" end
    return "HumanoidRootPart"
end


-- Tab: Browse.
local browseTab = addTab("Browse", 1)

local function importAnimation(code)
    local response = HttpService:RequestAsync({Url = API_BASE .. "/anim/" .. code, Method = "GET"})
    if not response.Success then error("Could not fetch " .. code .. " (" .. tostring(response.StatusCode) .. ").") end
    local animation = HttpService:JSONDecode(response.Body)
    local payload = animation.payload_json
    if not payload or type(payload.tracks) ~= "table" then error("Save has no supported normalized keyframe data.") end
    local times = {}
    for _, track in ipairs(payload.tracks) do for _, frame in ipairs(track.times) do times[frame] = true end end
    local sortedTimes = {}
    for frame in pairs(times) do table.insert(sortedTimes, frame) end
    table.sort(sortedTimes)
    if #sortedTimes == 0 then error("Save has no keyframes to import.") end
    local sequence = Instance.new("KeyframeSequence")
    sequence.Name = animation.title .. " (" .. code .. ")"
    sequence.Loop = false
    for _, frame in ipairs(sortedTimes) do
        local keyframe = Instance.new("Keyframe")
        keyframe.Name = "Frame " .. tostring(frame)
        keyframe.Time = frame / (animation.fps or 60)
        keyframe.Parent = sequence
        local poseCache = {}
        local function ensurePose(name)
            if poseCache[name] then return poseCache[name] end
            local pose = Instance.new("Pose")
            pose.Name = name
            pose.CFrame = CFrame.new()
            poseCache[name] = pose
            local parentName = poseParent(name, animation.rig_type or "R15")
            pose.Parent = parentName and ensurePose(parentName) or keyframe
            return pose
        end
        ensurePose("HumanoidRootPart")
        for _, track in ipairs(payload.tracks) do
            for index, trackFrame in ipairs(track.times) do
                if trackFrame == frame then
                    local offset = (index - 1) * 7
                    local v = track.values
                    local pose = ensurePose(track.boneName)
                    pose.CFrame = CFrame.new(v[offset+1], v[offset+2], v[offset+3], v[offset+4], v[offset+5], v[offset+6], v[offset+7])
                    break
                end
            end
        end
    end
    local folder = ServerStorage:FindFirstChild("MoonAnimator2Saves")
    if not folder then folder = Instance.new("Folder"); folder.Name = "MoonAnimator2Saves"; folder.Parent = ServerStorage end
    sequence.Parent = folder
    Selection:Set({sequence})
end
local function escapeRich(text)
    return (tostring(text):gsub("&", "&amp;"):gsub("<", "&lt;"):gsub(">", "&gt;"))
end
local function runImport(code)
    setStatus("Fetching " .. code .. "…", false)
    local ok, err = pcall(importAnimation, code)
    if ok then setStatus("Imported " .. code .. " into ServerStorage.MoonAnimator2Saves.", false)
    else setStatus(tostring(err), true) end
end

local BROWSE_LIMIT = 30
local browse = {query = "", category = nil, token = 0, expanded = nil, openRow = nil, rows = {}}

label(browseTab, "SEARCH", 15, true)
local searchField = field(browseTab, "title, code, or tag", "")

local chipBar = Instance.new("ScrollingFrame")
chipBar.Size = UDim2.new(1, 0, 0, 36); chipBar.LayoutOrder = nextOrder(); chipBar.BackgroundTransparency = 1
chipBar.BorderSizePixel = 0; chipBar.ScrollBarThickness = 3; chipBar.CanvasSize = UDim2.new()
chipBar.AutomaticCanvasSize = Enum.AutomaticSize.X; chipBar.ScrollingDirection = Enum.ScrollingDirection.X; chipBar.Parent = browseTab
local chipLayout = Instance.new("UIListLayout")
chipLayout.FillDirection = Enum.FillDirection.Horizontal; chipLayout.Padding = UDim.new(0, 6)
chipLayout.SortOrder = Enum.SortOrder.LayoutOrder; chipLayout.Parent = chipBar

local listStatus = label(browseTab, "", 20, true)
local resultsHolder = Instance.new("Frame")
resultsHolder.BackgroundTransparency = 1; resultsHolder.Size = UDim2.new(1, 0, 0, 0); resultsHolder.AutomaticSize = Enum.AutomaticSize.Y
resultsHolder.LayoutOrder = nextOrder(); resultsHolder.Parent = browseTab
stack(resultsHolder, 4)
local moreButton = button(browseTab, "LOAD MORE", false)
moreButton.Visible = false
local refreshButton = button(browseTab, "REFRESH", false)

label(browseTab, "IMPORT BY SHORT CODE", 15, true)
local codeField = field(browseTab, "MH-8492", "")
local importButton = button(browseTab, "FETCH SAVE INTO SERVERSTORAGE", false)
importButton.Activated:Connect(function()
    local code = (codeField.Text:upper():gsub("%s", ""))
    if not code:match("^MH%-%d%d%d%d$") then setStatus("Enter code like MH-8492.", true); return end
    runImport(code)
end)

-- Each result is one compact line. Selecting it opens its details underneath, with a button to use it.
local function renderRows(rows)
    for _, child in ipairs(resultsHolder:GetChildren()) do
        if child:IsA("GuiObject") then child:Destroy() end
    end
    browse.openRow = nil
    local subColor = guideColor(Enum.StudioStyleGuideColor.SubText):ToHex()
    for index, row in ipairs(rows) do
        local item = Instance.new("Frame")
        item.Size = UDim2.fromScale(1, 0); item.AutomaticSize = Enum.AutomaticSize.Y; item.LayoutOrder = index; item.BorderSizePixel = 0
        item.Parent = resultsHolder
        theme(item, "BackgroundColor3", Enum.StudioStyleGuideColor.InputFieldBackground)
        corner(item); stack(item, 0)

        local summary = "<b>" .. escapeRich(row.title) .. "</b>  <font color=\"#" .. subColor .. "\">" ..
            escapeRich(row.short_code .. " · " .. tostring(row.rig_type) .. " · " .. tostring(row.fps) .. "fps") .. "</font>"
        local rowHeader = Instance.new("TextButton")
        rowHeader.Size = UDim2.new(1, 0, 0, 26); rowHeader.LayoutOrder = 1; rowHeader.BackgroundTransparency = 1; rowHeader.BorderSizePixel = 0
        rowHeader.AutoButtonColor = false; rowHeader.Font = Enum.Font.Gotham; rowHeader.TextSize = 12; rowHeader.RichText = true
        rowHeader.TextWrapped = false; rowHeader.TextTruncate = Enum.TextTruncate.AtEnd; rowHeader.TextXAlignment = Enum.TextXAlignment.Left
        rowHeader.Parent = item
        theme(rowHeader, "TextColor3", Enum.StudioStyleGuideColor.MainText)
        local rowHeaderPadding = Instance.new("UIPadding"); rowHeaderPadding.PaddingLeft = UDim.new(0, 8); rowHeaderPadding.PaddingRight = UDim.new(0, 8); rowHeaderPadding.Parent = rowHeader

        local details = Instance.new("Frame")
        details.Size = UDim2.fromScale(1, 0); details.AutomaticSize = Enum.AutomaticSize.Y; details.LayoutOrder = 2
        details.BackgroundTransparency = 1; details.Visible = false; details.Parent = item
        stack(details, 6)
        local detailsPadding = Instance.new("UIPadding")
        detailsPadding.PaddingLeft = UDim.new(0, 8); detailsPadding.PaddingRight = UDim.new(0, 8); detailsPadding.PaddingBottom = UDim.new(0, 8); detailsPadding.Parent = details

        local tags = (row.tags and #row.tags > 0) and table.concat(row.tags, ", ") or "none"
        local text = Instance.new("TextLabel")
        text.BackgroundTransparency = 1; text.Size = UDim2.fromScale(1, 0); text.AutomaticSize = Enum.AutomaticSize.Y; text.LayoutOrder = nextOrder()
        text.Font = Enum.Font.Gotham; text.TextSize = 12; text.TextWrapped = true; text.RichText = true
        text.TextXAlignment = Enum.TextXAlignment.Left; text.TextYAlignment = Enum.TextYAlignment.Top
        text.Text = escapeRich(tostring(row.category) .. " · " .. tostring(row.duration_frames) .. " frames · by " .. tostring(row.author or "MoonHub animator") ..
            "   ♥ " .. tostring(row.likes_count or 0) .. "   ⬇ " .. tostring(row.downloads_count or 0)) .. "\n" ..
            escapeRich(row.description or "No description.") .. "\nTags: " .. escapeRich(tags)
        text.Parent = details
        theme(text, "TextColor3", Enum.StudioStyleGuideColor.MainText)

        local link = field(details, "", SITE_URL .. "/" .. row.short_code)
        link.TextEditable = false; link.Size = UDim2.new(1, 0, 0, 28)
        local use = button(details, "USE IN STUDIO", true)
        use.Size = UDim2.new(1, 0, 0, 32)
        use.Activated:Connect(function() runImport(row.short_code) end)

        local controller = {code = row.short_code}
        function controller.setOpen(open)
            details.Visible = open
            rowHeader.Text = (open and "▾  " or "▸  ") .. summary
        end
        controller.setOpen(false)
        rowHeader.Activated:Connect(function()
            local wasOpen = browse.openRow == controller
            if browse.openRow then browse.openRow.setOpen(false) end
            browse.openRow = nil; browse.expanded = nil
            if not wasOpen then controller.setOpen(true); browse.openRow = controller; browse.expanded = row.short_code end
        end)
        if browse.expanded == row.short_code then controller.setOpen(true); browse.openRow = controller end
    end
end

local function loadBrowse(append)
    browse.token += 1
    local token = browse.token
    listStatus.Text = "Loading…"; listStatus.TextColor3 = guideColor(Enum.StudioStyleGuideColor.SubText)
    local url = API_BASE .. "/animations?view=summary&limit=" .. BROWSE_LIMIT
    if append then url ..= "&offset=" .. #browse.rows end
    if browse.query ~= "" then url ..= "&q=" .. HttpService:UrlEncode(browse.query) end
    if browse.category then url ..= "&category=" .. HttpService:UrlEncode(browse.category) end
    local ok, result = pcall(function()
        local response = HttpService:RequestAsync({Url = url, Method = "GET"})
        if not response.Success then error("MoonHub returned " .. tostring(response.StatusCode) .. ".") end
        return HttpService:JSONDecode(response.Body)
    end)
    if token ~= browse.token then return end
    if not ok then
        if not append then browse.rows = {}; renderRows({}); moreButton.Visible = false end
        listStatus.Text = tostring(result):find("not enabled") and "HTTP blocked. Enable in Game Settings → Security." or tostring(result)
        listStatus.TextColor3 = ERR_COLOR
        return
    end
    if append then table.move(result, 1, #result, #browse.rows + 1, browse.rows) else browse.rows = result end
    renderRows(browse.rows)
    moreButton.Visible = #result == BROWSE_LIMIT
    listStatus.Text = #browse.rows == 0 and "No animations match." or (#browse.rows .. " animations")
end

local chipButtons = {}
local function renderChips()
    for name, chip in pairs(chipButtons) do
        local selected = (browse.category == nil and name == "All") or name == browse.category
        chip.BackgroundColor3 = selected and ACCENT or guideColor(Enum.StudioStyleGuideColor.Button)
        chip.TextColor3 = selected and Color3.fromRGB(255, 255, 249) or guideColor(Enum.StudioStyleGuideColor.ButtonText)
    end
end
local chipNames = {"All", table.unpack(CATEGORIES)}
for index, name in ipairs(chipNames) do
    local chip = Instance.new("TextButton")
    chip.AutomaticSize = Enum.AutomaticSize.X; chip.Size = UDim2.new(0, 0, 0, 28); chip.LayoutOrder = index
    chip.BorderSizePixel = 0; chip.Font = Enum.Font.GothamMedium; chip.TextSize = 12; chip.Text = name; chip.Parent = chipBar
    corner(chip); padding(chip, 0)
    local chipPad = chip:FindFirstChildOfClass("UIPadding"); chipPad.PaddingLeft = UDim.new(0, 12); chipPad.PaddingRight = UDim.new(0, 12)
    chip.Activated:Connect(function()
        browse.category = name ~= "All" and name or nil
        renderChips(); task.spawn(loadBrowse)
    end)
    chipButtons[name] = chip
end
renderChips()

searchField:GetPropertyChangedSignal("Text"):Connect(function()
    local text = searchField.Text:match("^%s*(.-)%s*$")
    task.delay(0.3, function()
        if searchField.Text:match("^%s*(.-)%s*$") ~= text or browse.query == text then return end
        browse.query = text
        loadBrowse()
    end)
end)
refreshButton.Activated:Connect(function() task.spawn(loadBrowse) end)
moreButton.Activated:Connect(function() task.spawn(loadBrowse, true) end)

local browseLoaded = false
browseTab:GetPropertyChangedSignal("Visible"):Connect(function()
    if browseTab.Visible and not browseLoaded then browseLoaded = true; task.spawn(loadBrowse) end
end)

-- Publishing. The site's firewall rejects request bodies over 8 KB, so the animation goes straight to storage with a
-- short-lived URL and only two tiny requests reach the site: start the upload, then finish it. Older servers without
-- those routes get the classic single request.
local function shortBody(text)
    return string.sub(tostring(text), 1, 200)
end
local function publishToMoonHub(apiKey, details, payload)
    local headers = {["Content-Type"] = "application/json", Authorization = "Bearer " .. apiKey, ["X-MoonHub-Plugin-Version"] = PLUGIN_VERSION}
    local function post(path, data)
        return HttpService:RequestAsync({Url = API_BASE .. path, Method = "POST", Headers = headers, Body = HttpService:JSONEncode(data)})
    end
    local body = HttpService:JSONEncode(payload)
    local init = post("/upload/init", {size = #body, ext = "json"})
    if init.StatusCode == 404 then
        details.payload_json = payload
        local response = post("/upload", details)
        if not response.Success then error("Upload failed (" .. tostring(response.StatusCode) .. "): " .. shortBody(response.Body)) end
        return HttpService:JSONDecode(response.Body)
    end
    if not init.Success then error("Could not start upload (" .. tostring(init.StatusCode) .. "): " .. shortBody(init.Body)) end
    local ticket = HttpService:JSONDecode(init.Body)
    local stored = HttpService:RequestAsync({Url = ticket.upload_url, Method = "PUT", Headers = ticket.headers, Body = body})
    if not stored.Success then error("Storage upload failed (" .. tostring(stored.StatusCode) .. "): " .. shortBody(stored.Body)) end
    details.key = ticket.key
    local done = post("/upload/complete", details)
    if not done.Success then error("Upload failed (" .. tostring(done.StatusCode) .. "): " .. shortBody(done.Body)) end
    return HttpService:JSONDecode(done.Body)
end

-- Tab: Export (live selection).
local exportTab = addTab("Export", 2)
local candidates, chosen = {}, nil -- candidates: {sequence, root}
local setRigChoice
local refreshExport

label(exportTab, "SELECTION", 15, true)
local _, selectionText = card(exportTab)
local pickerHolder = Instance.new("Frame")
pickerHolder.BackgroundTransparency = 1; pickerHolder.Size = UDim2.new(1, 0, 0, 0); pickerHolder.AutomaticSize = Enum.AutomaticSize.Y
pickerHolder.LayoutOrder = nextOrder(); pickerHolder.Parent = exportTab
stack(pickerHolder, 2)
local previewButton = button(exportTab, "PREVIEW ON RIG", false)

-- Preview: play the exact payload that would be published on the rig, before uploading.
-- Like Moon Animator, it drives each joint's C1 (edit mode does not apply Motor6D.Transform), and puts every C1 back when it stops.
local preview = {connection = nil, original = {}}
local function easeAlpha(spec, t)
    local style, direction = string.match(spec or "Linear/In", "^([^/]*)/?(.*)$")
    if direction == "" then direction = "In" end
    if style == "Linear" then return t end
    if style == "Constant" then return t >= 1 and 1 or 0 end
    local curve
    if style == "Elastic" then
        curve = function(x) if x == 0 or x == 1 then return x end return -(2 ^ (10 * x - 10)) * math.sin((x * 10 - 10.75) * (2 * math.pi / 3)) end
    elseif style == "Bounce" then
        curve = function(x)
            x = 1 - x
            local n, d, r = 7.5625, 2.75, nil
            if x < 1 / d then r = n * x * x
            elseif x < 2 / d then x -= 1.5 / d; r = n * x * x + 0.75
            elseif x < 2.5 / d then x -= 2.25 / d; r = n * x * x + 0.9375
            else x -= 2.625 / d; r = n * x * x + 0.984375 end
            return 1 - r
        end
    else
        curve = function(x) return x * x * x end
    end
    if direction == "Out" then return 1 - curve(1 - t) end
    if direction == "InOut" then if t < 0.5 then return curve(t * 2) / 2 end return 1 - curve((1 - t) * 2) / 2 end
    return curve(t)
end
local function sampleTrack(track, frame)
    local times, values = track.times, track.values
    local hi = #times
    for index, time in ipairs(times) do
        if time >= frame then hi = index; break end
    end
    local lo = math.max(1, hi - 1)
    local function pose(index)
        local o = (index - 1) * 7
        return CFrame.new(values[o + 1], values[o + 2], values[o + 3], values[o + 4], values[o + 5], values[o + 6], values[o + 7])
    end
    local span = times[hi] - times[lo]
    local alpha = span > 0 and easeAlpha(track.easing and track.easing[hi], math.clamp((frame - times[lo]) / span, 0, 1)) or 0
    return pose(lo):Lerp(pose(hi), alpha)
end
local function findPreviewRig(entry)
    if entry.moon then
        local rig = resolveMoonRig(entry.moon.path)
        if rig then return rig end
    end
    for _, item in ipairs(Selection:Get()) do
        local model = item:IsA("Model") and item or item:FindFirstAncestorOfClass("Model")
        if model and model:FindFirstChildWhichIsA("Motor6D", true) then return model end
    end
    return nil
end
local function stopPreview()
    if preview.connection then preview.connection:Disconnect(); preview.connection = nil end
    for motor, c1 in pairs(preview.original) do
        if motor.Parent then motor.C1 = c1 end
    end
    preview.original = {}
    previewButton.Text = "PREVIEW ON RIG"
end
local function startPreview()
    local entry = chosen
    if not entry then setStatus("Select a KeyframeSequence or Moon Animator save first.", true); return end
    local ok, payload, duration = pcall(function()
        if entry.moon then return serializeMoonSave(entry) end
        return serializeSequence(entry.sequence, entry.root)
    end)
    if not ok then setStatus(tostring(payload), true); return end
    local rig = findPreviewRig(entry)
    if not rig then
        setStatus(entry.moon and ("Rig " .. tostring(entry.moon.path.InstanceNames[#entry.moon.path.InstanceNames]) .. " is not in this place.") or "Select a rig (a Model with Motor6D joints) together with the sequence to preview.", true)
        return
    end
    local joints = {}
    for _, track in ipairs(payload.tracks) do
        for _, item in ipairs(rig:GetDescendants()) do
            if item:IsA("Motor6D") and item.Part1 and item.Part1.Name == track.boneName then
                preview.original[item] = item.C1
                table.insert(joints, {motor = item, c1 = item.C1, track = track})
                break
            end
        end
    end
    if #joints == 0 then preview.original = {}; setStatus("No joints on " .. rig.Name .. " match this animation.", true); return end
    local started, length = os.clock(), math.max(duration, 1)
    preview.connection = RunService.Heartbeat:Connect(function()
        -- Loop, holding the last pose for a third of a second.
        local frame = math.min(((os.clock() - started) * 60) % (length + 20), length)
        for _, joint in ipairs(joints) do
            if joint.motor.Parent then joint.motor.C1 = joint.c1 * sampleTrack(joint.track, frame):Inverse() end
        end
    end)
    previewButton.Text = "STOP PREVIEW"
    setStatus("Previewing " .. entry.name .. " on " .. rig.Name .. " (" .. #joints .. " of " .. #payload.tracks .. " joints). Loops until stopped.", false)
end
previewButton.Activated:Connect(function()
    if preview.connection then stopPreview(); setStatus("Preview stopped.", false) else startPreview() end
end)
plugin.Unloading:Connect(stopPreview)

label(exportTab, "TITLE", 15, true)
local titleField = field(exportTab, "Animation title", "")
label(exportTab, "CATEGORY", 15, true)
dropdown(exportTab, CATEGORIES, state.category, function(value) state.category = value; setSetting("Category", value) end)
label(exportTab, "RIG TYPE", 15, true)
setRigChoice = dropdown(exportTab, RIGS, state.rig, function(value) state.rig = value; setSetting("Rig", value) end)
label(exportTab, "TAGS (comma separated)", 15, true)
local tagsField = field(exportTab, "moon-animator, studio", "moon-animator, studio")
local exportButton = button(exportTab, "PUBLISH TO MOONHUB", true)
local resultCard, resultText = card(exportTab)
resultCard.Visible = false
local resultCode = field(exportTab, "", "")
resultCode.TextEditable = false; resultCode.Visible = false

local function describe(entry)
    if entry.moon then return describeMoon(entry) end
    local frames = entry.sequence:GetKeyframes()
    local maxTime, poses, count = 0, {}, 0
    for _, keyframe in ipairs(frames) do
        maxTime = math.max(maxTime, keyframe.Time)
        for _, pose in ipairs(keyframe:GetDescendants()) do
            if pose:IsA("Pose") and not poses[pose.Name] then poses[pose.Name] = true; count += 1 end
        end
    end
    return #frames, math.floor(maxTime * 60 + 0.5), count, poses
end
local function detectRig(poses)
    if poses.UpperTorso or poses.LowerTorso then return "R15" end
    if poses.Torso then return "R6" end
    return nil
end
local function canPublish()
    return chosen ~= nil and state.apiKey ~= ""
end
local function renderButton()
    exportButton.BackgroundTransparency = canPublish() and 0 or 0.6
end

local function renderPicker()
    for _, child in ipairs(pickerHolder:GetChildren()) do
        if child:IsA("GuiObject") then child:Destroy() end
    end
    if #candidates < 2 then return end
    for index, entry in ipairs(candidates) do
        local option = button(pickerHolder, entry.name, false)
        option.LayoutOrder = index
        if entry == chosen then option.TextColor3 = ACCENT end
        option.Activated:Connect(function() chosen = entry; titleField.Text = entry.name; refreshExport() end)
    end
end

refreshExport = function()
    renderPicker()
    renderButton()
    if #candidates == 0 then
        local selection = Selection:Get()
        selectionText.Text = (#selection == 0) and "Nothing selected.\nSelect a KeyframeSequence, a Moon Animator save, or a folder holding saves." or
            ('<font color="#f06e5a">No KeyframeSequence or Moon Animator save found in selection.</font>')
        return
    end
    local keyframes, frames, poseCount = describe(chosen)
    local lines = {"<b>" .. chosen.name .. "</b>", keyframes .. " keyframes · " .. frames .. " frames @60fps · " .. poseCount .. " poses"}
    if keyframes == 0 or poseCount == 0 then
        table.insert(lines, '<font color="#f06e5a">Save has no animated poses.</font>')
    elseif #candidates > 1 then
        table.insert(lines, "Pick one below (" .. #candidates .. " found).")
    end
    if state.apiKey == "" then table.insert(lines, '<font color="#f06e5a">Add API key in Settings.</font>') end
    selectionText.Text = table.concat(lines, "\n")
end

local function onSelectionChanged()
    stopPreview()
    local previous = chosen and chosen.key
    candidates = {}
    local seen = {}
    local function addCandidate(entry)
        if seen[entry.key] then return end
        seen[entry.key] = true
        table.insert(candidates, entry)
    end
    for _, item in ipairs(Selection:Get()) do
        for _, sequence in ipairs(findSequences(item)) do
            addCandidate({key = sequence, sequence = sequence, name = sequence.Name, root = item})
        end
        for _, entry in ipairs(findMoonSaves(item)) do addCandidate(entry) end
    end
    chosen = nil
    for _, entry in ipairs(candidates) do
        if entry.key == previous then chosen = entry end
    end
    if not chosen then
        chosen = candidates[1]
        if chosen then
            titleField.Text = chosen.name
            local _, _, _, poses = describe(chosen)
            local rig = detectRig(poses)
            if rig then state.rig = rig; setRigChoice(rig) end
        end
    end
    refreshExport()
end
Selection.SelectionChanged:Connect(onSelectionChanged)
onSelectionChanged()

local publishing = false
exportButton.Activated:Connect(function()
    if publishing then return end
    if state.apiKey == "" then setStatus("Add API key in Settings. Create one at moonhub.dev/dashboard.", true); selectTab("Settings"); return end
    if not chosen then setStatus("Select a KeyframeSequence or Moon Animator save first.", true); return end
    local title = titleField.Text:match("^%s*(.-)%s*$")
    if title == "" then setStatus("Title is empty.", true); return end
    local entry, key = chosen, state.apiKey
    stopPreview()
    publishing = true
    exportButton.Text = "PUBLISHING…"
    setStatus("Publishing…", false)
    local ok, result = pcall(function()
        local payload, duration
        if entry.moon then payload, duration = serializeMoonSave(entry) else payload, duration = serializeSequence(entry.sequence, entry.root) end
        return publishToMoonHub(key, {
            title = title, category = state.category, rig_type = state.rig, duration_frames = math.max(duration, 1), fps = 60, tags = tagsField.Text,
        }, payload)
    end)
    publishing = false
    exportButton.Text = "PUBLISH TO MOONHUB"
    if ok then
        setStatus("Published " .. result.short_code, false)
        resultText.Text = "<b>Published " .. result.short_code .. "</b>\nCopy the link below."
        resultCode.Text = result.short_code and (SITE_URL .. "/" .. result.short_code) or tostring(result.url)
        resultCard.Visible = true; resultCode.Visible = true
    else setStatus(tostring(result), true) end
end)

-- Tab: Settings.
local settingsTab = addTab("Settings", 3)
label(settingsTab, "API KEY", 15, true)
local keyField = field(settingsTab, "mh_live_…", "")
local keyShown = false
local function renderKey()
    keyField.Text = keyShown and state.apiKey or string.rep("•", #state.apiKey)
end
renderKey()
keyField.Focused:Connect(function() keyField.Text = state.apiKey end)
keyField.FocusLost:Connect(function()
    state.apiKey = (keyField.Text:gsub("%s", ""))
    state.username = nil
    setSetting("ApiKey", state.apiKey)
    refreshKeyDot(); renderKey(); refreshExport()
end)
local showButton = button(settingsTab, "SHOW KEY", false)
showButton.Activated:Connect(function()
    keyShown = not keyShown
    showButton.Text = keyShown and "HIDE KEY" or "SHOW KEY"
    renderKey()
end)
local validateButton = button(settingsTab, "VALIDATE KEY", false)
validateButton.Activated:Connect(function()
    if state.apiKey == "" then setStatus("Add API key first.", true); return end
    setStatus("Validating…", false)
    local ok, response = pcall(function()
        return HttpService:RequestAsync({Url = API_BASE .. "/me", Method = "GET", Headers = {Authorization = "Bearer " .. state.apiKey}})
    end)
    if not ok then setStatus("HTTP blocked. Enable in Game Settings → Security.", true); return end
    if response.StatusCode == 401 then state.username = nil; refreshKeyDot(); setStatus("Key invalid or revoked.", true); return end
    if not response.Success then setStatus("MoonHub returned " .. tostring(response.StatusCode) .. ".", true); return end
    state.username = HttpService:JSONDecode(response.Body).username
    refreshKeyDot()
    setStatus("Key valid. Signed in as " .. state.username .. ".", false)
end)
label(settingsTab, "Create keys at " .. SITE_URL .. "/dashboard", 18, true)

label(settingsTab, "CONNECTION", 15, true)
local httpButton = button(settingsTab, "CHECK HTTP ACCESS", false)
httpButton.Activated:Connect(function()
    setStatus("Checking…", false)
    local ok, response = pcall(function()
        return HttpService:RequestAsync({Url = API_BASE .. "/animations?view=summary&limit=1", Method = "GET"})
    end)
    if not ok then setStatus("HTTP blocked. Enable in Game Settings → Security.", true)
    elseif not response.Success then setStatus("MoonHub returned " .. tostring(response.StatusCode) .. ".", true)
    else setStatus("HTTP enabled. MoonHub reachable.", false) end
end)

label(settingsTab, "PREFERENCES", 15, true)
local resetButton = button(settingsTab, "RESET SAVED SETTINGS", false)
resetButton.Activated:Connect(function()
    for _, name in ipairs({"ApiKey", "Category", "Rig"}) do setSetting(name, nil) end
    state.apiKey, state.category, state.rig, state.username = "", "Locomotion", "R15", nil
    keyShown = false; showButton.Text = "SHOW KEY"; renderKey(); refreshKeyDot(); setRigChoice(state.rig); refreshExport()
    setStatus("Saved settings cleared.", false)
end)
label(settingsTab, "Docs: " .. DOCS_URL, 34, true)

selectTab(state.apiKey == "" and "Settings" or "Browse")
