if not game or not game:GetService("Players") then
    error("[GAG Hub] Must run inside Roblox game")
end

---------------------------------------------------------------
-- CONFIG
---------------------------------------------------------------

local VERSION = "ersion - July"

local Config = {
    Features = {
        AutoHarvest = false, AutoSell = false, AutoWater = false,
        AutoPlant = false, RestockSniper = false, MutationTracker = false,
        WeatherBot = false, StealBot = false, InventoryOptimizer = false,
        AutoBuyPet = false, AntiAfk = true, SeedPackClaimer = false,
        AutoJoinServer = false, AutoPetCatch = false, AutoCenterPlot = false,
    },
    Timings = {
        HarvestInterval = 0.2, SellInterval = 5, WaterInterval = 3,
        PlantInterval = 5, RestockPollInterval = 1, MutationScanInterval = 3,
        WeatherPollInterval = 5, StealInterval = 1.5, InventoryCheckInterval = 10,
        PetHatchInterval = 2, SeedPackPollInterval = 2, PetCatchInterval = 3,
        CenterPlotInterval = 5,
    },
    Restock = {
        TargetSeeds = {},
        BlacklistedSeeds = {},
    },
    Steal = { MinFruitValue = 10000, MaxAttemptsPerNight = 20, PreferMutations = true },
    Sell = { Mode = "all", UseDailyDeal = false },
    Plant = { OnlyEmptyPlots = true, PreferSeed = nil, GridSpacing = 3, PlantOrder = "Top", BlacklistMutated = true },
    Water = { WaterAll = false, WaterFullyGrown = false, RequiredCan = "" },
    Inventory = { FavoriteThreshold = 500, AutoPromote = true, DropThreshold = 5 },
    Pet = { MinRarity = "Rare", AutoSellUnwanted = false },
    Gear = { TargetGears = {} },
    Mutation = {
        AlertMutations = { "Rainbow", "Starstruck", "Gold", "Frozen", "Electric", "Bloodlit", "Chained" },
        PriceMultipliers = { Gold = 20, Rainbow = 50, Electric = 12, Frozen = 10, Bloodlit = 5, Chained = 8, Starstruck = 100 },
        LogToConsole = true,
    },
    UI = { Title = "🌿 Grow A Garden 2 🌿", Subtitle = "Grow A Garden 2", NotifyDuration = 5 },
    Server = { TargetJobId = "", AutoRejoin = true, RejoinDelay = 5, MaxRetries = 10 },
    PetCatch = { MinRarity = "Common", AutoReturn = true },
}

function Config.Notify(title, text, duration)
    pcall(function()
        game:GetService("StarterGui"):SetCore("SendNotification", {
            Title = title or "GAG Hub", Text = text or "",
            Duration = duration or Config.UI.NotifyDuration,
        })
    end)
end

---------------------------------------------------------------
-- CORE: NETWORKING (inlined from core/networking.lua)
---------------------------------------------------------------

local Networking = {}
local RS = game:GetService("ReplicatedStorage")

-- Internal cache
Networking._module = nil
Networking._cache = {}
Networking._connections = {}
Networking._log = true

---------------------------------------------------------------
-- RESOLVE NETWORKING MODULE
---------------------------------------------------------------

function Networking._resolve()
    if Networking._module then return Networking._module end

    -- Method 1: try require()
    local ok, result = pcall(function()
        local shared = RS:WaitForChild("SharedModules", 10)
        if not shared then error("SharedModules not found") end
        local net = shared:WaitForChild("Networking", 10)
        if not net then error("Networking not found") end
        return require(net)
    end)

    if ok and result and type(result) == "table" then
        Networking._module = result
        return result
    end

    -- Method 2: getgc — find already-loaded Networking table by checking for known keys
    local gcOk, gcResult = pcall(function()
        if not getgc then return nil end
        for _, v in pairs(getgc(true)) do
            if type(v) == "table" then
                -- Check for unique Networking structure: has Plant.PlantSeed AND Garden.CollectFruit AND SeedShop.PurchaseSeed
                local hasPlant = type(v.Plant) == "table" and type(v.Plant.PlantSeed) ~= "nil"
                local hasGarden = type(v.Garden) == "table" and type(v.Garden.CollectFruit) ~= "nil"
                local hasSeedShop = type(v.SeedShop) == "table" and type(v.SeedShop.PurchaseSeed) ~= "nil"
                if hasPlant and hasGarden and hasSeedShop then
                    return v
                end
            end
        end
        return nil
    end)

    if gcOk and gcResult and type(gcResult) == "table" then
        print("[GAG Hub] Networking resolved via getgc")
        Networking._module = gcResult
        return gcResult
    end

    -- Method 3: try require Packet directly, then build minimal net
    local pktOk, pktResult = pcall(function()
        local shared = RS:WaitForChild("SharedModules", 10)
        if not shared then error("SharedModules not found") end
        local pkt = shared:WaitForChild("Packet", 5)
        local net = shared:WaitForChild("Networking", 5)
        if not pkt or not net then return nil end
        -- Pre-require Packet so it's in module cache, then require Networking
        require(pkt)
        return require(net)
    end)

    if pktOk and pktResult and type(pktResult) == "table" then
        print("[GAG Hub] Networking resolved via Packet pre-require")
        Networking._module = pktResult
        return pktResult
    end

    warn("[GAG Hub] Failed to resolve Networking module (all methods failed):", result or gcResult or pktResult)
    return nil
end

---------------------------------------------------------------
-- RESOLVE REMOTE BY DOT PATH
-- e.g., "Garden.CollectFruit" → Networking.Garden.CollectFruit
---------------------------------------------------------------

function Networking._resolveRemote(path)
    -- Check cache
    if Networking._cache[path] then return Networking._cache[path] end

    local net = Networking._resolve()
    if not net then return nil end

    local current = net
    for segment in string.gmatch(path, "[^%.]+") do
        if type(current) ~= "table" then
            warn("[GAG Hub] Remote path broken at segment:", segment, "in", path)
            return nil
        end
        current = current[segment]
        if current == nil then
            -- Try searching by iterating keys (case-insensitive fallback)
            for k, v in pairs(current or {}) do
                if string.lower(k) == string.lower(segment) then
                    current = v
                    break
                end
            end
            if current == nil then
                warn("[GAG Hub] Remote not found:", segment, "in path", path)
                return nil
            end
        end
    end

    Networking._cache[path] = current
    return current
end

---------------------------------------------------------------
-- FIRE (RemoteEvent → server)
---------------------------------------------------------------

function Networking.fire(path, ...)
    local remote = Networking._resolveRemote(path)
    if not remote then
        warn("[GAG Hub] Cannot fire - remote not found:", path)
        return false
    end

    local args = {...}
    local argc = select("#", ...)
    local ok, err = pcall(function()
        if remote.Fire then
            remote:Fire(unpack(args, 1, argc))
        elseif type(remote) == "table" and remote.fire then
            remote:fire(unpack(args, 1, argc))
        else
            error("Remote has no :Fire method - type: " .. typeof(remote))
        end
    end)

    if not ok then
        warn("[GAG Hub] Fire error on", path, ":", err)
        return false
    end

    if Networking._log then
        print("[GAG Hub] Fired:", path)
    end
    return true
end

---------------------------------------------------------------
-- INVOKE (RemoteFunction → server → response)
---------------------------------------------------------------

function Networking.invoke(path, ...)
    local remote = Networking._resolveRemote(path)
    if not remote then
        warn("[GAG Hub] Cannot invoke - remote not found:", path)
        return nil
    end

    local args = {...}
    local argc = select("#", ...)
    local ok, result = pcall(function()
        if remote.Invoke then
            return remote:Invoke(unpack(args, 1, argc))
        else
            error("Remote has no :Invoke method")
        end
    end)

    if not ok then
        warn("[GAG Hub] Invoke error on", path, ":", result)
        return nil
    end

    return result
end

---------------------------------------------------------------
-- LISTEN (RemoteEvent ← server)
---------------------------------------------------------------

function Networking.on(path, callback)
    local remote = Networking._resolveRemote(path)
    if not remote then
        warn("[GAG Hub] Cannot listen - remote not found:", path)
        return nil
    end

    local ok, connection = pcall(function()
        if remote.OnClientEvent then
            return remote.OnClientEvent:Connect(callback)
        elseif remote.Connect then
            return remote:Connect(callback)
        else
            -- Try the .Changed pattern or direct connect
            warn("[GAG Hub] Remote has no OnClientEvent:", path)
            return nil
        end
    end)

    if ok and connection then
        table.insert(Networking._connections, connection)
        return connection
    end
    return nil
end

---------------------------------------------------------------
-- BATCH LISTEN (connect multiple events at once)
---------------------------------------------------------------

function Networking.onMany(map)
    local connections = {}
    for path, callback in pairs(map) do
        local conn = Networking.on(path, callback)
        if conn then
            connections[path] = conn
        end
    end
    return connections
end

---------------------------------------------------------------
-- GET RAW REMOTE (for advanced usage)
---------------------------------------------------------------

function Networking.get(path)
    return Networking._resolveRemote(path)
end

---------------------------------------------------------------
-- CHECK IF REMOTE EXISTS
---------------------------------------------------------------

function Networking.exists(path)
    return Networking._resolveRemote(path) ~= nil
end

---------------------------------------------------------------
-- LOGGING
---------------------------------------------------------------

function Networking.setLogging(enabled)
    Networking._log = enabled
end

---------------------------------------------------------------
-- DISCONNECT ALL
---------------------------------------------------------------

function Networking.disconnectAll()
    for _, conn in ipairs(Networking._connections) do
        pcall(function() conn:Disconnect() end)
    end
    Networking._connections = {}
end

---------------------------------------------------------------
-- CACHE REFRESH (call after game update)
---------------------------------------------------------------

function Networking.refreshCache()
    Networking._module = nil
    Networking._cache = {}
    Networking._resolve()
end

---------------------------------------------------------------
-- AUTO-INIT
---------------------------------------------------------------

Networking._resolve()


---------------------------------------------------------------
-- CORE: UTILITIES (inlined from core/utils.lua)
---------------------------------------------------------------

local Utils = {}
local Players = game:GetService("Players")
local RS      = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

---------------------------------------------------------------
-- INSTANCE RESOLVER
---------------------------------------------------------------

-- Resolve dot-path: "A.B.C" → A.B.C
function Utils.resolve(root, path)
    if not root or not path then return nil end
    local current = root
    for segment in string.gmatch(path, "[^%.]+") do
        current = current:FindFirstChild(segment)
        if not current then return nil end
    end
    return current
end

-- Safe resolve with WaitForChild (timeout)
function Utils.resolveWait(root, path, timeout)
    if not root or not path then return nil end
    local current = root
    for segment in string.gmatch(path, "[^%.]+") do
        current = current:WaitForChild(segment, timeout or 10)
        if not current then return nil end
    end
    return current
end

---------------------------------------------------------------
-- PLAYER HELPERS
---------------------------------------------------------------

function Utils.getLocalPlayer()
    return Players.LocalPlayer
end

function Utils.getCharacter()
    local lp = Players.LocalPlayer
    return lp and lp.Character or nil
end

function Utils.getHumanoidRootPart()
    local char = Utils.getCharacter()
    return char and char:FindFirstChild("HumanoidRootPart")
end

function Utils.getHumanoid()
    local char = Utils.getCharacter()
    return char and char:FindFirstChildWhichIsA("Humanoid")
end

function Utils.getPlotId()
    local lp = Players.LocalPlayer
    return lp and lp:GetAttribute("PlotId")
end

function Utils.getMyGarden()
    local plotId = Utils.getPlotId()
    if not plotId then return nil end
    local gardens = workspace:FindFirstChild("Gardens")
    if not gardens then return nil end
    return gardens:FindFirstChild("Plot" .. tostring(plotId))
end

---------------------------------------------------------------
-- GARDEN HELPERS
---------------------------------------------------------------

-- Get all plants in a garden plot
function Utils.getPlantsInGarden(garden)
    if not garden then return {} end
    local plants = {}
    for _, child in ipairs(garden:GetDescendants()) do
        if child:IsA("Model") and child:GetAttribute("SeedName") then
            table.insert(plants, child)
        end
    end
    return plants
end

-- Get plant info from attributes
function Utils.getPlantInfo(plant)
    if not plant then return nil end
    return {
        Name     = plant:GetAttribute("SeedName") or plant.Name,
        Growth   = plant:GetAttribute("Growth") or 0,
        Mutation = plant:GetAttribute("Mutation"),
        Age      = plant:GetAttribute("Age") or 0,
        Size     = plant:GetAttribute("Size") or 1,
        IsRipe   = (plant:GetAttribute("Growth") or 0) >= 1,
        Owner    = plant:GetAttribute("Owner"),
        Instance = plant,
    }
end

-- Get all fruits in a garden
function Utils.getFruitsInGarden(garden)
    if not garden then return {} end
    local fruits = {}
    for _, child in ipairs(garden:GetDescendants()) do
        if child:GetAttribute("FruitName") or child:GetAttribute("IsFruit") then
            table.insert(fruits, child)
        end
    end
    return fruits
end

-- Get all gardens in workspace
function Utils.getAllGardens()
    local gardens = workspace:FindFirstChild("Gardens")
    if not gardens then return {} end
    local result = {}
    for _, garden in ipairs(gardens:GetChildren()) do
        table.insert(result, garden)
    end
    return result
end

---------------------------------------------------------------
-- VALUE CALCULATOR
---------------------------------------------------------------

-- Calculate fruit sell value (mirrors FruitValueCalc)
-- baseValue * size^exponent * mutationMult * sizeMult
function Utils.calculateFruitValue(seedName, size, mutation, sellData, mutationData)
    local base = sellData and sellData[seedName] or 0
    local sizeExponent = 2.65
    local sizeMult = 1
    local mutationMult = 1

    if mutation and mutationData then
        local mData = mutationData[mutation]
        mutationMult = mData and mData.PriceMultiplier or 1
    end

    local value = base * (size ^ sizeExponent) * sizeMult * mutationMult
    return math.floor(value)
end

---------------------------------------------------------------
-- INVENTORY HELPERS
---------------------------------------------------------------

-- Get backpack contents
function Utils.getBackpackItems()
    local lp = Players.LocalPlayer
    local bp = lp and lp:FindFirstChild("Backpack")
    if not bp then return {} end
    local items = {}
    for _, tool in ipairs(bp:GetChildren()) do
        if tool:IsA("Tool") then
            table.insert(items, {
                Name = tool.Name,
                Instance = tool,
                Type = tool:GetAttribute("ItemType") or "Unknown",
            })
        end
    end
    return items
end

-- Get equipped tool
function Utils.getEquippedTool()
    local char = Utils.getCharacter()
    if not char then return nil end
    for _, child in ipairs(char:GetChildren()) do
        if child:IsA("Tool") then
            return child
        end
    end
    return nil
end

---------------------------------------------------------------
-- NIGHT CHECK
---------------------------------------------------------------

function Utils.isNight()
    local night = RS:FindFirstChild("Night")
    if night then return night.Value == true end
    -- Fallback: check lighting
    local clock = game:GetService("Lighting").ClockTime
    return clock >= 18 or clock < 6
end

---------------------------------------------------------------
-- SHECKLE BALANCE
---------------------------------------------------------------

function Utils.getSheckles()
    local lp = Players.LocalPlayer
    local leaderstats = lp and lp:FindFirstChild("leaderstats")
    if not leaderstats then return 0 end
    local sheckles = leaderstats:FindFirstChild("Sheckles")
    return sheckles and sheckles.Value or 0
end

---------------------------------------------------------------
-- SAFE CALL
---------------------------------------------------------------

function Utils.safeCall(fn, ...)
    local ok, result = pcall(fn, ...)
    if not ok then
        warn("[GAG Hub] Error:", result)
    end
    return ok, result
end

---------------------------------------------------------------
-- TABLE HELPERS
---------------------------------------------------------------

function Utils.tableContains(tbl, value)
    for _, v in ipairs(tbl) do
        if v == value then return true end
    end
    return false
end

function Utils.tableKeys(tbl)
    local keys = {}
    for k in pairs(tbl) do
        table.insert(keys, k)
    end
    return keys
end

function Utils.deepCopy(original)
    local copy = {}
    for k, v in pairs(original) do
        if type(v) == "table" then
            copy[k] = Utils.deepCopy(v)
        else
            copy[k] = v
        end
    end
    return copy
end

---------------------------------------------------------------
-- STRING HELPERS
---------------------------------------------------------------

function Utils.formatNumber(n)
    if n >= 1e12 then return string.format("%.1fT", n / 1e12) end
    if n >= 1e9  then return string.format("%.1fB", n / 1e9) end
    if n >= 1e6  then return string.format("%.1fM", n / 1e6) end
    if n >= 1e3  then return string.format("%.1fK", n / 1e3) end
    return tostring(n)
end

function Utils.formatTime(seconds)
    local h = math.floor(seconds / 3600)
    local m = math.floor((seconds % 3600) / 60)
    local s = math.floor(seconds % 60)
    if h > 0 then return string.format("%dh %dm %ds", h, m, s) end
    if m > 0 then return string.format("%dm %ds", m, s) end
    return string.format("%ds", s)
end

---------------------------------------------------------------
-- SIGNAL (simple event)
---------------------------------------------------------------

function Utils.createSignal()
    local signal = {}
    signal._bindables = {}

    function signal:Connect(fn)
        local connection = { _fn = fn, _connected = true }
        table.insert(signal._bindables, connection)
        function connection:Disconnect()
            self._connected = false
        end
        return connection
    end

    function signal:Fire(...)
        for _, conn in ipairs(signal._bindables) do
            if conn._connected then
                task.spawn(conn._fn, ...)
            end
        end
    end

    return signal
end


---------------------------------------------------------------
-- CORE: ANTI-AFK (inlined from core/antiafk.lua)
---------------------------------------------------------------

local AntiAfk = {}
local Players    = game:GetService("Players")
local VirtualUser = game:GetService("VirtualUser")
local GuiService  = game:GetService("GuiService")
local RS          = game:GetService("ReplicatedStorage")

local LP = Players.LocalPlayer

AntiAfk._running = false
AntiAfk._thread  = nil
AntiAfk._rejoinThread = nil
AntiAfk._stats   = { actions = 0, rejoins = 0, lastAction = 0 }

---------------------------------------------------------------
-- ANTI-AFK
---------------------------------------------------------------

function AntiAfk.start(config)
    if AntiAfk._running then return end
    AntiAfk._running = true

    local interval = config.Timings.AntiAfkInterval or 60

    AntiAfk._thread = task.spawn(function()
        while AntiAfk._running do
            -- Method 1: VirtualUser click (most reliable)
            pcall(function()
                VirtualUser:CaptureController()
                VirtualUser:ClickButton2(Vector2.new())
            end)

            -- Method 2: Simulate movement
            pcall(function()
                local humanoid = LP.Character
                    and LP.Character:FindFirstChildWhichIsA("Humanoid")
                if humanoid then
                    humanoid.Jump = true
                end
            end)

            -- Method 3: Fire game's anti-AFK remote if available
            pcall(function()
                local Net = require(
                    RS:WaitForChild("SharedModules"):WaitForChild("Networking")
                )
                if Net.AntiAfk and Net.AntiAfk.RequestHop then
                    -- Only fire if idle long enough (game tracks this)
                end
            end)

            AntiAfk._stats.actions += 1
            AntiAfk._stats.lastAction = os.time()
            task.wait(interval)
        end
    end)

    -- Also handle the idle kicked event
    LP.Idled:Connect(function()
        pcall(function()
            VirtualUser:CaptureController()
            VirtualUser:ClickButton2(Vector2.new())
        end)
    end)

    print("[GAG Hub] Anti-AFK started (interval: " .. interval .. "s)")
end

function AntiAfk.stop()
    AntiAfk._running = false
    if AntiAfk._thread then
        task.cancel(AntiAfk._thread)
        AntiAfk._thread = nil
    end
end

---------------------------------------------------------------
-- AUTO-REJOIN
---------------------------------------------------------------

function AntiAfk.startAutoRejoin(config)
    if AntiAfk._rejoinThread then return end

    local delay = config.Timings.RejoinDelay or 5

    -- Handle disconnection
    game:GetService("CoreGui").RobloxPromptGui.promptOverlay
        .ChildAdded:Connect(function(child)
            if child.Name == "ErrorPrompt" or child.Name == "TeleportPrompt" then
                AntiAfk._stats.rejoins += 1
                task.wait(delay)
                pcall(function()
                    game:GetService("TeleportService"):TeleportToPlaceInstance(
                        game.PlaceId,
                        game.JobId,
                        LP
                    )
                end)
            end
        end)

    -- Handle kick messages
    game:GetService("GuiService").ErrorMessageChanged:Connect(function()
        AntiAfk._stats.rejoins += 1
        task.wait(delay)
        pcall(function()
            game:GetService("TeleportService"):Teleport(game.PlaceId, LP)
        end)
    end)

    -- Handle character death (auto-respawn)
    LP.CharacterAdded:Connect(function(char)
        local humanoid = char:WaitForChild("Humanoid", 10)
        if humanoid then
            humanoid.Died:Connect(function()
                task.wait(3)
                pcall(function()
                    LP:LoadCharacter()
                end)
            end)
        end
    end)

    print("[GAG Hub] Auto-Rejoin enabled")
end

function AntiAfk.getStats()
    return AntiAfk._stats
end


---------------------------------------------------------------
-- MODULE REGISTRY
---------------------------------------------------------------

local Modules = {}
local Running = {}

local function startModule(name)
    if Running[name] then return end
    local mod = Modules[name]
    if mod and mod.start then
        mod.start(Config, Networking, Utils)
        Running[name] = true
        print("[GAG Hub] Started:", name)
    end
end

local function stopModule(name)
    if not Running[name] then return end
    local mod = Modules[name]
    if mod and mod.stop then
        mod.stop()
        Running[name] = false
        print("[GAG Hub] Stopped:", name)
    end
end

local function toggleModule(name)
    if Running[name] then stopModule(name) else startModule(name) end
    Config.Features[name] = Running[name]
end

---------------------------------------------------------------
-- RESOURCES: Prices & Meta (loaded from resources.lua)
---------------------------------------------------------------

local Resources = nil
pcall(function()
    -- Try loading from GitHub raw (always latest)
    local url = "https://[Log in to view URL]"
    local src = game:HttpGet(url, true)
    if src and #src > 100 then
        Resources = loadstring(src)()
    end
end)
if not Resources then
    -- Fallback: inline minimal prices
    Resources = { SeedPrices = {}, GearPrices = {}, SeedMeta = {}, GearMeta = {}, AllSeeds = {}, AllGears = {} }
    warn("[GAG Hub] Failed to load resources.lua, affordability guard disabled")
end

---------------------------------------------------------------
-- MODULE: AUTO HARVEST
---------------------------------------------------------------

Modules.AutoHarvest = {}
do
    local M = Modules.AutoHarvest
    local Harvest = M
    local Players = game:GetService("Players")
    Harvest._running = false
    Harvest._thread  = nil
    Harvest._connections = {}
    Harvest._stats = { harvested = 0, scans = 0, errors = 0 }

    ---------------------------------------------------------------
    -- GET MY PLOT (dynamic)
    ---------------------------------------------------------------

    function Harvest._getMyPlot()
        local lp = Players.LocalPlayer
        if not lp then return nil end
        local plotId = lp:GetAttribute("PlotId")
        if not plotId then return nil end
        local gardens = workspace:FindFirstChild("Gardens")
        if not gardens then return nil end
        return gardens:FindFirstChild("Plot" .. tostring(plotId))
    end

    function Harvest._isAlive()
        local lp = Players.LocalPlayer
        if not lp then return false end
        local char = lp.Character
        if not char then return false end
        local hum = char:FindFirstChildWhichIsA("Humanoid")
        return hum and hum.Health > 0
    end

    function Harvest._isHarvestable(fruitModel)
        if not fruitModel or not fruitModel.Parent then return false end
        local harvestPart = fruitModel:FindFirstChild("HarvestPart")
        if not harvestPart then return false end
        local prompt = harvestPart:FindFirstChild("HarvestPrompt")
        if not prompt then return false end
        if not prompt.Enabled then return false end
        return true
    end

    ---------------------------------------------------------------
    -- COLLECT ALL FRUITS ON MY PLOT (direct remote, no prompt)
    -- Two harvest paths:
    --   Multi: Plant → Fruits → FruitModel(HarvestPart.HarvestPrompt) → CollectFruit(plantId, fruitId)
    --   Single: Plant → HarvestPart.HarvestPrompt (no Fruits folder) → CollectFruit(plantId, "")
    ---------------------------------------------------------------

    function Harvest._collectAll(Net)
        if not Harvest._isAlive() then return 0 end

        local plot = Harvest._getMyPlot()
        if not plot then return 0 end
        local count = 0
        local plantsFolder = plot:FindFirstChild("Plants")
        if not plantsFolder then return 0 end
        for _, plantModel in ipairs(plantsFolder:GetChildren()) do
            if not Harvest._running then break end
            local plantId = plantModel:GetAttribute("PlantId")
            if not plantId then continue end

            -- Path A: Multi-harvest (has Fruits folder)
            local fruitsFolder = plantModel:FindFirstChild("Fruits")
            if fruitsFolder then
                for _, fruitModel in ipairs(fruitsFolder:GetChildren()) do
                    if not Harvest._running then break end
                    if not Harvest._isHarvestable(fruitModel) then continue end
                    local fruitId = fruitModel:GetAttribute("FruitId")
                    pcall(function()
                        Net.fire("Garden.CollectFruit", plantId, fruitId or "")
                    end)
                    count += 1
                    task.wait(0.1)
                end
            end

            -- Path B: Single-harvest (HarvestPrompt directly on plant)
            if Harvest._isHarvestable(plantModel) then
                pcall(function()
                    Net.fire("Garden.CollectFruit", plantId, "")
                end)
                count += 1
                task.wait(0.1)
            end
        end
        return count
    end

    ---------------------------------------------------------------
    -- START
    ---------------------------------------------------------------

    function Harvest.start(config, Net, Utils)
        if Harvest._running then return end
        Harvest._running = true

        local interval = config.Timings.HarvestInterval or 0.5

        -- [METHOD 1] Listen for new fruits and collect immediately
        local fruitAddedConn = Net.on("Garden.FruitAdded", function(plantId, fruitId, fruitName, data)
            if not Harvest._running then return end
            if not Harvest._isAlive() then return end
            task.wait(0.15)
            pcall(function()
                Net.fire("Garden.CollectFruit", plantId, fruitId or "")
            end)
            Harvest._stats.harvested += 1
        end)
        if fruitAddedConn then
            table.insert(Harvest._connections, fruitAddedConn)
        end

        -- [METHOD 2] Periodic scan — walk garden tree, fire CollectFruit directly
        Harvest._thread = task.spawn(function()
            while Harvest._running do
                Harvest._stats.scans += 1
                local count = Harvest._collectAll(Net)
                Harvest._stats.harvested += count
                task.wait(interval)
            end
        end)

        print("[GAG Hub] Auto-Harvest started")
    end

    ---------------------------------------------------------------
    -- STOP / STATUS
    ---------------------------------------------------------------

    function Harvest.stop()
        Harvest._running = false
        if Harvest._thread then
            Harvest._thread = nil
        end
        for _, conn in ipairs(Harvest._connections) do
            pcall(function() conn:Disconnect() end)
        end
        Harvest._connections = {}
    end

    function Harvest.getStats()
        return Harvest._stats
    end
end

---------------------------------------------------------------
-- MODULE: AUTO SELL
---------------------------------------------------------------

Modules.AutoSell = {}
do
    local M = Modules.AutoSell
    local Sell = M
Sell._running = false
Sell._thread  = nil
Sell._connections = {}
Sell._stats = { sold = 0, totalEarned = 0, errors = 0 }

---------------------------------------------------------------
-- START
---------------------------------------------------------------

function Sell.start(config, Net, Utils)
    if Sell._running then return end
    Sell._running = true

    local interval = config.Timings.SellInterval or 5
    local sellConfig = config.Sell or {}

    Sell._thread = task.spawn(function()
        while Sell._running do
            Sell._autoSell(sellConfig, Net, Utils)
            task.wait(interval)
        end
    end)

    print("[GAG Hub] Auto-Sell started (mode: " .. (sellConfig.Mode or "all") .. ")")
end

---------------------------------------------------------------
-- AUTO SELL LOGIC
---------------------------------------------------------------

function Sell._autoSell(sellConfig, Net, Utils)
    -- Guard: skip if no fruits in backpack
    local lp = Players.LocalPlayer
    local bp = lp and lp:FindFirstChild("Backpack")
    local hasFruit = false
    if bp then
        for _, tool in ipairs(bp:GetChildren()) do
            if tool:IsA("Tool") and (tool:GetAttribute("FruitName") or tool:GetAttribute("IsFruit")) then
                hasFruit = true
                break
            end
        end
    end
    -- Also check character (equipped tool)
    if not hasFruit and lp and lp.Character then
        for _, tool in ipairs(lp.Character:GetChildren()) do
            if tool:IsA("Tool") and (tool:GetAttribute("FruitName") or tool:GetAttribute("IsFruit")) then
                hasFruit = true
                break
            end
        end
    end
    if not hasFruit then return end

    local mode = sellConfig.Mode or "all"

    if mode == "all" then
        -- Sell everything
        local ok = Net.fire("NPCS.SellAll")
        if ok then
            Sell._stats.sold += 1
        else
            Sell._stats.errors += 1
        end

    elseif mode == "below_threshold" then
        -- Sell individual fruits below value threshold
        Sell._sellBelowThreshold(sellConfig, Net, Utils)

    elseif mode == "keep_best" then
        -- Sell all except top N
        Sell._sellKeepBest(sellConfig, Net, Utils)
    end

    -- Use daily deal if configured
    if sellConfig.UseDailyDeal then
        Net.fire("NPCS.UseDailyDealAll")
    end
end

-- Sell fruits below a certain value threshold
function Sell._sellBelowThreshold(sellConfig, Net, Utils)
    local threshold = sellConfig.ValueThreshold or 100

    -- Get backpack items
    local items = Utils.getBackpackItems()
    for _, item in ipairs(items) do
        if item.Type == "HarvestedFruit" or string.find(item.Name, "Fruit") then
            -- Try to sell this individual fruit
            local ok = Net.fire("NPCS.SellFruit", item.Name)
            if ok then
                Sell._stats.sold += 1
            end
        end
    end
end

-- Sell all except keep top N valuable fruits
function Sell._sellKeepBest(sellConfig, Net, Utils)
    -- Just sell all for now - more complex logic needs inventory API
    local ok = Net.fire("NPCS.SellAll")
    if ok then
        Sell._stats.sold += 1
    end
end

---------------------------------------------------------------
-- MANUAL SELL ALL
---------------------------------------------------------------

function Sell.sellAll(Net)
    local ok = Net.fire("NPCS.SellAll")
    if ok then
        Sell._stats.sold += 1
    end
    return ok
end

---------------------------------------------------------------
-- SELL SPECIFIC FRUIT
---------------------------------------------------------------

function Sell.sellFruit(Net, fruitName)
    local ok = Net.fire("NPCS.SellFruit", fruitName)
    if ok then
        Sell._stats.sold += 1
    end
    return ok
end

---------------------------------------------------------------
-- USE DAILY DEAL
---------------------------------------------------------------

function Sell.useDailyDeal(Net, single)
    if single then
        return Net.fire("NPCS.UseDailyDealSingle")
    else
        return Net.fire("NPCS.UseDailyDealAll")
    end
end

---------------------------------------------------------------
-- STOP / STATUS
---------------------------------------------------------------

function Sell.stop()
    Sell._running = false
    for _, conn in ipairs(Sell._connections) do
        pcall(function() conn:Disconnect() end)
    end
    Sell._connections = {}
end

function Sell.getStats()
    return Sell._stats
end

end

---------------------------------------------------------------
-- MODULE: AUTO WATER
---------------------------------------------------------------

Modules.AutoWater = {}
do
    local M = Modules.AutoWater
    local Water = M
    local Players = game:GetService("Players")
Water._running = false
Water._thread  = nil
Water._connections = {}
Water._stats = { watered = 0, scans = 0, errors = 0, noCan = 0 }
Water._debug = true -- set false to silence debug

---------------------------------------------------------------
-- FIND WATERING CAN TOOL IN BACKPACK/CHARACTER
-- Tool attribute: "WateringCan" = can name string
-- Can types: "Common Watering Can", "Super Watering Can"
---------------------------------------------------------------

function Water._findCan(requiredCan)
    local LP = Players.LocalPlayer
    if not LP then
        if Water._debug then warn("[Water][DEBUG] No LocalPlayer") end
        return nil, nil
    end

    -- Normalize: trim whitespace
    local function trim(s)
        if type(s) ~= "string" then return "" end
        return s:match("^%s*(.-)%s*$") or ""
    end

    local reqNorm = trim(requiredCan)
    if Water._debug then
        warn("[Water][DEBUG] RequiredCan raw:", string.format("%q", tostring(requiredCan or "")),
             "| norm:", string.format("%q", reqNorm),
             "| type:", type(requiredCan),
             "| len:", #reqNorm)
    end

    -- If no specific can required, use first found
    local matchFn = function(canName)
        if reqNorm == "" then return true end
        return trim(canName) == reqNorm
    end

    local function scanContainer(container, label)
        if not container then return nil end
        local tools = container:GetChildren()
        if Water._debug then
            warn("[Water][DEBUG] Scanning", label, "- items:", #tools)
        end
        for _, tool in ipairs(tools) do
            if tool:IsA("Tool") then
                local wcAttr = tool:GetAttribute("WateringCan")
                if Water._debug then
                    warn("[Water][DEBUG] Tool:", tool.Name,
                         "| WateringCan attr:", string.format("%q", tostring(wcAttr)),
                         "| attr type:", type(wcAttr),
                         "| Class:", tool.ClassName)
                end
                -- WateringCan attribute = flag for watering can tools
                if wcAttr ~= nil then
                    local canName = tool.Name
                    local matched = matchFn(canName)
                    if Water._debug then
                        warn("[Water][DEBUG] Found can:", string.format("%q", canName),
                             "| match:", matched,
                             "| compare:", string.format("%q", trim(canName)), "==", string.format("%q", reqNorm))
                    end
                    if matched then
                        return tool, canName
                    end
                end
            end
        end
        return nil
    end

    -- Check character first (already equipped)
    local char = LP.Character
    local tool, canName = scanContainer(char, "Character")
    if tool then return tool, canName end

    -- Check backpack
    local backpack = LP:FindFirstChild("Backpack")
    tool, canName = scanContainer(backpack, "Backpack")

    if not tool and Water._debug then
        warn("[Water][DEBUG] No watering can found! RequiredCan:", string.format("%q", tostring(requiredCan or "")))
    end

    return tool, canName
end

---------------------------------------------------------------
-- EQUIP WATERING CAN
---------------------------------------------------------------

function Water._equipCan(tool)
    local LP = Players.LocalPlayer
    local char = LP and LP.Character
    local humanoid = char and char:FindFirstChildOfClass("Humanoid")
    if not humanoid then
        if Water._debug then warn("[Water][DEBUG] No humanoid for equip") end
        return false
    end

    -- Already in character?
    if tool.Parent == char then
        if Water._debug then warn("[Water][DEBUG] Can already equipped:", tool.Name) end
        return true
    end

    if Water._debug then warn("[Water][DEBUG] Equipping:", tool.Name, "from:", tool.Parent and tool.Parent.Name) end
    pcall(function() humanoid:EquipTool(tool) end)
    task.wait(0.2)
    local success = tool.Parent == char
    if Water._debug then warn("[Water][DEBUG] Equip result:", success, "parent:", tool.Parent and tool.Parent.Name) end
    return success
end

---------------------------------------------------------------
-- START
---------------------------------------------------------------

function Water.start(config, Net, Utils)
    if Water._running then return end
    Water._running = true

    local interval = config.Timings.WaterInterval or 3

    Water._thread = task.spawn(function()
        while Water._running do
            Water._waterPlants(config, Net, Utils)
            task.wait(interval)
        end
    end)

    print("[GAG Hub] Auto-Water started")
end

---------------------------------------------------------------
-- WATER PLANTS
-- Reference: Controllers_WateringcanController.TryWater()
-- Remote: WateringCan.UseWateringCan(position, canName, toolInstance)
---------------------------------------------------------------

function Water._waterPlants(config, Net, Utils)
    local garden = Utils.getMyGarden()
    if not garden then
        if Water._debug then warn("[Water][DEBUG] No garden found") end
        return
    end

    Water._stats.scans += 1

    -- 1. Find + equip watering can
    local canTool, canName = Water._findCan(config.Water.RequiredCan)
    if not canTool then
        Water._stats.noCan += 1
        if Water._debug then warn("[Water][DEBUG] noCan, scan:", Water._stats.scans) end
        return
    end

    local equipped = Water._equipCan(canTool)
    if not equipped then
        Water._stats.errors += 1
        if Water._debug then warn("[Water][DEBUG] Failed to equip can") end
        return
    end

    -- 2. Scan plants
    local plants = Utils.getPlantsInGarden(garden)
    local waterFullyGrown = config.Water.WaterFullyGrown or false
    if Water._debug then
        warn("[Water][DEBUG] Garden:", garden.Name, "| Plants:", #plants,
             "| WaterAll:", tostring(config.Water.WaterAll),
             "| WaterFullyGrown:", tostring(waterFullyGrown),
             "| RequiredCan:", tostring(config.Water.RequiredCan))
    end

    local wateredThisRound = 0
    for _, plant in ipairs(plants) do
        if not Water._running then break end

        local info = Utils.getPlantInfo(plant)
        if not info then
            if Water._debug then warn("[Water][DEBUG] No info for plant:", plant.Name) end
            continue
        end

        -- Skip fully grown unless toggle enabled
        local growth = info.Growth or 0
        local isFullyGrown = growth >= 1
        if isFullyGrown and not waterFullyGrown then
            if Water._debug then warn("[Water][DEBUG] Skip fully grown:", info.Name, "growth:", growth) end
            continue
        end

        -- Water all mode OR needs water (growth < 1)
        local needsWater = growth < 1
        if not needsWater and not waterFullyGrown and not config.Water.WaterAll then
            if Water._debug then warn("[Water][DEBUG] Skip no-water-need:", info.Name, "growth:", growth) end
            continue
        end

        -- 3. Get plant position
        local rootPart = plant:FindFirstChildWhichIsA("BasePart")
        if not rootPart then
            if Water._debug then warn("[Water][DEBUG] No BasePart for plant:", info.Name) end
            continue
        end

        -- 4. Fire UseWateringCan(position, canName, toolInstance)
        -- Position offset -0.3 Y like decompiled TryWater
        local pos = rootPart.Position - Vector3.new(0, 0.3, 0)
        if Water._debug then
            warn("[Water][DEBUG] Watering:", info.Name,
                 "| pos:", tostring(pos),
                 "| can:", canName)
        end
        local ok = pcall(function()
            Net.fire("WateringCan.UseWateringCan", pos, canName, canTool)
        end)

        if ok then
            Water._stats.watered += 1
            wateredThisRound += 1
        else
            Water._stats.errors += 1
            if Water._debug then warn("[Water][DEBUG] Fire failed for:", info.Name) end
        end

        task.wait(0.5) -- cooldown between plants (match TryWater 0.5s cooldown)
    end

    if Water._debug and wateredThisRound > 0 then
        warn("[Water][DEBUG] Round done. Watered:", wateredThisRound,
             "| Total:", Water._stats.watered,
             "| Errors:", Water._stats.errors)
    end
end

---------------------------------------------------------------
-- PLACE SPRINKLER (utility)
---------------------------------------------------------------

function Water.placeSprinkler(Net, Utils, sprinklerType)
    local garden = Utils.getMyGarden()
    if not garden then return false end

    local spawnPoint = garden:FindFirstChild("SpawnPoint")
    local position = spawnPoint and spawnPoint.Position or Vector3.new(0, 0, 0)

    local ok = Net.fire("Place.PlaceSprinkler", position, sprinklerType or "Common Sprinkler")
    return ok
end

---------------------------------------------------------------
-- STOP / STATUS
---------------------------------------------------------------

function Water.stop()
    Water._running = false
    for _, conn in ipairs(Water._connections) do
        pcall(function() conn:Disconnect() end)
    end
    Water._connections = {}
end

function Water.getStats()
    return Water._stats
end

end

---------------------------------------------------------------
-- MODULE: AUTO PLANT
-- Reference: Controllers_PlantController.module.lua
-- Remote: Plant.PlantSeed(position: Vector3, seedName: String, toolInstance: Instance)
-- Plant areas: CollectionService:GetTagged("PlantArea")
-- Seed tool: Character tool with "SeedTool" attribute
-- Flow: find seed in backpack → equip → fire PlantSeed
---------------------------------------------------------------

Modules.AutoPlant = {}
do
    local M = Modules.AutoPlant
    local Plant = M
    local Players = game:GetService("Players")
    local CollectionService = game:GetService("CollectionService")
Plant._running = false
Plant._thread  = nil
Plant._connections = {}
Plant._stats = { planted = 0, scans = 0, errors = 0, noSeeds = 0, equipped = 0 }

---------------------------------------------------------------
-- GET EQUIPPED SEED TOOL (matching decompiled GetEquippedTool)
-- Must be a Tool with "SeedTool" attribute = seed name
---------------------------------------------------------------

function Plant._getEquippedSeed()
    local lp = Players.LocalPlayer
    local char = lp and lp.Character
    if not char then return nil, nil end
    local tool = char:FindFirstChildWhichIsA("Tool")
    if not tool then return nil, nil end
    if tool:GetAttribute("MainCategory") ~= "Seed" then return nil, nil end
    local seedName = tool:GetAttribute("SeedTool")
    if not seedName then return nil, nil end
    return seedName, tool
end

---------------------------------------------------------------
-- FIND SEED TOOLS IN BACKPACK
-- Returns list of {tool, seedName} sorted by seed name
---------------------------------------------------------------

function Plant._isMutatedSeed(seedToolValue)
    if not seedToolValue then return false end
    -- Match "Gold", "Gold Carrot", "Rainbow", "Rainbow Tomato", etc.
    if seedToolValue == "Gold" or seedToolValue == "Rainbow" then return true end
    return string.match(seedToolValue, "^Gold ") ~= nil
        or string.match(seedToolValue, "^Rainbow ") ~= nil
end

function Plant._findSeedsInBackpack(preferSeed, skipMutated)
    local lp = Players.LocalPlayer
    local bp = lp and lp:FindFirstChild("Backpack")
    if not bp then return {} end
    local seeds = {}
    for _, tool in ipairs(bp:GetChildren()) do
        if tool:IsA("Tool") then
            local cat = tool:GetAttribute("MainCategory")
            local sn = tool:GetAttribute("SeedTool")
            if sn and cat == "Seed" then
                -- Skip Gold/Rainbow mutated seeds if filter enabled
                local include = true
                if skipMutated and Plant._isMutatedSeed(sn) then
                    include = false
                end
                if include then
                    table.insert(seeds, { tool = tool, seedName = sn })
                end
            end
        end
    end
    -- Sort: preferred seed first, then alphabetical
    table.sort(seeds, function(a, b)
        if preferSeed then
            local aMatch = (a.seedName == preferSeed) and 1 or 0
            local bMatch = (b.seedName == preferSeed) and 1 or 0
            if aMatch ~= bMatch then return aMatch > bMatch end
        end
        return a.seedName < b.seedName
    end)
    return seeds
end

---------------------------------------------------------------
-- EQUIP SEED TOOL FROM BACKPACK
-- Uses Humanoid:EquipTool() — same as game's internal flow
-- Returns seedName, toolInstance on success
---------------------------------------------------------------

function Plant._equipSeed(preferSeed, skipMutated)
    local lp = Players.LocalPlayer
    local char = lp and lp.Character
    if not char then return nil, nil end
    local humanoid = char:FindFirstChildWhichIsA("Humanoid")
    if not humanoid then return nil, nil end

    -- Check if already equipped (and not Gold/Rainbow mutated if filter on)
    local sn, tool = Plant._getEquippedSeed()
    if sn then
        if skipMutated then
            if not Plant._isMutatedSeed(sn) then return sn, tool end
        else
            return sn, tool
        end
    end

    -- Find seed in backpack
    local seeds = Plant._findSeedsInBackpack(preferSeed, skipMutated)
    if #seeds == 0 then return nil, nil end

    -- Equip first seed (preferred or first available)
    local target = seeds[1]
    local ok = pcall(function()
        humanoid:EquipTool(target.tool)
    end)
    if not ok then return nil, nil end

    -- Wait for tool to appear in character
    local waited = 0
    while waited < 2 do
        task.wait(0.1)
        waited += 0.1
        local equipped = char:FindFirstChild(target.tool.Name)
        if equipped and equipped:IsA("Tool") and equipped:GetAttribute("SeedTool") then
            Plant._stats.equipped += 1
            return target.seedName, target.tool
        end
    end

    return nil, nil
end

---------------------------------------------------------------
-- UNEQUIP CURRENT TOOL (back to backpack)
---------------------------------------------------------------

function Plant._unequipTool()
    local lp = Players.LocalPlayer
    local char = lp and lp.Character
    if not char then return end
    local tool = char:FindFirstChildWhichIsA("Tool")
    if not tool then return end
    pcall(function()
        tool.Parent = lp:FindFirstChild("Backpack")
    end)
end

---------------------------------------------------------------
-- GET MY PLOT (matching decompiled GetPlayerPlot)
---------------------------------------------------------------

function Plant._getMyPlot()
    local lp = Players.LocalPlayer
    if not lp then return nil end
    local plotId = lp:GetAttribute("PlotId")
    if not plotId then return nil end
    return workspace:FindFirstChild("Gardens") and workspace.Gardens:FindFirstChild("Plot" .. tostring(plotId))
end

---------------------------------------------------------------
-- CHECK IF POSITION IS EMPTY (no existing plant within range)
-- Returns true if position is clear
---------------------------------------------------------------

function Plant._isPosEmpty(pos, myPlot, minDist)
    minDist = minDist or 2.5 -- minimum spacing between plants
    local plantsFolder = myPlot:FindFirstChild("Plants")
    if not plantsFolder then return true end
    for _, plantModel in ipairs(plantsFolder:GetChildren()) do
        local plantId = plantModel:GetAttribute("PlantId")
        if plantId then
            local root = plantModel.PrimaryPart or plantModel:FindFirstChildWhichIsA("BasePart")
            if root then
                local dist = (Vector2.new(root.Position.X, root.Position.Z) - Vector2.new(pos.X, pos.Z)).Magnitude
                if dist < minDist then
                    return false
                end
            end
        end
    end
    return true
end

---------------------------------------------------------------
-- GENERATE GRID POSITIONS FROM A PLANTAREA PART
-- Covers the entire part surface with evenly spaced points
-- spacing: studs between each grid point (default 3)
---------------------------------------------------------------

function Plant._generateGridFromPart(part, spacing)
    spacing = spacing or 3
    local positions = {}
    local size = part.Size
    local cf = part.CFrame

    -- Calculate grid steps in local X and Z axes
    local halfX = size.X / 2
    local halfZ = size.Z / 2
    local stepsX = math.max(1, math.floor(size.X / spacing))
    local stepsZ = math.max(1, math.floor(size.Z / spacing))

    -- Generate evenly spaced points across the part surface
    for ix = 0, stepsX do
        for iz = 0, stepsZ do
            -- Map to local coordinates: -halfX to +halfX
            local localX = -halfX + (ix / stepsX) * size.X
            local localZ = -halfZ + (iz / stepsZ) * size.Z
            -- Transform to world position (use top surface Y)
            local worldPos = cf * Vector3.new(localX, size.Y / 2, localZ)
            table.insert(positions, worldPos)
        end
    end
    return positions
end

---------------------------------------------------------------
-- FIND ALL EMPTY PLANT POSITIONS
-- 1. Get all PlantArea parts in my plot
-- 2. Generate grid across each part's surface
-- 3. Filter out positions too close to existing plants
-- Returns sorted list of empty world positions
---------------------------------------------------------------

function Plant._findEmptySpots(myPlot, spacing, sortMode)
    spacing = spacing or 3

    -- Collect all PlantArea parts belonging to my plot
    local plantAreaParts = {}
    for _, part in ipairs(CollectionService:GetTagged("PlantArea")) do
        if part:IsA("BasePart") and part:IsDescendantOf(myPlot) then
            table.insert(plantAreaParts, part)
        end
    end

    -- Also check for PlantArea tagged via attribute (some plots use this)
    for _, desc in ipairs(myPlot:GetDescendants()) do
        if desc:IsA("BasePart") and desc:GetAttribute("PlantArea") then
            if not table.find(plantAreaParts, desc) then
                table.insert(plantAreaParts, desc)
            end
        end
    end

    if #plantAreaParts == 0 then
        -- Fallback: use GardenTotalArea if no PlantArea found
        for _, part in ipairs(CollectionService:GetTagged("GardenTotalArea")) do
            if part:IsA("BasePart") and part:IsDescendantOf(myPlot) then
                table.insert(plantAreaParts, part)
            end
        end
    end

    -- Generate grid positions across all parts
    local allPositions = {}
    for _, part in ipairs(plantAreaParts) do
        local grid = Plant._generateGridFromPart(part, spacing)
        for _, pos in ipairs(grid) do
            table.insert(allPositions, pos)
        end
    end

    -- Filter: only keep empty positions
    local emptySpots = {}
    for _, pos in ipairs(allPositions) do
        if Plant._isPosEmpty(pos, myPlot) then
            table.insert(emptySpots, pos)
        end
    end

    -- Sort based on mode
    sortMode = sortMode or "Top"
    if sortMode == "Top" then
        table.sort(emptySpots, function(a, b) return a.Y > b.Y end)
    elseif sortMode == "Bottom" then
        table.sort(emptySpots, function(a, b) return a.Y < b.Y end)
    else -- Random
        for i = #emptySpots, 2, -1 do
            local j = math.random(1, i)
            emptySpots[i], emptySpots[j] = emptySpots[j], emptySpots[i]
        end
    end

    return emptySpots
end

---------------------------------------------------------------
-- START
---------------------------------------------------------------

function Plant.start(config, Net, Utils)
    if Plant._running then return end
    Plant._running = true

    local interval = config.Timings.PlantInterval or 5
    local plantConfig = config.Plant or {}

    Plant._thread = task.spawn(function()
        while Plant._running do
            Plant._autoPlant(plantConfig, Net, Utils)
            task.wait(interval)
        end
    end)

    print("[GAG Hub] Auto-Plant started")
end

---------------------------------------------------------------
-- AUTO PLANT LOGIC
-- Flow: equip seed from backpack → fill all empty spots → unequip
---------------------------------------------------------------

function Plant._autoPlant(plantConfig, Net, Utils)
    Plant._stats.scans += 1

    local preferSeed = plantConfig.PreferSeed -- optional: preferred seed name

    -- Step 1: USE SEED — equip from backpack before planting
    local seedName, toolInstance = Plant._equipSeed(preferSeed, plantConfig.BlacklistMutated)
    if not seedName then
        Plant._stats.noSeeds += 1
        return
    end

    -- Step 2: Get plot
    local myPlot = Plant._getMyPlot()
    if not myPlot then
        Plant._unequipTool()
        return
    end

    -- Step 3: Find empty spots — grid scan across all PlantArea parts
    local spacing = plantConfig.GridSpacing or 3
    local sortMode = plantConfig.PlantOrder or "Top"
    local spots = Plant._findEmptySpots(myPlot, spacing, sortMode)
    if #spots == 0 then
        Plant._unequipTool()
        return
    end

    print("[GAG Hub] Found", #spots, "empty spots in plot")

    -- Step 4: Plant in ALL empty spots (fill the entire plot)
    local planted = 0
    for _, pos in ipairs(spots) do
        if not Plant._running then break end

        -- Verify still equipped before each fire
        local curSn, curTool = Plant._getEquippedSeed()
        if not curSn then
            -- Re-equip if tool got consumed
            seedName, toolInstance = Plant._equipSeed(preferSeed, plantConfig.BlacklistMutated)
            if not seedName then break end
        end

        local ok = pcall(function()
            Net.fire("Plant.PlantSeed", pos, seedName, toolInstance)
        end)

        if ok then
            planted += 1
            Plant._stats.planted += 1
            print("[GAG Hub] Planted:", seedName, "at", tostring(pos))
        else
            Plant._stats.errors += 1
        end

        task.wait(0.3) -- small delay between plants
    end

    -- Step 5: Unequip after planting
    Plant._unequipTool()

    if planted > 0 then
        print("[GAG Hub] Auto-Plant cycle: planted", planted, seedName)
    end
end

---------------------------------------------------------------
-- STOP / STATUS
---------------------------------------------------------------

function Plant.stop()
    Plant._running = false
    for _, conn in ipairs(Plant._connections) do
        pcall(function() conn:Disconnect() end)
    end
    Plant._connections = {}
end

function Plant.getStats()
    return Plant._stats
end

end

---------------------------------------------------------------
-- MODULE: RESTOCK SNIPER
---------------------------------------------------------------

Modules.RestockSniper = {}
do
    local M = Modules.RestockSniper
    local Restock = M
Restock._running = false
Restock._thread  = nil
Restock._connections = {}
Restock._stats = { bought = 0, scanned = 0, moneySpent = 0, errors = 0, skipped = 0 }

-- Seed prices loaded from Resources (resources.lua via loadstring)
local SeedPrices = Resources.SeedPrices or {}

---------------------------------------------------------------
-- GET STOCK VALUES
---------------------------------------------------------------

function Restock._getStockFolder()
    local ok, folder = pcall(function()
        return game:GetService("ReplicatedStorage")
            :WaitForChild("StockValues", 5)
            :WaitForChild("SeedShop", 5)
            :WaitForChild("Items", 5)
    end)
    return ok and folder or nil
end

function Restock._getStock(seedName)
    local folder = Restock._getStockFolder()
    if not folder then return -1 end -- unknown
    local val = folder:FindFirstChild(seedName)
    if not val then return 0 end
    if val:IsA("ValueBase") then return (val.Value or 0) end
    -- might be a NumberValue / IntValue directly
    return 0
end

function Restock._getRestockTime()
    local ok, val = pcall(function()
        local unix = game:GetService("ReplicatedStorage")
            :WaitForChild("StockValues", 5)
            :WaitForChild("SeedShop", 5)
            :WaitForChild("UnixNextRestock", 5)
        return unix.Value or 0
    end)
    return ok and val or 0
end

---------------------------------------------------------------
-- START
---------------------------------------------------------------

function Restock.start(config, Net, Utils)
    if Restock._running then return end
    Restock._running = true

    local interval = config.Timings.RestockPollInterval or 1
    local restockConfig = config.Restock or {}

    Restock._thread = task.spawn(function()
        while Restock._running do
            Restock._pollAndBuy(restockConfig, Net, Utils)
            task.wait(interval)
        end
    end)

    print("[GAG Hub] Restock Sniper started (targets: " ..
        #(restockConfig.TargetSeeds or {}) .. " seeds)")
end

---------------------------------------------------------------
-- POLL AND BUY — DRAIN STOCK
-- For each target seed: buy in loop until stock == 0
---------------------------------------------------------------

function Restock._pollAndBuy(restockConfig, Net, Utils)
    Restock._stats.scanned += 1

    local targets = restockConfig.TargetSeeds or {}
    if #targets == 0 then return end

    local blacklist = {}
    for _, name in ipairs(restockConfig.BlacklistedSeeds or {}) do
        blacklist[name] = true
    end

    for _, seedName in ipairs(targets) do
        if not Restock._running then break end
        if blacklist[seedName] then continue end

        -- Affordability check
        local price = SeedPrices[seedName] or 0
        local sheckles = Utils.getSheckles()
        if price > 0 and sheckles < price then
            Restock._stats.skipped += 1
            continue -- can't afford
        end

        local stock = Restock._getStock(seedName)
        if stock == 0 then
            Restock._stats.skipped += 1
            continue -- out of stock
        end

        -- DRAIN: buy in loop until stock empty
        local buyCount = 0
        local maxBuys = (stock > 0 and stock) or 50 -- stock=-1 unknown, try 50
        for i = 1, maxBuys do
            if not Restock._running then break end

            -- Re-check affordability inside loop
            if price > 0 and Utils.getSheckles() < price then break end

            local prevStock = Restock._getStock(seedName)
            Restock._buySeed(Net, seedName)
            task.wait(0.15) -- wait for server to update stock
            local newStock = Restock._getStock(seedName)

            if newStock < prevStock then
                buyCount += 1
                Restock._stats.bought += 1
                Restock._stats.moneySpent += price
            else
                break -- stock didn't change, buy failed
            end
        end

        if buyCount > 0 then
            print("[GAG Hub] Drained:", seedName, "x" .. buyCount, "(stock was:", stock .. ")")
        end
    end
end

---------------------------------------------------------------
-- BUY SEED (actual remote) — returns ok, price
---------------------------------------------------------------

function Restock._buySeed(Net, seedName)
    local ok, err = pcall(function()
        Net.fire("SeedShop.PurchaseSeed", seedName)
    end)
    if ok then
        return true, 0
    end
    Restock._stats.errors += 1
    return false, 0
end

---------------------------------------------------------------
-- STOP / STATUS
---------------------------------------------------------------

function Restock.stop()
    Restock._running = false
    Restock._thread = nil
    for _, conn in ipairs(Restock._connections) do
        pcall(function() conn:Disconnect() end)
    end
    Restock._connections = {}
end

function Restock.getStats()
    return Restock._stats
end
end

---------------------------------------------------------------
-- MODULE: MUTATION TRACKER
---------------------------------------------------------------

Modules.MutationTracker = {}
do
    local M = Modules.MutationTracker
    local Mutation = M
Mutation._running = false
Mutation._thread  = nil
Mutation._connections = {}
Mutation._stats = { tracked = 0, alerts = 0, totalValue = 0 }
Mutation._mutationLog = {}

---------------------------------------------------------------
-- START
---------------------------------------------------------------

function Mutation.start(config, Net, Utils)
    if Mutation._running then return end
    Mutation._running = true

    local interval = config.Timings.MutationScanInterval or 3
    local mutConfig = config.Mutation or {}

    -- Load mutation data for price multipliers
    local RS = game:GetService("ReplicatedStorage")
    local mutationData = {}
    pcall(function()
        local shared = RS:WaitForChild("SharedModules", 10)
        if shared then
            local mData = shared:FindFirstChild("MutationData")
            if mData then
                mutationData = require(mData)
            end
        end
    end)

    -- Listen for real-time mutation events
    local plantMutConn = Net.on("Garden.PlantMutationUpdated",
        function(plantId, mutation)
            Mutation._onMutation("plant", plantId, mutation, mutConfig, Utils)
        end
    )
    if plantMutConn then
        table.insert(Mutation._connections, plantMutConn)
    end

    local fruitMutConn = Net.on("Garden.FruitMutationUpdated",
        function(plantId, fruitId, mutation)
            Mutation._onMutation("fruit", plantId, mutation, mutConfig, Utils)
        end
    )
    if fruitMutConn then
        table.insert(Mutation._connections, fruitMutConn)
    end

    -- Also listen for plant growth (sometimes mutation comes with growth update)
    local growthConn = Net.on("Garden.PlantGrowthUpdated",
        function(plantId, growth, size, mutation)
            if mutation and mutation ~= "" then
                Mutation._onMutation("growth", plantId, mutation, mutConfig, Utils)
            end
        end
    )
    if growthConn then
        table.insert(Mutation._connections, growthConn)
    end

    -- Periodic scan of own garden
    Mutation._thread = task.spawn(function()
        while Mutation._running do
            Mutation._scanGarden(mutConfig, Utils)
            task.wait(interval)
        end
    end)

    print("[GAG Hub] Mutation Tracker started")
end

---------------------------------------------------------------
-- ON MUTATION EVENT
---------------------------------------------------------------

function Mutation._onMutation(source, plantId, mutation, config, Utils)
    if not mutation or mutation == "" then return end

    Mutation._stats.tracked += 1

    local entry = {
        source   = source,
        plantId  = plantId,
        mutation = mutation,
        time     = os.time(),
        priceMult = config.PriceMultipliers[mutation] or 1,
    }

    table.insert(Mutation._mutationLog, entry)

    -- Keep log manageable
    if #Mutation._mutationLog > 500 then
        table.remove(Mutation._mutationLog, 1)
    end

    -- Check if this is an alert-worthy mutation
    local isAlert = false
    if config.TrackAll then
        isAlert = true
    else
        for _, name in ipairs(config.AlertMutations or {}) do
            if name == mutation then
                isAlert = true
                break
            end
        end
    end

    if isAlert then
        Mutation._stats.alerts += 1
        local mult = config.PriceMultipliers[mutation] or 1

        local msg = string.format("[%s] %s mutation: %s (x%d value)",
            source, tostring(plantId), mutation, mult)

        if config.LogToConsole then
            print("[GAG Hub] 🧬 " .. msg)
        end

        Config.Notify("🧬 Mutation Detected!", msg, 8)

        -- Play sound if available
        pcall(function()
            local SoundService = game:GetService("SoundService")
            local sound = Instance.new("Sound")
            sound.SoundId = "rbxassetid://6518811702" -- notification sound
            sound.Volume = 0.5
            sound.Parent = SoundService
            sound:Play()
            game:GetService("Debris"):AddItem(sound, 3)
        end)
    end
end

---------------------------------------------------------------
-- SCAN GARDEN FOR EXISTING MUTATIONS
---------------------------------------------------------------

function Mutation._scanGarden(config, Utils)
    local garden = Utils.getMyGarden()
    if not garden then return end

    local plants = Utils.getPlantsInGarden(garden)
    for _, plant in ipairs(plants) do
        local info = Utils.getPlantInfo(plant)
        if info and info.Mutation and info.Mutation ~= "" then
            -- Already tracked mutations are skipped
            -- This is mainly for initial discovery
            Mutation._stats.tracked += 1
        end
    end
end

---------------------------------------------------------------
-- GET MUTATION PRICE MULTIPLIER
---------------------------------------------------------------

function Mutation.getPriceMultiplier(mutationName, config)
    if config and config.PriceMultipliers then
        return config.PriceMultipliers[mutationName] or 1
    end
    return 1
end

---------------------------------------------------------------
-- GET MUTATION LOG
---------------------------------------------------------------

function Mutation.getLog()
    return Mutation._mutationLog
end

function Mutation.getLogByMutation(mutationName)
    local filtered = {}
    for _, entry in ipairs(Mutation._mutationLog) do
        if entry.mutation == mutationName then
            table.insert(filtered, entry)
        end
    end
    return filtered
end

---------------------------------------------------------------
-- STOP / STATUS
---------------------------------------------------------------

function Mutation.stop()
    Mutation._running = false
    for _, conn in ipairs(Mutation._connections) do
        pcall(function() conn:Disconnect() end)
    end
    Mutation._connections = {}
end

function Mutation.getStats()
    return Mutation._stats
end

end

---------------------------------------------------------------
-- MODULE: WEATHER BOT
---------------------------------------------------------------

Modules.WeatherBot = {}
do
    local M = Modules.WeatherBot
    local Weather = M
Weather._running = false
Weather._thread  = nil
Weather._connections = {}
Weather._stats = { events = 0, alerts = 0, scans = 0 }
Weather._currentWeather = "Unknown"
Weather._currentPhase   = "Unknown"
Weather._weatherLog = {}

---------------------------------------------------------------
-- WEATHER TYPES (from decompiled TimeCycleController)
---------------------------------------------------------------

Weather.Phases = {
    Day         = { color = "☀️", value = "Day" },
    Sunset      = { color = "🌅", value = "Sunset" },
    Moon        = { color = "🌙", value = "Moon" },
    Bloodmoon   = { color = "🔴", value = "Bloodmoon", rare = true },
    Goldmoon    = { color = "🟡", value = "Goldmoon", rare = true },
    Rainbow     = { color = "🌈", value = "Rainbow", rare = true },
    Chained     = { color = "⛓️", value = "Chained Moon", rare = true },
    Pizza       = { color = "🍕", value = "Pizza Moon" },
}

---------------------------------------------------------------
-- START
---------------------------------------------------------------

function Weather.start(config, Net, Utils)
    if Weather._running then return end
    Weather._running = true

    local interval = config.Timings.WeatherPollInterval or 5
    local weatherConfig = config.Weather or {}

    -- Listen for weather effect events
    local weatherEvents = {
        "WeatherEffects.BloodmoonBeam",
        "WeatherEffects.RainbowStart",
        "WeatherEffects.RainbowEnd",
        "WeatherEffects.GoldMoonStrike",
        "WeatherEffects.RainbowMoonStrike",
        "WeatherEffects.BlizzardStart",
        "WeatherEffects.BlizzardEnd",
        "WeatherEffects.ShootingStar",
        "WeatherEffects.ChainPull",
    }

    for _, eventPath in ipairs(weatherEvents) do
        local conn = Net.on(eventPath, function(...)
            Weather._onWeatherEvent(eventPath, weatherConfig, Utils, ...)
        end)
        if conn then
            table.insert(Weather._connections, conn)
        end
    end

    -- Listen for time cycle changes
    local RS = game:GetService("ReplicatedStorage")
    local nightValue = RS:FindFirstChild("Night")
    if nightValue then
        local conn = nightValue.Changed:Connect(function(isNight)
            if isNight then
                Weather._currentPhase = "Night"
                Weather._logEvent("Night", "Night cycle started")
            else
                Weather._currentPhase = "Day"
                Weather._logEvent("Day", "Day cycle started")
            end
        end)
        table.insert(Weather._connections, conn)
    end

    -- Periodic scan of weather state
    Weather._thread = task.spawn(function()
        while Weather._running do
            Weather._scanWeather(weatherConfig, Utils)
            task.wait(interval)
        end
    end)

    print("[GAG Hub] Weather Bot started")
end

---------------------------------------------------------------
-- ON WEATHER EVENT
---------------------------------------------------------------

function Weather._onWeatherEvent(eventPath, config, Utils, ...)
    local args = {...}
    Weather._stats.events += 1

    -- Extract weather type from event path
    local weatherType = eventPath:match("WeatherEffects%.(.+)")
    if not weatherType then return end

    -- Determine if this is an alert-worthy event
    local isSpecial = false
    for _, name in ipairs(config.AlertEvents or {}) do
        if weatherType:match(name) then
            isSpecial = true
            break
        end
    end

    -- Alert for all start events
    if weatherType:match("Start") or weatherType:match("Strike") or
       weatherType:match("Beam") or weatherType:match("Star") then
        isSpecial = true
    end

    if isSpecial then
        Weather._stats.alerts += 1
        local emoji = "🌤️"
        for _, phase in pairs(Weather.Phases) do
            if weatherType:match(phase.value) then
                emoji = phase.color
                break
            end
        end

        local msg = emoji .. " " .. weatherType .. " event detected!"
        print("[GAG Hub] " .. msg)
        Config.Notify("Weather Event!", msg, 10)

        -- Execute configured action
        local action = config.Actions and config.Actions[weatherType]
        if action == "harvest_priority" then
            print("[GAG Hub] Priority harvest triggered by weather event")
        end

        -- Play sound
        if config.PlaySound then
            pcall(function()
                local SoundService = game:GetService("SoundService")
                local sound = Instance.new("Sound")
                sound.SoundId = "rbxassetid://6518811702"
                sound.Volume = 0.8
                sound.Parent = SoundService
                sound:Play()
                game:GetService("Debris"):AddItem(sound, 3)
            end)
        end
    end

    Weather._logEvent(weatherType, "Weather event fired")
end

---------------------------------------------------------------
-- SCAN WEATHER STATE
---------------------------------------------------------------

function Weather._scanWeather(config, Utils)
    Weather._stats.scans += 1

    -- Check moon phase from workspace or ReplicatedStorage
    local RS = game:GetService("ReplicatedStorage")

    -- Check if night
    local isNight = Utils.isNight()

    -- Try to read current moon phase
    local moonPhase = nil
    pcall(function()
        local lighting = game:GetService("Lighting")
        -- Some games store weather in lighting attributes
        moonPhase = lighting:GetAttribute("MoonPhase")
            or lighting:GetAttribute("CurrentPhase")
    end)

    -- Check workspace for weather indicators
    pcall(function()
        local weatherFolder = workspace:FindFirstChild("Weather")
            or workspace:FindFirstChild("WeatherEffects")
        if weatherFolder then
            for _, child in ipairs(weatherFolder:GetChildren()) do
                if child:IsA("BoolValue") and child.Value then
                    Weather._currentWeather = child.Name
                end
            end
        end
    end)

    -- Update phase
    if isNight and Weather._currentPhase == "Day" then
        Weather._currentPhase = "Night"
        Weather._logEvent("Phase", "Transition to Night")
    elseif not isNight and Weather._currentPhase ~= "Day" then
        Weather._currentPhase = "Day"
        Weather._logEvent("Phase", "Transition to Day")
    end
end

---------------------------------------------------------------
-- LOG
---------------------------------------------------------------

function Weather._logEvent(eventType, description)
    table.insert(Weather._weatherLog, {
        type = eventType,
        desc = description,
        time = os.time(),
        phase = Weather._currentPhase,
    })
    if #Weather._weatherLog > 200 then
        table.remove(Weather._weatherLog, 1)
    end
end

---------------------------------------------------------------
-- GETTERS
---------------------------------------------------------------

function Weather.getCurrentWeather()
    return Weather._currentWeather
end

function Weather.getCurrentPhase()
    return Weather._currentPhase
end

function Weather.getLog()
    return Weather._weatherLog
end

---------------------------------------------------------------
-- STOP / STATUS
---------------------------------------------------------------

function Weather.stop()
    Weather._running = false
    for _, conn in ipairs(Weather._connections) do
        pcall(function() conn:Disconnect() end)
    end
    Weather._connections = {}
end

function Weather.getStats()
    return Weather._stats
end

end

---------------------------------------------------------------
-- MODULE: STEAL BOT
---------------------------------------------------------------

Modules.StealBot = {}
do
    local M = Modules.StealBot
    local Steal = M
    local Players = game:GetService("Players")
    Steal._running = false
    Steal._thread  = nil
    Steal._connections = {}
    Steal._stats = { attempts = 0, stolen = 0, returned = 0, errors = 0, nightCycles = 0, skipped = 0 }

    ---------------------------------------------------------------
    -- GET MY PLOT ID
    ---------------------------------------------------------------

    function Steal._getMyPlotId()
        local lp = Players.LocalPlayer
        return lp and lp:GetAttribute("PlotId")
    end

    ---------------------------------------------------------------
    -- CHECK IF GARDEN IS UNLOCKED (owner left plot)
    -- Checks: attribute Locked, BoolValue Locked, owner proximity
    -- Returns true if garden is STEALABLE (unlocked)
    ---------------------------------------------------------------

    function Steal._isGardenUnlocked(garden)
        -- Check: owner player inside plot → locked
        local ownerUserId = garden:GetAttribute("OwnerUserId")
            or garden:GetAttribute("Owner")
        if ownerUserId then
            ownerUserId = tonumber(ownerUserId)
            if ownerUserId then
                local Players = game:GetService("Players")
                local owner = Players:GetPlayerByUserId(ownerUserId)
                if owner and owner.Character then
                    local ownerHRP = owner.Character:FindFirstChild("HumanoidRootPart")
                    if ownerHRP then
                        -- Check if owner position is inside any PlantArea part
                        for _, part in ipairs(garden:GetDescendants()) do
                            if part:IsA("BasePart") then
                                local relPos = part.CFrame:PointToObjectSpace(ownerHRP.Position)
                                local halfSize = part.Size / 2
                                if math.abs(relPos.X) <= halfSize.X
                                    and math.abs(relPos.Y) <= halfSize.Y + 10
                                    and math.abs(relPos.Z) <= halfSize.Z then
                                    return false -- owner inside plot boundary → locked
                                end
                            end
                        end
                    end
                end
            end
        end

        return true -- owner not found or not in plot → unlocked → stealable
    end

    ---------------------------------------------------------------
    -- FIND STEALABLE PROMPTS ON OTHER PLAYERS' GARDENS
    -- Matching decompiled u87 guard logic:
    --   gate: Night.Value == true
    --   prompt.Enabled == true
    --   prompt:GetAttribute("Collected") != true
    --   StealPrompt + HoldDuration > 0 → SKIP (Bamboo)
    --   garden must be UNLOCKED (owner left plot)
    --   get PlantId/FruitId from parent fruit Model
    ---------------------------------------------------------------

    function Steal._findStealablePrompts(myPlotId)
        local results = {}
        local gardens = workspace:FindFirstChild("Gardens")
        if not gardens then return results end

        for _, garden in ipairs(gardens:GetChildren()) do
            local plotNum = tonumber(garden.Name:match("Plot(%d+)"))
            if plotNum and plotNum ~= myPlotId then
                -- Gate: only steal from unlocked gardens
                if not Steal._isGardenUnlocked(garden) then continue end

                local plantsFolder = garden:FindFirstChild("Plants")
                if plantsFolder then
                    for _, plantModel in ipairs(plantsFolder:GetChildren()) do
                        local fruitsFolder = plantModel:FindFirstChild("Fruits")
                        if fruitsFolder then
                            for _, fruitModel in ipairs(fruitsFolder:GetChildren()) do
                                local prompt = fruitModel:FindFirstChild("StealPrompt", true)
                                if not prompt then continue end
                                if not prompt:IsA("ProximityPrompt") then continue end

                                -- Guard: must be enabled, not already collected
                                if not prompt.Enabled then continue end
                                if prompt:GetAttribute("Collected") then continue end

                                -- Guard: Bamboo has HoldDuration > 0, can't steal
                                if prompt.HoldDuration > 0 then continue end

                                -- Read attrs from fruit MODEL (not prompt)
                                local userId = tonumber(fruitModel:GetAttribute("UserId"))
                                local plantId = fruitModel:GetAttribute("PlantId")
                                local fruitId = fruitModel:GetAttribute("FruitId")

                                if userId and plantId then
                                    table.insert(results, {
                                        prompt   = prompt,
                                        userId   = userId,
                                        plantId  = plantId,
                                        fruitId  = fruitId or "",
                                        gardenName = garden.Name,
                                    })
                                end
                            end
                        end
                    end
                end
            end
        end
        return results
    end

    ---------------------------------------------------------------
    -- START
    ---------------------------------------------------------------

    function Steal.start(config, Net, Utils)
        if Steal._running then return end
        Steal._running = true

        local interval = config.Timings.StealInterval or 1.5
        local stealConfig = config.Steal or {}

        -- Listen for server steal confirmation events
        local startedConn = Net.on("Steal.StealStarted", function(fruitInstance)
            print("[GAG Hub] StealStarted confirmed by server:", fruitInstance and fruitInstance.Name or "?")
        end)
        local cancelledConn = Net.on("Steal.StealCancelled", function(fruitInstance)
            print("[GAG Hub] StealCancelled by server:", fruitInstance and fruitInstance.Name or "?")
        end)
        if startedConn then table.insert(Steal._connections, startedConn) end
        if cancelledConn then table.insert(Steal._connections, cancelledConn) end

        Steal._thread = task.spawn(function()
            local wasNight = false
            while Steal._running do
                local isNight = Utils.isNight()
                if isNight and not wasNight then
                    Steal._stats.nightCycles += 1
                    Steal._stats.attempts = 0 -- reset per night
                    print("[GAG Hub] 🌙 Night cycle started - Steal Bot active")
                end
                if isNight then
                    Steal._stealLoop(stealConfig, Net, Utils)
                elseif wasNight then
                    print("[GAG Hub] ☀️ Day started - Steal Bot sleeping")
                    pcall(function() Net.fire("Steal.CancelSteal") end)
                end
                wasNight = isNight
                task.wait(interval)
            end
        end)

        print("[GAG Hub] Steal Bot started (waits for night)")
    end

    ---------------------------------------------------------------
    -- STEAL LOOP
    ---------------------------------------------------------------

    function Steal._stealLoop(stealConfig, Net, Utils)
        local LP = Players.LocalPlayer

        -- If already carrying → return to plot first
        local carrying = LP:GetAttribute("CarryingStolenFruit")
        if carrying then
            Steal._returnFruit(Net, Utils)
            return
        end

        -- Max attempts guard
        local maxAttempts = stealConfig.MaxAttemptsPerNight or 20
        if Steal._stats.attempts >= maxAttempts then return end

        local myPlotId = Steal._getMyPlotId()
        if not myPlotId then return end

        local entries = Steal._findStealablePrompts(myPlotId)
        if #entries == 0 then return end

        -- Sort by value descending (high value first)
        local minValue = stealConfig.MinFruitValue or 0
        table.sort(entries, function(a, b)
            return (Steal._estimateValue(a.plantId) or 0) > (Steal._estimateValue(b.plantId) or 0)
        end)

        for _, entry in ipairs(entries) do
            if not Steal._running then break end
            if Steal._stats.attempts >= maxAttempts then break end

            -- Value filter
            local sellValue = Steal._estimateValue(entry.plantId)
            if minValue > 0 and sellValue < minValue then
                Steal._stats.skipped += 1
                continue
            end

            -- Teleport to fruit → fire prompt → teleport back
            local success = Steal._attemptSteal(entry, Net, Utils)
            if success then
                Steal._stats.stolen += 1
                print("[GAG Hub] Stolen from", entry.gardenName, "plant:", entry.plantId, "value:", sellValue)
                return
            end

            task.wait(0.5)
        end
    end

    ---------------------------------------------------------------
    -- ATTEMPT STEAL (teleport flow)
    -- 1. Get HRP + save old CFrame
    -- 2. Teleport to fruit position (near prompt)
    -- 3. Fire proximity prompt (HoldDuration=0)
    -- 4. Wait for CarryingStolenFruit
    -- 5. Teleport back to own plot
    ---------------------------------------------------------------

    function Steal._attemptSteal(entry, Net, Utils)
        local prompt = entry.prompt
        if not prompt or not prompt.Parent then return false end
        if not prompt.Enabled then return false end
        if prompt:GetAttribute("Collected") then return false end

        Steal._stats.attempts += 1

        local hrp = Utils.getHumanoidRootPart()
        if not hrp then return false end

        -- Save position
        local savedCFrame = hrp.CFrame

        -- Get fruit position (parent of prompt)
        local fruitPart = prompt.Parent
        if not fruitPart or not fruitPart:IsA("BasePart") then
            -- Try finding a BasePart in the fruit model
            fruitPart = prompt.Parent and prompt.Parent:FindFirstChildWhichIsA("BasePart")
        end
        if not fruitPart then return false end

        -- Step 1: Teleport to fruit
        pcall(function()
            hrp.CFrame = fruitPart.CFrame + Vector3.new(0, 3, 0)
        end)
        task.wait(0.8) -- wait for server to register position

        -- Step 2: Fire proximity prompt
        local triggered = false
        pcall(function()
            if fireproximityprompt then
                fireproximityprompt(prompt)
                triggered = true
            else
                -- Fallback: InputHoldBegin/End
                prompt:InputHoldBegin()
                task.wait(math.max(0.09, prompt.HoldDuration + 0.1))
                if prompt and prompt:IsDescendantOf(workspace) then
                    prompt:InputHoldEnd()
                end
                triggered = true
            end
        end)

        if not triggered then
            -- Restore position on failure
            pcall(function() hrp.CFrame = savedCFrame end)
            return false
        end

        -- Step 3: Wait for server to confirm carrying
        task.wait(0.5)
        local carrying = Players.LocalPlayer:GetAttribute("CarryingStolenFruit")

        -- Step 4: Teleport back to own plot (base)
        local garden = Utils.getMyGarden()
        if garden then
            local spawnPoint = garden:FindFirstChild("SpawnPoint") or garden:FindFirstChildWhichIsA("BasePart")
            if spawnPoint then
                pcall(function()
                    hrp.CFrame = spawnPoint.CFrame + Vector3.new(0, 3, 0)
                end)
            end
        else
            -- Fallback: restore old position
            pcall(function() hrp.CFrame = savedCFrame end)
        end

        if carrying then
            Steal._stats.returned += 1
            print("[GAG Hub] Stolen + returned to base")
        end

        return carrying and true or false
    end

    ---------------------------------------------------------------
    -- RETURN FRUIT TO OWN PLOT
    ---------------------------------------------------------------

    function Steal._returnFruit(Net, Utils)
        local LP = Players.LocalPlayer
        local carrying = LP:GetAttribute("CarryingStolenFruit")
        if not carrying then return end
        local garden = Utils.getMyGarden()
        if not garden then return end
        local hrp = Utils.getHumanoidRootPart()
        local spawnPoint = garden:FindFirstChild("SpawnPoint") or garden:FindFirstChildWhichIsA("BasePart")
        if hrp and spawnPoint then
            hrp.CFrame = spawnPoint.CFrame + Vector3.new(0, 3, 0)
            task.wait(1)
        end
        Steal._stats.returned += 1
        print("[GAG Hub] Returned stolen fruit to plot")
    end

    ---------------------------------------------------------------
    -- ESTIMATE FRUIT VALUE
    ---------------------------------------------------------------

    function Steal._estimateValue(seedName)
        local values = {
            ["Carrot"] = 5, ["Strawberry"] = 3, ["Blueberry"] = 5,
            ["Tomato"] = 9, ["Apple"] = 12, ["Cactus"] = 40,
            ["Pineapple"] = 30, ["Banana"] = 35, ["Corn"] = 34,
            ["Grape"] = 45, ["Mango"] = 90, ["Coconut"] = 60,
            ["Cherry"] = 350, ["Pomegranate"] = 900,
            ["Dragon Fruit"] = 150, ["Mushroom"] = 13000,
            ["Sunflower"] = 1750, ["Venus Fly Trap"] = 3000,
            ["Moon Bloom"] = 9000, ["Dragon's Breath"] = 3400,
            ["Ghost Pepper"] = 2500, ["Lotus"] = 6500,
        }
        return values[seedName] or 0
    end

    ---------------------------------------------------------------
    -- STOP / STATUS
    ---------------------------------------------------------------

    function Steal.stop()
        Steal._running = false
        for _, conn in ipairs(Steal._connections) do
            pcall(function() conn:Disconnect() end)
        end
        Steal._connections = {}
    end

    function Steal.getStats()
        return Steal._stats
    end
end

---------------------------------------------------------------
-- MODULE: AUTO BUY PET (Egg Hatch + Rarity Filter)
-- Reference: Controllers_EggHandleController, EggOpenController
-- Remotes: Egg.OpenEgg(eggName), Egg.ConfirmEgg(eggName, petName, size)
--          SellPet(petId)
-- Data: SharedModules.EggData, SharedData.PetData
---------------------------------------------------------------

Modules.AutoBuyPet = {}
do
    local M = Modules.AutoBuyPet
    local Pet = M
    local Players = game:GetService("Players")
    local ReplicatedStorage = game:GetService("ReplicatedStorage")
Pet._running = false
Pet._thread  = nil
Pet._connections = {}
Pet._stats = { hatched = 0, kept = 0, sold = 0, errors = 0, noEggs = 0 }

-- Rarity priority (lower = more common)
local RARITY_ORDER = {
    Common = 1, Uncommon = 2, Rare = 3,
    Legendary = 4, Epic = 4, Mythic = 5, Super = 6,
}

-- Load PetData for species rarity lookup
local PetData = nil
pcall(function()
    PetData = require(ReplicatedStorage:WaitForChild("SharedData"):WaitForChild("PetData"))
end)

-- Get rarity of a pet species from PetData
function Pet._getSpeciesRarity(petName)
    if PetData and PetData[petName] then
        return PetData[petName].Rarity or "Common"
    end
    return "Common"
end

-- Check if a pet passes the rarity filter
function Pet._passesFilter(petName, size, minRarity)
    local speciesRarity = Pet._getSpeciesRarity(petName)
    local gotRank = RARITY_ORDER[speciesRarity] or 1
    local wantRank = RARITY_ORDER[minRarity] or 1
    if gotRank < wantRank then return false end
    -- Also check size filter (Huge always passes)
    if size == "Huge" then return true end
    return true
end

---------------------------------------------------------------
-- FIND EGG TOOLS IN BACKPACK
-- Tools with "Egg" attribute = egg name
---------------------------------------------------------------

function Pet._findEggTools()
    local lp = Players.LocalPlayer
    local backpack = lp and lp:FindFirstChild("Backpack")
    if not backpack then return {} end
    local eggs = {}
    for _, tool in ipairs(backpack:GetChildren()) do
        if tool:IsA("Tool") then
            local eggName = tool:GetAttribute("Egg")
            if eggName and eggName ~= "" then
                table.insert(eggs, { tool = tool, eggName = eggName })
            end
        end
    end
    return eggs
end

---------------------------------------------------------------
-- HATCH ONE EGG
-- 1. Listen for ReplicateOpenEgg once
-- 2. Fire Egg.OpenEgg(eggName)
-- 3. Wait for result (petName, size, type)
-- 4. Fire Egg.ConfirmEgg(eggName, petName, size)
---------------------------------------------------------------

function Pet._hatchEgg(eggName, Net)
    local result = nil
    local done = false

    -- Hook ReplicateOpenEgg once
    local conn
    conn = Net.on("Egg.ReplicateOpenEgg", function(player, eName, petName, size, pos, petType, extra)
        if player == Players.LocalPlayer and eName == eggName then
            result = { petName = petName, size = size, petType = petType }
            done = true
            if conn then conn:Disconnect() end
        end
    end)

    -- Fire OpenEgg
    local fireOk = pcall(function()
        Net.fire("Egg.OpenEgg", eggName)
    end)

    if not fireOk then
        if conn then conn:Disconnect() end
        return nil
    end

    -- Wait for result (max 5s)
    local t = 0
    while not done and t < 5 do
        task.wait(0.1)
        t = t + 0.1
    end

    if conn then pcall(function() conn:Disconnect() end) end

    if not result then return nil end

    -- Confirm the egg
    pcall(function()
        Net.fire("Egg.ConfirmEgg", eggName, result.petName, result.size or "")
    end)

    return result
end

---------------------------------------------------------------
-- SELL PET (find pet tool in backpack by species, sell via NPCS.SellPet)
-- Pet tool attributes: "Pet" = species name, "PetId" = unique ID
---------------------------------------------------------------

function Pet._findAndSellPet(petName, Net)
    -- Wait a bit for pet tool to appear in backpack after ConfirmEgg
    task.wait(1)
    local lp = Players.LocalPlayer
    local backpack = lp and lp:FindFirstChild("Backpack")
    if not backpack then return false end

    -- Also check character (might be equipped)
    local char = lp.Character
    local function scanContainer(container)
        if not container then return nil end
        for _, tool in ipairs(container:GetChildren()) do
            if tool:IsA("Tool") then
                local toolPetName = tool:GetAttribute("Pet")
                if toolPetName == petName then
                    local petId = tool:GetAttribute("PetId")
                    if petId then
                        return { tool = tool, petId = petId }
                    end
                end
            end
        end
        return nil
    end

    local found = scanContainer(backpack) or scanContainer(char)
    if not found then
        print("[GAG Hub] Sell: pet tool not found for", petName)
        return false
    end

    -- Equip the tool first (NPC sell requires holding it)
    if char then
        pcall(function() found.tool.Parent = char end)
        task.wait(0.3)
    end

    -- Fire NPCS.SellPet(petId) — invoke for response
    local ok, result = pcall(function()
        return Net.invoke("NPCS.SellPet", found.petId)
    end)

    if ok and result and result.Success then
        print("[GAG Hub] Sold pet:", petName, "for", tostring(result.SellPrice or "?"))
        return true
    end

    return false
end

---------------------------------------------------------------
-- AUTO HATCH LOOP
---------------------------------------------------------------

function Pet._autoHatch(petConfig, Net, Utils)
    local minRarity = petConfig.MinRarity or "Rare"
    local autoSell = petConfig.AutoSellUnwanted or false

    -- Find egg tools in backpack
    local eggs = Pet._findEggTools()
    if #eggs == 0 then
        Pet._stats.noEggs += 1
        return
    end

    -- Hatch one egg per cycle
    local egg = eggs[1]
    local result = Pet._hatchEgg(egg.eggName, Net)

    if not result then
        Pet._stats.errors += 1
        return
    end

    Pet._stats.hatched += 1
    local speciesRarity = Pet._getSpeciesRarity(result.petName)
    local passes = Pet._passesFilter(result.petName, result.size, minRarity)

    local sizeStr = result.size and (" [" .. result.size .. "]") or ""
    print("[GAG Hub] Hatched:", result.petName, sizeStr, "(" .. speciesRarity .. ")")

    if passes then
        Pet._stats.kept += 1
        print("[GAG Hub] KEPT - matches rarity filter:", minRarity .. "+")
    else
        if autoSell then
            local sold = Pet._findAndSellPet(result.petName, Net)
            if sold then
                Pet._stats.sold += 1
                print("[GAG Hub] SOLD - below rarity filter")
            end
        else
            print("[GAG Hub] Below filter (" .. minRarity .. "+), kept in inventory")
        end
    end
end

---------------------------------------------------------------
-- START / STOP
---------------------------------------------------------------

function Pet.start(config, Net, Utils)
    if Pet._running then return end
    Pet._running = true

    local interval = config.Timings.PetHatchInterval or 2
    local petConfig = config.Pet or {}

    Pet._thread = task.spawn(function()
        while Pet._running do
            Pet._autoHatch(petConfig, Net, Utils)
            task.wait(interval)
        end
    end)

    print("[GAG Hub] Auto-Buy Pet started")
end

function Pet.stop()
    Pet._running = false
    for _, conn in ipairs(Pet._connections) do
        pcall(function() conn:Disconnect() end)
    end
    Pet._connections = {}
end

function Pet.getStats()
    return Pet._stats
end

end
---------------------------------------------------------------

Modules.InventoryOptimizer = {}
do
    local M = Modules.InventoryOptimizer
    local Inventory = M
Inventory._running = false
Inventory._thread  = nil
Inventory._connections = {}
Inventory._stats = { favorited = 0, promoted = 0, dropped = 0, scanned = 0 }

---------------------------------------------------------------
-- START
---------------------------------------------------------------

function Inventory.start(config, Net, Utils)
    if Inventory._running then return end
    Inventory._running = true

    local interval = config.Timings.InventoryCheckInterval or 10
    local invConfig = config.Inventory or {}

    Inventory._thread = task.spawn(function()
        while Inventory._running do
            Inventory._optimize(invConfig, Net, Utils)
            task.wait(interval)
        end
    end)

    print("[GAG Hub] Inventory Optimizer started")
end

---------------------------------------------------------------
-- OPTIMIZE LOGIC
---------------------------------------------------------------

function Inventory._optimize(invConfig, Net, Utils)
    Inventory._stats.scanned += 1
    local LP = Utils.getLocalPlayer()
    local backpack = LP and LP:FindFirstChild("Backpack")
    if not backpack then return end

    for _, tool in ipairs(backpack:GetChildren()) do
        if not tool:IsA("Tool") then continue end

        local itemName = tool.Name
        local itemType = tool:GetAttribute("ItemType") or ""
        local fruitName = tool:GetAttribute("FruitName") or ""
        local mutation  = tool:GetAttribute("Mutation") or ""
        local size      = tool:GetAttribute("Size") or 1

        -- Get base value for this item
        local seedName = fruitName ~= "" and fruitName or itemName
        local baseValue = Inventory._getBaseValue(seedName)
        local mult = Inventory._getMutationMult(mutation, config)
        local estimatedValue = baseValue * (size ^ 2.65) * mult

        -- AUTO-FAVORITE high value fruits
        if invConfig.AutoFavorite ~= false and
           estimatedValue >= (invConfig.FavoriteThreshold or 500) then
            local ok = pcall(function()
                Net.fire("Backpack.SetFruitFavorite", tool.Name, true)
            end)
            if ok then
                Inventory._stats.favorited += 1
            end
        end

        -- AUTO-PROMOTE fruits to inventory
        if invConfig.AutoPromote then
            if itemType == "HarvestedFruit" or
               itemType == "Fruit" or
               fruitName ~= "" then
                local ok = pcall(function()
                    Net.fire("Backpack.PromoteFruit", tool.Name)
                end)
                if ok then
                    Inventory._stats.promoted += 1
                end
            end
        end

        -- AUTO-DROP low value items
        if invConfig.DropThreshold and
           invConfig.DropThreshold > 0 and
           estimatedValue < invConfig.DropThreshold then
            -- Don't drop seeds or tools
            if itemType ~= "SeedTool" and
               itemType ~= "WateringCan" and
               itemType ~= "Sprinkler" and
               not itemName:match("Seed") then
                local ok = pcall(function()
                    Net.fire("DroppedItem.RequestDrop", tool.Name, 1)
                end)
                if ok then
                    Inventory._stats.dropped += 1
                end
            end
        end
    end
end

---------------------------------------------------------------
-- VALUE HELPERS
---------------------------------------------------------------

function Inventory._getBaseValue(seedName)
    local values = {
        ["Carrot"] = 5, ["Strawberry"] = 3, ["Blueberry"] = 5,
        ["Tomato"] = 9, ["Apple"] = 12, ["Cactus"] = 40,
        ["Pineapple"] = 30, ["Banana"] = 35, ["Corn"] = 34,
        ["Grape"] = 45, ["Mango"] = 90, ["Coconut"] = 60,
        ["Cherry"] = 350, ["Pomegranate"] = 900,
        ["Dragon Fruit"] = 150, ["Mushroom"] = 13000,
        ["Sunflower"] = 1750, ["Venus Fly Trap"] = 3000,
        ["Moon Bloom"] = 9000, ["Dragon's Breath"] = 3400,
        ["Ghost Pepper"] = 2500, ["Lotus"] = 6500,
        ["Romanesco"] = 1500, ["Poison Apple"] = 900,
        ["Poison Ivy"] = 1700, ["Glow Mushroom"] = 700,
        ["Horned Melon"] = 200, ["Baby Cactus"] = 70,
        ["Tulip"] = 60, ["Bamboo"] = 800, ["Pumpkin"] = 350,
        ["Pinetree"] = 100, ["Green Bean"] = 10,
        ["Beanstalk"] = 2000, ["Thorn Rose"] = 140,
        ["Acorn"] = 200, ["Moon Bloom"] = 9000,
    }
    return values[seedName] or 0
end

function Inventory._getMutationMult(mutation, config)
    if not mutation or mutation == "" then return 1 end
    if config and config.Mutation and config.Mutation.PriceMultipliers then
        return config.Mutation.PriceMultipliers[mutation] or 1
    end
    local defaults = {
        Gold = 20, Rainbow = 50, Electric = 12,
        Frozen = 10, Bloodlit = 5, Chained = 8, Starstruck = 100,
    }
    return defaults[mutation] or 1
end

---------------------------------------------------------------
-- MANUAL OPERATIONS
---------------------------------------------------------------

function Inventory.favoriteAll(Net, Utils, threshold)
    local count = 0
    local backpack = Utils.getLocalPlayer():FindFirstChild("Backpack")
    if not backpack then return 0 end

    for _, tool in ipairs(backpack:GetChildren()) do
        if tool:IsA("Tool") then
            pcall(function()
                Net.fire("Backpack.SetFruitFavorite", tool.Name, true)
                count += 1
            end)
        end
    end
    Inventory._stats.favorited += count
    return count
end

function Inventory.dropAllLowValue(Net, Utils, threshold)
    local count = 0
    local backpack = Utils.getLocalPlayer():FindFirstChild("Backpack")
    if not backpack then return 0 end

    for _, tool in ipairs(backpack:GetChildren()) do
        if tool:IsA("Tool") then
            local fruitName = tool:GetAttribute("FruitName") or tool.Name
            local baseValue = Inventory._getBaseValue(fruitName)
            if baseValue < threshold then
                pcall(function()
                    Net.fire("DroppedItem.RequestDrop", tool.Name, 1)
                    count += 1
                end)
            end
        end
    end
    Inventory._stats.dropped += count
    return count
end

---------------------------------------------------------------
-- STOP / STATUS
---------------------------------------------------------------

function Inventory.stop()
    Inventory._running = false
    for _, conn in ipairs(Inventory._connections) do
        pcall(function() conn:Disconnect() end)
    end
    Inventory._connections = {}
end

function Inventory.getStats()
    return Inventory._stats
end

end

---------------------------------------------------------------
-- GEAR BUYER MODULE
---------------------------------------------------------------

Modules.GearBuyer = {}
do
    local Gear = Modules.GearBuyer
    Gear._running = false
    Gear._thread = nil
    Gear._stats = { scanned = 0, bought = 0, skipped = 0, errors = 0, moneySpent = 0 }

    -- Gear prices loaded from Resources (resources.lua via loadstring)
    local GearCosts = Resources.GearPrices or {}

    function Gear._getGearStockFolder()
        local ok, folder = pcall(function()
            return game:GetService("ReplicatedStorage")
                :WaitForChild("StockValues", 5)
                :WaitForChild("GearShop", 5)
                :WaitForChild("Items", 5)
        end)
        return ok and folder or nil
    end

    function Gear._getStock(gearName)
        local folder = Gear._getGearStockFolder()
        if not folder then return -1 end
        local val = folder:FindFirstChild(gearName)
        if not val then return 0 end
        if val:IsA("ValueBase") then return (val.Value or 0) end
        return 0
    end

    function Gear._buyGear(Net, gearName)
        local ok, err = pcall(function()
            Net.fire("GearShop.PurchaseGear", gearName)
        end)
        if ok then
            return true, 0
        end
        Gear._stats.errors += 1
        return false, 0
    end

    function Gear._pollAndBuy(gearConfig, Net, Utils)
        Gear._stats.scanned += 1

        local targets = gearConfig.TargetGears or {}
        if #targets == 0 then return end

        for _, gearName in ipairs(targets) do
            if not Gear._running then break end

            local stock = Gear._getStock(gearName)
            if stock == 0 then
                Gear._stats.skipped += 1
                continue
            end

            local cost = GearCosts[gearName] or 0
            local sheckles = Utils.getSheckles()
            if cost > 0 and sheckles < cost then
                Gear._stats.skipped += 1
                continue -- can't afford
            end

            local buyCount = 0
            local maxBuys = (stock > 0 and stock) or 10
            for i = 1, maxBuys do
                if not Gear._running then break end

                -- Re-check affordability inside loop
                if cost > 0 and Utils.getSheckles() < cost then break end

                local prevStock = Gear._getStock(gearName)
                local ok, price = Gear._buyGear(Net, gearName)
                if ok then
                    task.wait(0.15) -- wait for server to update stock
                    local newStock = Gear._getStock(gearName)
                    if newStock < prevStock or newStock < 0 then
                        buyCount += 1
                        Gear._stats.bought += 1
                        Gear._stats.moneySpent += cost
                    else
                        break -- stock didn't change, buy failed
                    end
                else
                    break
                end
            end

            if buyCount > 0 then
                print("[GAG Hub] Gear bought:", gearName, "x" .. buyCount)
            end
        end
    end

    function Gear.start(config, Net, Utils)
        if Gear._running then return end
        Gear._running = true

        local gearConfig = config.Gear or {}
        local interval = gearConfig.PollInterval or 2

        Gear._thread = task.spawn(function()
            while Gear._running do
                Gear._pollAndBuy(gearConfig, Net, Utils)
                task.wait(interval)
            end
        end)

        print("[GAG Hub] Gear Buyer started")
    end

    function Gear.stop()
        Gear._running = false
    end

    function Gear.getStats()
        return Gear._stats
    end
end
---------------------------------------------------------------
-- SEED PACK CLAIMER MODULE
---------------------------------------------------------------

Modules.SeedPackClaimer = {}
do
    local M = Modules.SeedPackClaimer
    local SeedPack = M
    SeedPack._running = false
    SeedPack._thread = nil
    SeedPack._connections = {}
    SeedPack._stats = { claimed = 0, rainbow = 0, gold = 0, regular = 0, scanned = 0, errors = 0 }
    SeedPack._claimed = {} -- track already-claimed packs by instance

    ---------------------------------------------------------------
    -- SCAN & CLAIM
    ---------------------------------------------------------------

    function SeedPack._scanAndClaim(config, Net, Utils)
        SeedPack._stats.scanned += 1
        local LP = Utils.getLocalPlayer()
        if not LP or not LP.Character or not LP.Character:FindFirstChild("HumanoidRootPart") then return end

        local root = LP.Character.HumanoidRootPart
        local spawnFolder = workspace:FindFirstChild("Map")
            and workspace.Map:FindFirstChild("SeedPackSpawnServerLocations")
        if not spawnFolder then return end

        -- Collect all spawn parts, sort by priority (Rainbow > Gold > Regular)
        local spawns = {}
        for _, part in ipairs(spawnFolder:GetChildren()) do
            if part:IsA("BasePart") and not SeedPack._claimed[part] then
                local isRainbow = part:GetAttribute("RainbowSeed") == true
                local isGold = part:GetAttribute("GoldSeed") == true
                local packName = part:GetAttribute("SeedPack")
                local priority = isRainbow and 3 or (isGold and 2 or 1)
                table.insert(spawns, {
                    part = part,
                    rainbow = isRainbow,
                    gold = isGold,
                    pack = packName,
                    priority = priority,
                    dist = (part.Position - root.Position).Magnitude,
                })
            end
        end

        if #spawns == 0 then return end

        -- Sort: priority desc, then distance asc
        table.sort(spawns, function(a, b)
            if a.priority ~= b.priority then return a.priority > b.priority end
            return a.dist < b.dist
        end)

        for _, spawn in ipairs(spawns) do
            if not SeedPack._running then break end
            SeedPack._claimOne(spawn, Net, root)
        end
    end

    function SeedPack._claimOne(spawn, Net, root)
        local part = spawn.part
        if not part or not part.Parent then return end
        if SeedPack._claimed[part] then return end

        local packId = part:GetAttribute("SeedPack") or ""
        local claimed = false

        -- Save original position, teleport near part for server distance check
        local origCFrame = root.CFrame
        pcall(function()
            root.CFrame = part.CFrame * CFrame.new(0, 3, 0)
        end)
        task.wait(0.3)

        -- Method 1: Fire ProximityPrompt (search recursively)
        local prompt = part:FindFirstChildWhichIsA("ProximityPrompt", true)
        if prompt then
            local pOk = pcall(function()
                prompt.HoldDuration = 0
                prompt:InputHoldBegin()
                task.wait(0.1)
                prompt:InputHoldEnd()
                claimed = true
            end)
            if claimed then
                print("[GAG Hub] SeedPack ProximityPrompt fired:", spawn.pack or part.Name)
            end
        end

        -- Method 2: ClickPack remote (fallback)
        if not claimed then
            local ok1 = pcall(function()
                Net.fire("SeedPack.ClickPack", part)
            end)
            if ok1 then claimed = true end
        end

        -- Method 3: OpenSeedPack invoke (fallback)
        if not claimed then
            local ok2 = pcall(function()
                Net.invoke("SeedPack.OpenSeedPack", packId)
            end)
            if ok2 then claimed = true end
        end

        task.wait(0.1)

        -- Mark as claimed regardless (avoid re-attempt)
        SeedPack._claimed[part] = true

        -- Track stats
        if spawn.rainbow then
            SeedPack._stats.rainbow += 1
            SeedPack._stats.claimed += 1
            print("[GAG Hub] 🌈 RAINBOW SEED claimed!")
            Config.Notify("Rainbow Seed!", "Rainbow Seed pack claimed!", 10)
        elseif spawn.gold then
            SeedPack._stats.gold += 1
            SeedPack._stats.claimed += 1
            print("[GAG Hub] 🥇 GOLD SEED claimed!")
            Config.Notify("Gold Seed!", "Gold Seed pack claimed!", 10)
        else
            SeedPack._stats.regular += 1
            SeedPack._stats.claimed += 1
            if packId ~= "" then
                print("[GAG Hub] Seed pack claimed:", packId)
            end
        end

        -- Cleanup: remove from claimed list after part despawns
        task.spawn(function()
            while part and part.Parent do
                task.wait(1)
            end
            SeedPack._claimed[part] = nil
        end)

        -- Return to original position
        task.wait(0.1)
        pcall(function()
            root.CFrame = origCFrame
        end)
    end

    ---------------------------------------------------------------
    -- LISTEN FOR NEW SPAWNS (real-time)
    ---------------------------------------------------------------

    function SeedPack._listenSpawns(config, Net, Utils)
        local spawnFolder = workspace:FindFirstChild("Map")
            and workspace.Map:FindFirstChild("SeedPackSpawnServerLocations")
        if not spawnFolder then return end

        local conn = spawnFolder.ChildAdded:Connect(function(part)
            if not SeedPack._running then return end
            if not part:IsA("BasePart") then return end
            if SeedPack._claimed[part] then return end

            -- Wait for attributes to replicate
            task.wait(0.5)

            local isRainbow = part:GetAttribute("RainbowSeed") == true
            local isGold = part:GetAttribute("GoldSeed") == true

            local LP = Utils.getLocalPlayer()
            local root = LP and LP.Character and LP.Character:FindFirstChild("HumanoidRootPart")
            if not root then return end
            SeedPack._claimOne({
                part = part,
                rainbow = isRainbow,
                gold = isGold,
                pack = part:GetAttribute("SeedPack") or "",
                priority = isRainbow and 3 or (isGold and 2 or 1),
            }, Net)
        end)
        table.insert(SeedPack._connections, conn)
    end

    ---------------------------------------------------------------
    -- START / STOP / STATS
    ---------------------------------------------------------------

    function SeedPack.start(config, Net, Utils)
        if SeedPack._running then return end
        SeedPack._running = true

        local interval = config.Timings.SeedPackPollInterval or 2

        -- Listen for real-time spawns (instant claim for rainbow/gold)
        SeedPack._listenSpawns(config, Net, Utils)

        -- Periodic scan for existing unclaimed packs
        SeedPack._thread = task.spawn(function()
            while SeedPack._running do
                pcall(function()
                    SeedPack._scanAndClaim(config, Net, Utils)
                end)
                task.wait(interval)
            end
        end)

        print("[GAG Hub] Seed Pack Claimer started")
    end

    function SeedPack.stop()
        SeedPack._running = false
        for _, conn in ipairs(SeedPack._connections) do
            pcall(function() conn:Disconnect() end)
        end
        SeedPack._connections = {}
    end

    function SeedPack.getStats()
        return SeedPack._stats
    end
end
---------------------------------------------------------------
-- MODULE: AUTO JOIN SERVER (teleport all accounts to same server)
---------------------------------------------------------------

Modules.AutoJoinServer = {}
do
    local M = Modules.AutoJoinServer
    local TS = game:GetService("TeleportService")
    M._running = false
    M._thread = nil
    M._stats = { teleports = 0, errors = 0 }

    function M.start(config, Net, Utils)
        if M._running then return end
        M._running = true

        local serverConfig = config.Server or {}
        local targetJobId = serverConfig.TargetJobId or ""
        local autoRejoin = serverConfig.AutoRejoin ~= false
        local rejoinDelay = serverConfig.RejoinDelay or 5
        local maxRetries = serverConfig.MaxRetries or 10

        if targetJobId == "" then
            warn("[GAG Hub] AutoJoinServer: no TargetJobId set — set Config.Server.TargetJobId first")
            M._running = false
            return
        end

        -- If already on target server, just enable auto-rejoin
        if game.JobId == targetJobId then
            print("[GAG Hub] Already on target server:", targetJobId)
            if autoRejoin then
                M._setupAutoRejoin(config, Utils)
            end
            return
        end

        -- Teleport to target server
        M._thread = task.spawn(function()
            local LP = Utils.getLocalPlayer()
            local retries = 0

            while M._running and retries < maxRetries do
                retries += 1
                print("[GAG Hub] AutoJoinServer: teleporting to", targetJobId, "(attempt " .. retries .. ")")

                local ok, err = pcall(function()
                    TS:TeleportToPlaceInstance(game.PlaceId, targetJobId, LP)
                end)

                if ok then
                    M._stats.teleports += 1
                    print("[GAG Hub] AutoJoinServer: teleport initiated")
                    -- Wait for teleport to process
                    task.wait(10)
                    -- If still here, teleport may have failed
                    if game.JobId ~= targetJobId then
                        warn("[GAG Hub] AutoJoinServer: still on old server, retrying...")
                    end
                else
                    M._stats.errors += 1
                    warn("[GAG Hub] AutoJoinServer: teleport error:", err)
                end

                task.wait(rejoinDelay)
            end

            if retries >= maxRetries then
                warn("[GAG Hub] AutoJoinServer: max retries reached")
                Config.Notify("Server Join Failed", "Could not join target server after " .. maxRetries .. " attempts", 10)
            end
        end)

        -- Setup auto-rejoin if enabled
        if autoRejoin then
            M._setupAutoRejoin(config, Utils)
        end

        print("[GAG Hub] AutoJoinServer started → JobId:", targetJobId)
    end

    function M._setupAutoRejoin(config, Utils)
        local serverConfig = config.Server or {}
        local targetJobId = serverConfig.TargetJobId or ""
        local rejoinDelay = serverConfig.RejoinDelay or 5

        -- On teleport failure (kicked/disconnected), rejoin target server
        TS.TeleportInitFailed:Connect(function(player, teleportResult, errorMessage)
            if not M._running then return end
            if targetJobId == "" then return end

            warn("[GAG Hub] TeleportInitFailed:", teleportResult, errorMessage, "— rejoining in", rejoinDelay, "s")
            task.wait(rejoinDelay)

            local LP = Utils.getLocalPlayer()
            local retries = 0
            while M._running and retries < 5 do
                retries += 1
                local ok = pcall(function()
                    TS:TeleportToPlaceInstance(game.PlaceId, targetJobId, LP)
                end)
                if ok then break end
                task.wait(rejoinDelay)
            end
        end)

        -- On character respawn (server change), check if we need to rejoin
        local LP = Utils.getLocalPlayer()
        LP.CharacterAdded:Connect(function()
            task.wait(5) -- wait for game to load
            if not M._running then return end
            if targetJobId == "" then return end

            -- Check if we're on wrong server after respawn
            if game.JobId ~= targetJobId then
                print("[GAG Hub] AutoJoinServer: respawned on wrong server, rejoining target...")
                task.wait(rejoinDelay)
                pcall(function()
                    TS:TeleportToPlaceInstance(game.PlaceId, targetJobId, LP)
                end)
            end
        end)
    end

    function M.stop()
        M._running = false
    end

    function M.getStats()
        return M._stats
    end

    -- Helper: get current JobId for sharing
    function M.getCurrentJobId()
        return game.JobId
    end

    -- Helper: set target and start
    function M.setTargetAndStart(jobId, config, Net, Utils)
        if not jobId or jobId == "" then return end
        config.Server.TargetJobId = jobId
        if M._running then M.stop() end
        task.wait(0.5)
        M.start(config, Net, Utils)
    end
end
---------------------------------------------------------------
-- MODULE: AUTO PET CATCH (wild pet tame + purchase)
---------------------------------------------------------------

Modules.AutoPetCatch = {}
do
    local M = Modules.AutoPetCatch
    local CS = game:GetService("CollectionService")
    M._running = false
    M._thread = nil
    M._connections = {}
    M._stats = { caught = 0, scanned = 0, errors = 0, skipped = 0 }
    M._tamed = {} -- track already-tamed pets by instance

    -- Rarity priority: higher = better
    local RARITY_ORDER = { Common = 1, Uncommon = 2, Rare = 3, Legendary = 4, Mythic = 5, Super = 6 }

    function M._passesFilter(refPart, petCatchConfig)
        local price = refPart:GetAttribute("Price") or 0
        local rarity = refPart:GetAttribute("Rarity") or "Common"
        local ownerUserId = refPart:GetAttribute("OwnerUserId") or 0
        local state = refPart:GetAttribute("State") or ""

        -- Skip if already owned
        if ownerUserId ~= 0 then return false end
        -- Skip if not wandering
        if state ~= "wandering" then return false end
        -- Rarity filter
        local minRarity = petCatchConfig.MinRarity or "Common"
        local petRarityVal = RARITY_ORDER[rarity] or 0
        local minRarityVal = RARITY_ORDER[minRarity] or 0
        if petRarityVal < minRarityVal then return false end

        return true
    end

    function M._getWildPetModels()
        local spawnFolder = workspace:FindFirstChild("Map")
            and workspace.Map:FindFirstChild("WildPetSpawns")
        if not spawnFolder then return {} end

        local pets = {}
        for _, model in ipairs(spawnFolder:GetChildren()) do
            if model:IsA("Model") then
                local petName = model:GetAttribute("PetName") or model.Name
                -- Extract UUID from name: WildPet_Bunny_WildPet_uuid
                local uuid = model.Name:match("WildPet_%w+_WildPet_(.+)")
                table.insert(pets, {
                    model = model,
                    petName = petName,
                    uuid = uuid,
                })
            end
        end
        return pets
    end

    function M._getRefPart(uuid)
        local refFolder = workspace:FindFirstChild("Map")
            and workspace.Map:FindFirstChild("WildPetRef")
        if not refFolder then return nil end
        return refFolder:FindFirstChild("WildPet_" .. uuid)
    end

    function M._catchPet(petInfo, petCatchConfig, Net, root)
        local model = petInfo.model
        if not model or not model.Parent then return false end
        if M._tamed[model] then return false end

        -- Get ref part for attributes
        local refPart = petInfo.uuid and M._getRefPart(petInfo.uuid)
        if refPart and not M._passesFilter(refPart, petCatchConfig) then
            M._stats.skipped += 1
            return false
        end

        local rootPart = model:FindFirstChild("RootPart") or model.PrimaryPart or model:FindFirstChildWhichIsA("BasePart")
        if not rootPart then return false end

        local petName = petInfo.petName or "Unknown"
        local rarity = refPart and refPart:GetAttribute("Rarity") or "?"
        local price = refPart and refPart:GetAttribute("Price") or 0

        -- Save original position
        local origCFrame = root.CFrame

        -- Teleport to pet
        pcall(function()
            root.CFrame = rootPart.CFrame * CFrame.new(0, 5, 0)
        end)
        task.wait(0.8)

        -- Find nearest ProximityPrompt to player after teleport
        local nearestPrompt = nil
        local nearestDist = math.huge
        for _, desc in ipairs(workspace:GetDescendants()) do
            if desc:IsA("ProximityPrompt") and desc.Enabled then
                local promptParent = desc.Parent
                if promptParent and promptParent:IsA("BasePart") then
                    local dist = (promptParent.Position - root.Position).Magnitude
                    if dist < nearestDist and dist <= desc.MaxActivationDistance then
                        nearestDist = dist
                        nearestPrompt = desc
                    end
                end
            end
        end

        local caught = false

        -- Method 1: Fire nearest ProximityPrompt (primary)
        if nearestPrompt then
            local pOk = pcall(function()
                nearestPrompt.HoldDuration = 0
                nearestPrompt:InputHoldBegin()
                task.wait(0.2)
                nearestPrompt:InputHoldEnd()
                caught = true
            end)
            if caught then
                print("[GAG Hub] 🐾 ProximityPrompt fired for:", petName, "dist:", math.floor(nearestDist))
            end
        end

        -- Method 2: WildPetTame remote (backup)
        if not caught then
            local ok1, err1 = pcall(function()
                Net.fire("Pets.WildPetTame", model)
            end)
            if ok1 then
                caught = true
                print("[GAG Hub] 🐾 WildPetTame fired for:", petName)
            else
                warn("[GAG Hub] WildPetTame error:", err1)
            end
        end

        -- Method 3: WildPetCollected (backup)
        if not caught then
            pcall(function()
                Net.fire("Pets.WildPetCollected", model)
                caught = true
            end)
        end

        task.wait(1) -- wait for server to process

        -- Return to original position
        if petCatchConfig.AutoReturn ~= false then
            task.wait(0.2)
            pcall(function()
                root.CFrame = origCFrame
            end)
        end

        M._tamed[model] = true

        -- Track stats
        M._stats.caught += 1
        local priceStr = Utils.formatNumber and Utils.formatNumber(price) or tostring(price)
        print("[GAG Hub] 🐾 Caught:", petName, rarity, "¢" .. priceStr)
        Config.Notify("Pet Caught!", petName .. " (" .. rarity .. ") for ¢" .. priceStr, 8)

        -- Cleanup after pet despawns
        task.spawn(function()
            while model and model.Parent do
                task.wait(1)
            end
            M._tamed[model] = nil
        end)

        return true
    end

    function M._scan(petCatchConfig, Net, Utils)
        M._stats.scanned += 1
        local LP = Utils.getLocalPlayer()
        local root = LP and LP.Character and LP.Character:FindFirstChild("HumanoidRootPart")
        if not root then return end

        local petModels = M._getWildPetModels()
        if #petModels == 0 then return end

        -- Build list with ref data, sort by rarity desc then price asc
        local candidates = {}
        for _, pet in ipairs(petModels) do
            if not M._tamed[pet.model] then
                local refPart = pet.uuid and M._getRefPart(pet.uuid)
                local rarity = refPart and refPart:GetAttribute("Rarity") or "Common"
                local price = refPart and refPart:GetAttribute("Price") or 0
                local ownerUserId = refPart and refPart:GetAttribute("OwnerUserId") or 0
                local state = refPart and refPart:GetAttribute("State") or ""

                if ownerUserId == 0 and state == "wandering" then
                    local petPart = pet.model:FindFirstChild("RootPart") or pet.model:FindFirstChildWhichIsA("BasePart")
                    local petPos = petPart and petPart.Position or root.Position
                    table.insert(candidates, {
                        pet = pet,
                        rarity = rarity,
                        price = price,
                        rarityVal = RARITY_ORDER[rarity] or 0,
                        dist = (petPos - root.Position).Magnitude,
                    })
                end
            end
        end

        -- Sort: rarity desc, then price asc (prefer expensive = rare), then distance asc
        table.sort(candidates, function(a, b)
            if a.rarityVal ~= b.rarityVal then return a.rarityVal > b.rarityVal end
            if a.price ~= b.price then return a.price > b.price end
            return a.dist < b.dist
        end)

        -- Catch each
        for _, cand in ipairs(candidates) do
            if not M._running then break end
            if M._passesFilter(cand.pet.uuid and M._getRefPart(cand.pet.uuid) or nil, petCatchConfig) then
                M._catchPet(cand.pet, petCatchConfig, Net, root)
                task.wait(0.5)
            end
        end
    end

    function M._listenSpawns(petCatchConfig, Net, Utils)
        local spawnFolder = workspace:FindFirstChild("Map")
            and workspace.Map:FindFirstChild("WildPetSpawns")
        if not spawnFolder then return end

        local conn = spawnFolder.ChildAdded:Connect(function(model)
            if not M._running then return end
            if not model:IsA("Model") then return end

            -- Wait for attributes to replicate
            task.wait(1)

            local petName = model:GetAttribute("PetName") or model.Name
            local uuid = model.Name:match("WildPet_%w+_WildPet_(.+)")
            local refPart = uuid and M._getRefPart(uuid)

            if refPart and M._passesFilter(refPart, petCatchConfig) then
                local LP = Utils.getLocalPlayer()
                local root = LP and LP.Character and LP.Character:FindFirstChild("HumanoidRootPart")
                if root then
                    print("[GAG Hub] 🐾 New wild pet spawned:", petName)
                    M._catchPet({
                        model = model,
                        petName = petName,
                        uuid = uuid,
                    }, petCatchConfig, Net, root)
                end
            end
        end)
        table.insert(M._connections, conn)
    end

    function M.start(config, Net, Utils)
        if M._running then return end
        M._running = true

        local petCatchConfig = config.PetCatch or {}
        local interval = config.Timings.PetCatchInterval or 3

        -- Listen for real-time spawns
        M._listenSpawns(petCatchConfig, Net, Utils)

        -- Periodic scan
        M._thread = task.spawn(function()
            while M._running do
                pcall(function()
                    M._scan(petCatchConfig, Net, Utils)
                end)
                task.wait(interval)
            end
        end)

        print("[GAG Hub] Auto Pet Catch started")
    end

    function M.stop()
        M._running = false
        for _, conn in ipairs(M._connections) do
            pcall(function() conn:Disconnect() end)
        end
        M._connections = {}
    end

    function M.getStats()
        return M._stats
    end
end

---------------------------------------------------------------
-- MODULE: AUTO CENTER PLOT
-- Periodically teleport player to center of their garden plot
---------------------------------------------------------------

Modules.AutoCenterPlot = {}
do
    local M = Modules.AutoCenterPlot
    local Center = M
    Center._running = false
    Center._thread = nil
    Center._connections = {}

    function Center.start(config, Net, Utils)
        if Center._running then return end
        Center._running = true

        -- One-shot: teleport to soil center on load, then stop
        task.spawn(function()
            task.wait(1) -- brief wait for character to load
            local hrp = Utils.getHumanoidRootPart()
            local garden = Utils.getMyGarden()
            if hrp and garden then
                -- Find center of PlantArea
                local CollectionService = game:GetService("CollectionService")
                local totalPos = Vector3.new(0, 0, 0)
                local count = 0
                for _, part in ipairs(CollectionService:GetTagged("PlantArea")) do
                    if part:IsA("BasePart") and part:IsDescendantOf(garden) then
                        totalPos = totalPos + part.Position
                        count = count + 1
                    end
                end
                if count > 0 then
                    local center = totalPos / count
                    pcall(function()
                        hrp.CFrame = CFrame.new(center + Vector3.new(0, 3, 0))
                    end)
                    print("[GAG Hub] Centered to soil")
                end
            end
            Center._running = false
        end)
    end

    function Center.stop()
        Center._running = false
        for _, conn in ipairs(Center._connections) do
            pcall(function() conn:Disconnect() end)
        end
        Center._connections = {}
    end
end

---------------------------------------------------------------
-- STATUS
---------------------------------------------------------------

local function getFullStatus()
    local lines = {"GAG HUB STATUS", "Sheckles: " .. Utils.formatNumber(Utils.getSheckles()), ""}
    for name, mod in pairs(Modules) do
        local st = Running[name] and "ON" or "OFF"
        local info = ""
        if name == "RestockSniper" then
            local t = Config.Restock.TargetSeeds or {}
            info = #t > 0 and table.concat(t, ", ") or "(none)"
        elseif name == "GearBuyer" then
            local t = Config.Gear.TargetGears or {}
            info = #t > 0 and table.concat(t, ", ") or "(none)"
        elseif name == "AutoPlant" then
            info = (Config.Plant.PlantOrder or "Top") .. " | " .. (Config.Plant.PreferSeed or "any")
        elseif name == "AutoBuyPet" then
            info = "min: " .. (Config.Pet.MinRarity or "Rare")
        elseif name == "MutationTracker" then
            info = "min: " .. (Config.Mutation.MinRarity or "Common")
        elseif name == "AutoSell" then
            info = Config.Sell.AutoSell and "auto" or "manual"
        elseif name == "AutoWater" then
            info = Config.Water.WaterAll and "all" or "dry only"
        elseif name == "StealBot" then
            info = Config.Steal.Enabled and "night mode" or "off"
        end
        lines[#lines+1] = "  " .. st .. " " .. name .. ": " .. info
    end
    return table.concat(lines, "\n")
end

---------------------------------------------------------------
-- LIVE STATS TRACKER
---------------------------------------------------------------

local Stats = {
    startSheckles = 0,
    startTime = os.clock(),
    sessionHarvested = 0,
    sessionPlanted = 0,
    sessionSold = 0,
    sessionBought = 0,
}

-- Capture initial state on load
function Stats.init()
    Stats.startSheckles = Utils.getSheckles()
    Stats.startTime = os.clock()
end

-- Get elapsed time since start
function Stats.getElapsed()
    return os.clock() - Stats.startTime
end

-- Calculate profit/loss since start
function Stats.getProfit()
    return Utils.getSheckles() - Stats.startSheckles
end

-- Count plants in my garden
function Stats.getPlantCount()
    local garden = Utils.getMyGarden()
    if not garden then return 0 end
    local plants = garden:FindFirstChild("Plants")
    return plants and #plants:GetChildren() or 0
end

-- Calculate approximate garden value (all fruits on all plants)
-- Uses SellValueData * size^2.65 as base estimate
function Stats.getGardenValue()
    local garden = Utils.getMyGarden()
    if not garden then return 0 end
    local total = 0
    local plants = garden:FindFirstChild("Plants")
    if not plants then return 0 end
    for _, plantModel in ipairs(plants:GetChildren()) do
        local seedName = plantModel:GetAttribute("SeedName")
        if not seedName then continue end
        local fruitsFolder = plantModel:FindFirstChild("Fruits")
        if fruitsFolder then
            for _, fruitModel in ipairs(fruitsFolder:GetChildren()) do
                local size = fruitModel:GetAttribute("SizeMultiplier") or 1
                local mutation = fruitModel:GetAttribute("Mutation")
                local baseVal = Stats._sellData[seedName] or 0
                local sizeMult = size ^ 2.65
                local mutMult = 1
                if mutation and Stats._mutData and Stats._mutData[mutation] then
                    mutMult = Stats._mutData[mutation].PriceMultiplier or 1
                end
                total += math.floor(baseVal * sizeMult * mutMult)
            end
        else
            -- Single-harvest: plant itself has value
            local size = plantModel:GetAttribute("SizeMultiplier") or 1
            local baseVal = Stats._sellData[seedName] or 0
            total += math.floor(baseVal * (size ^ 2.65))
        end
    end
    return total
end

-- Count backpack items and estimate seed value
function Stats.getBackpackInfo()
    local lp = Players and Players.LocalPlayer
    local bp = lp and lp:FindFirstChild("Backpack")
    if not bp then return 0, 0, 0 end
    local totalItems = 0
    local seedCount = 0
    local fruitCount = 0
    for _, tool in ipairs(bp:GetChildren()) do
        if tool:IsA("Tool") then
            totalItems += 1
            if tool:GetAttribute("SeedTool") then
                seedCount += 1
            elseif tool:GetAttribute("FruitName") or tool:GetAttribute("IsFruit") then
                fruitCount += 1
            end
        end
    end
    return totalItems, seedCount, fruitCount
end

-- Count active modules
function Stats.getActiveModules()
    local count = 0
    local names = {}
    for name, active in pairs(Running) do
        if active then
            count += 1
            table.insert(names, name)
        end
    end
    return count, names
end

-- Build full live stats text for Status tab
function Stats.buildText()
    local sheckles = Utils.getSheckles()
    local profit = Stats.getProfit()
    local elapsed = Stats.getElapsed()
    local gardenVal = Stats.getGardenValue()
    local plantCount = Stats.getPlantCount()
    local totalItems, seedCount, fruitCount = Stats.getBackpackInfo()
    local activeCount, activeNames = Stats.getActiveModules()

    local profitSign = profit >= 0 and "+" or ""
    local profitColor = profit >= 0 and "🟢" or "🔴"

    local lines = {}

    -- Money section
    table.insert(lines, "💰 **Money**")
    table.insert(lines, string.format("  Current: %s", Utils.formatNumber(sheckles)))
    table.insert(lines, string.format("  Start:   %s", Utils.formatNumber(Stats.startSheckles)))
    table.insert(lines, string.format("  Profit:  %s%s%s", profitColor, profitSign, Utils.formatNumber(profit)))
    table.insert(lines, "")

    -- Session
    table.insert(lines, "⏱ **Session**")
    table.insert(lines, string.format("  Runtime: %s", Utils.formatTime(elapsed)))
    table.insert(lines, "")

    -- Garden
    table.insert(lines, "🌱 **Garden**")
    table.insert(lines, string.format("  Plants: %d", plantCount))
    table.insert(lines, string.format("  Value:  %s", Utils.formatNumber(gardenVal)))
    table.insert(lines, "")

    -- Backpack
    table.insert(lines, "🎒 **Backpack**")
    table.insert(lines, string.format("  Items: %d  (Seeds: %d, Fruits: %d)", totalItems, seedCount, fruitCount))
    table.insert(lines, "")

    -- Module configs
    table.insert(lines, string.format("⚡ **Modules** (%d active)", activeCount))
    for name, mod in pairs(Modules) do
        local st = Running[name] and "✅" or "⬜"
        local info = ""
        if name == "RestockSniper" then
            local t = Config.Restock.TargetSeeds or {}
            info = #t > 0 and table.concat(t, ", ") or "(none)"
        elseif name == "GearBuyer" then
            local t = Config.Gear.TargetGears or {}
            info = #t > 0 and table.concat(t, ", ") or "(none)"
        elseif name == "AutoPlant" then
            info = (Config.Plant.PlantOrder or "Top") .. " | " .. (Config.Plant.PreferSeed or "any")
        elseif name == "AutoBuyPet" then
            info = "min: " .. (Config.Pet.MinRarity or "Rare")
        elseif name == "MutationTracker" then
            info = "min: " .. (Config.Mutation.MinRarity or "Common")
        elseif name == "AutoSell" then
            info = Config.Sell.AutoSell and "auto" or "manual"
        elseif name == "AutoWater" then
            info = Config.Water.WaterAll and "all" or "dry only"
        elseif name == "StealBot" then
            info = Config.Steal.Enabled and "night mode" or "off"
        end
        table.insert(lines, string.format("  %s %s: %s", st, name, info))
    end

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

-- SellValueData cache (loaded lazily)
Stats._sellData = {}
Stats._mutData = {}
task.spawn(function()
    pcall(function()
        local RS = game:GetService("ReplicatedStorage")
        local shared = RS:WaitForChild("SharedModules", 10)
        if shared then
            local svd = shared:WaitForChild("SellValueData", 5)
            if svd then Stats._sellData = require(svd) end
            local md = shared:WaitForChild("MutationData", 5)
            if md then Stats._mutData = require(md) end
        end
    end)
end)

---------------------------------------------------------------
-- RAYFIELD UI
---------------------------------------------------------------

local AllSeeds = Resources.AllSeeds or {
    "Strawberry","Carrot","Blueberry","Tomato","Green Bean",
    "Apple","Pineapple","Corn","Banana","Cactus","Grape",
    "Coconut","Tulip","Baby Cactus","Mango","Pinetree",
    "Thorn Rose","Dragon Fruit","Acorn","Horned Melon",
    "Pumpkin","Cherry","Glow Mushroom","Bamboo",
    "Pomegranate","Poison Apple","Romanesco","Poison Ivy",
    "Sunflower","Beanstalk","Ghost Pepper","Venus Fly Trap",
    "Dragon's Breath","Lotus","Moon Bloom","Mushroom",
}

local AllGears = Resources.AllGears or {
    "Trowel","Speed Mushroom","Jump Mushroom","Common Watering Can",
    "Common Sprinkler","Sign","Shrink Mushroom","Supersize Mushroom",
    "Flashbang","Uncommon Sprinkler","Lantern","Teleporter",
    "Rare Sprinkler","Gnome","Basic Pot","Legendary Sprinkler",
    "Super Watering Can","Super Sprinkler","Wheelbarrow",
}

local function createUI()
    local Rayfield = nil
    local ok = pcall(function()
        Rayfield = loadstring(game:HttpGet('https://[Log in to view URL]'))()
    end)
    if not ok or not Rayfield then warn("[GAG Hub] Rayfield failed") return false end

    -- Init live stats tracker (capture starting money)
    Stats.init()

    local Window = Rayfield:CreateWindow({
        Name = "🌿 " .. Config.UI.Title .. " v" .. VERSION,
        LoadingTitle = Config.UI.Title .. " v" .. VERSION,
        LoadingSubtitle = "V-Team-X",
        ConfigurationSaving = { Enabled = true, FolderName = "GAGHub", FileName = "config" },
        Discord = { Enabled = false }, KeySystem = false,
    })

    -------------------------------------------------------
    -- TAB 1: FARMING (harvest + sell + water + plant)
    -------------------------------------------------------
    local FarmTab = Window:CreateTab("Farming", 6034510) -- leaf icon

    FarmTab:CreateSection("⚡ Auto Modules")
    for _, e in ipairs({
        {"Auto Harvest",   "AutoHarvest"},
        {"Auto Sell",      "AutoSell"},
        {"Auto Water",     "AutoWater"},
        {"Auto Plant",     "AutoPlant"},
    }) do
        FarmTab:CreateToggle({
            Name = e[1], CurrentValue = false, Flag = e[2],
            Callback = function(v) if v then startModule(e[2]) else stopModule(e[2]) end end
        })
    end

    FarmTab:CreateSection("⏱ Intervals")
    FarmTab:CreateSlider({Name="Harvest", Range={0.5,10}, Increment=0.5, Suffix="s", CurrentValue=Config.Timings.HarvestInterval, Flag="HarvestInterval", Callback=function(v) Config.Timings.HarvestInterval=v end})
    FarmTab:CreateSlider({Name="Sell", Range={1,30}, Increment=1, Suffix="s", CurrentValue=Config.Timings.SellInterval, Flag="SellInterval", Callback=function(v) Config.Timings.SellInterval=v end})
    FarmTab:CreateSlider({Name="Water", Range={1,15}, Increment=1, Suffix="s", CurrentValue=Config.Timings.WaterInterval, Flag="WaterInterval", Callback=function(v) Config.Timings.WaterInterval=v end})
    FarmTab:CreateSlider({Name="Plant", Range={1,15}, Increment=1, Suffix="s", CurrentValue=Config.Timings.PlantInterval, Flag="PlantInterval", Callback=function(v) Config.Timings.PlantInterval=v end})

    FarmTab:CreateSection("💧 Water Config")
    FarmTab:CreateToggle({Name="Water Fully Grown", CurrentValue=false, Flag="WaterFullyGrown", Callback=function(v) Config.Water.WaterFullyGrown=v end})
    FarmTab:CreateDropdown({Name="Required Can (empty=any)", Options={"","Common Watering Can","Super Watering Can"}, CurrentOption=Config.Water.RequiredCan, Flag="RequiredCan", Callback=function(v) Config.Water.RequiredCan=v end})

    FarmTab:CreateSection("🌱 Plant Config")
    FarmTab:CreateDropdown({Name="Plant Order", Options={"Top","Bottom","Random"}, CurrentOption=Config.Plant.PlantOrder, Flag="PlantOrder", Callback=function(v) Config.Plant.PlantOrder=v end})
    FarmTab:CreateSlider({Name="Grid Spacing", Range={2,8}, Increment=0.5, Suffix=" studs", CurrentValue=Config.Plant.GridSpacing, Flag="GridSpacing", Callback=function(v) Config.Plant.GridSpacing=v end})
    FarmTab:CreateInput({Name="Prefer Seed (empty=any)", PlaceholderText="e.g. Carrot", RemoveTextAfterFocusLost=false, Flag="PreferSeed", Callback=function(v) Config.Plant.PreferSeed = (v~="" and v or nil) end})
    FarmTab:CreateToggle({Name="Skip Mutated Seeds", CurrentValue=Config.Plant.BlacklistMutated, Flag="BlacklistMutated", Callback=function(v) Config.Plant.BlacklistMutated=v end})

    -------------------------------------------------------
    -- TAB 2: SHOP & PETS (restock + inventory + pets)
    -------------------------------------------------------
    local ShopTab = Window:CreateTab("Shop", 6031790) -- shopping-cart icon

    ShopTab:CreateSection("🎯 Restock Sniper")
    ShopTab:CreateToggle({Name="Enabled", CurrentValue=false, Flag="RestockSniper", Callback=function(v) if v then startModule("RestockSniper") else stopModule("RestockSniper") end end})
    ShopTab:CreateSlider({Name="Poll", Range={0.5,5}, Increment=0.5, Suffix="s", CurrentValue=Config.Timings.RestockPollInterval, Flag="RestockPollInterval", Callback=function(v) Config.Timings.RestockPollInterval=v end})
    ShopTab:CreateDropdown({Name="Buy Targets", Options=AllSeeds, CurrentOption=Config.Restock.TargetSeeds, MultipleOptions=true, Flag="RestockTargets", Callback=function(opts) Config.Restock.TargetSeeds=opts end})
    ShopTab:CreateDropdown({Name="Blacklist", Options=AllSeeds, CurrentOption=Config.Restock.BlacklistedSeeds, MultipleOptions=true, Flag="RestockBlacklist", Callback=function(opts) Config.Restock.BlacklistedSeeds=opts end})

    ShopTab:CreateSection("🔧 Auto Buy Gear")
    ShopTab:CreateToggle({Name="Enabled", CurrentValue=false, Flag="GearBuyer", Callback=function(v) if v then startModule("GearBuyer") else stopModule("GearBuyer") end end})
    ShopTab:CreateSlider({Name="Poll Interval", Range={1,10}, Increment=1, Suffix="s", CurrentValue=5, Flag="GearPollInterval", Callback=function(v) Config.Gear.PollInterval=v end})
    ShopTab:CreateDropdown({Name="Buy Gears", Options=AllGears, CurrentOption=Config.Gear.TargetGears, MultipleOptions=true, Flag="GearTargets", Callback=function(opts) Config.Gear.TargetGears=opts end})

    ShopTab:CreateSection("📦 Inventory")
    ShopTab:CreateToggle({Name="Optimizer", CurrentValue=false, Flag="InventoryOptimizer", Callback=function(v) if v then startModule("InventoryOptimizer") else stopModule("InventoryOptimizer") end end})
    ShopTab:CreateSlider({Name="Check", Range={5,60}, Increment=5, Suffix="s", CurrentValue=Config.Timings.InventoryCheckInterval, Flag="InventoryCheckInterval", Callback=function(v) Config.Timings.InventoryCheckInterval=v end})

    ShopTab:CreateSection("🐾 Pets")
    ShopTab:CreateToggle({Name="Auto Hatch", CurrentValue=false, Flag="AutoBuyPet", Callback=function(v) if v then startModule("AutoBuyPet") else stopModule("AutoBuyPet") end end})
    ShopTab:CreateSlider({Name="Hatch", Range={1,10}, Increment=0.5, Suffix="s", CurrentValue=Config.Timings.PetHatchInterval, Flag="PetHatchInterval", Callback=function(v) Config.Timings.PetHatchInterval=v end})
    ShopTab:CreateDropdown({Name="Min Rarity", Options={"Common","Uncommon","Rare","Legendary","Mythic","Super"}, CurrentOption={Config.Pet.MinRarity}, MultipleOptions=false, Flag="PetMinRarity", Callback=function(opt) Config.Pet.MinRarity=type(opt)=="table" and opt[1] or opt end})
    ShopTab:CreateToggle({Name="Sell Unwanted", CurrentValue=Config.Pet.AutoSellUnwanted, Flag="PetAutoSell", Callback=function(v) Config.Pet.AutoSellUnwanted=v end})

    -------------------------------------------------------
    -- TAB 3: EVENTS (mutations + weather + steal)
    -------------------------------------------------------
    local EventTab = Window:CreateTab("Events", 6035974) -- zap icon

    EventTab:CreateSection("🧬 Mutations")
    EventTab:CreateToggle({Name="Tracker", CurrentValue=false, Flag="MutationTracker", Callback=function(v) if v then startModule("MutationTracker") else stopModule("MutationTracker") end end})
    EventTab:CreateSlider({Name="Scan", Range={1,10}, Increment=1, Suffix="s", CurrentValue=Config.Timings.MutationScanInterval, Flag="MutationScanInterval", Callback=function(v) Config.Timings.MutationScanInterval=v end})

    EventTab:CreateSection("🌧 Weather")
    EventTab:CreateToggle({Name="Weather Bot", CurrentValue=false, Flag="WeatherBot", Callback=function(v) if v then startModule("WeatherBot") else stopModule("WeatherBot") end end})
    EventTab:CreateSlider({Name="Poll", Range={1,15}, Increment=1, Suffix="s", CurrentValue=Config.Timings.WeatherPollInterval, Flag="WeatherPollInterval", Callback=function(v) Config.Timings.WeatherPollInterval=v end})

    EventTab:CreateSection("🌱 Seed Pack Claimer")
    EventTab:CreateToggle({Name="Auto Claim", CurrentValue=false, Flag="SeedPackClaimer", Callback=function(v) if v then startModule("SeedPackClaimer") else stopModule("SeedPackClaimer") end end})
    EventTab:CreateSlider({Name="Poll", Range={0.1,5}, Increment=0.1, Suffix="s", CurrentValue=Config.Timings.SeedPackPollInterval, Flag="SeedPackPollInterval", Callback=function(v) Config.Timings.SeedPackPollInterval=v end})

    EventTab:CreateSection("🐾 Wild Pet Catch")
    EventTab:CreateToggle({Name="Auto Catch", CurrentValue=false, Flag="AutoPetCatch", Callback=function(v) if v then startModule("AutoPetCatch") else stopModule("AutoPetCatch") end end})
    EventTab:CreateSlider({Name="Scan", Range={1,15}, Increment=1, Suffix="s", CurrentValue=Config.Timings.PetCatchInterval, Flag="PetCatchInterval", Callback=function(v) Config.Timings.PetCatchInterval=v end})
    EventTab:CreateDropdown({Name="Min Rarity", Options={"Common","Uncommon","Rare","Legendary","Mythic","Super"}, CurrentOption={Config.PetCatch.MinRarity}, MultipleOptions=false, Flag="PetCatchMinRarity", Callback=function(opt) Config.PetCatch.MinRarity=type(opt)=="table" and opt[1] or opt end})
    EventTab:CreateToggle({Name="Return After Catch", CurrentValue=Config.PetCatch.AutoReturn, Flag="PetCatchAutoReturn", Callback=function(v) Config.PetCatch.AutoReturn=v end})

    EventTab:CreateSection("🌙 Steal Bot")
    EventTab:CreateToggle({Name="Enabled (Night)", CurrentValue=false, Flag="StealBot", Callback=function(v) if v then startModule("StealBot") else stopModule("StealBot") end end})
    EventTab:CreateSlider({Name="Interval", Range={0.5,5}, Increment=0.5, Suffix="s", CurrentValue=Config.Timings.StealInterval, Flag="StealInterval", Callback=function(v) Config.Timings.StealInterval=v end})
    EventTab:CreateSlider({Name="Max/Night", Range={5,100}, Increment=5, Suffix="", CurrentValue=Config.Steal.MaxAttemptsPerNight, Flag="MaxStealAttempts", Callback=function(v) Config.Steal.MaxAttemptsPerNight=v end})
    EventTab:CreateSlider({Name="Min Value", Range={100,10000}, Increment=100, Suffix=" $", CurrentValue=Config.Steal.MinFruitValue, Flag="MinFruitValue", Callback=function(v) Config.Steal.MinFruitValue=v end})

    EventTab:CreateSection("🏡 Auto Center Plot")
    EventTab:CreateToggle({Name="Enabled (on load)", CurrentValue=false, Flag="AutoCenterPlot", Callback=function(v) if v then startModule("AutoCenterPlot") else stopModule("AutoCenterPlot") end end})

    -------------------------------------------------------
    -- TAB: SERVER (auto join / boost)
    -------------------------------------------------------
    local ServerTab = Window:CreateTab("Server", 6035172) -- globe icon

    ServerTab:CreateSection("📡 Current Server")
    local CurrentJobParagraph = ServerTab:CreateParagraph({
        Title = "Your JobId",
        Content = game.JobId ~= "" and game.JobId or "N/A (studio)"
    })
    ServerTab:CreateButton({Name="📋 Copy JobId", Callback=function()
        if setclipboard then
            setclipboard(game.JobId)
            Rayfield:Notify({Title="Copied!", Content="JobId copied to clipboard", Duration=3})
        else
            Rayfield:Notify({Title="JobId", Content=game.JobId, Duration=10})
        end
    end})

    ServerTab:CreateSection("🚀 Join Target Server")
    ServerTab:CreateInput({
        Name = "Target JobId",
        PlaceholderText = "Paste server JobId here...",
        RemoveTextAfterFocusLost = false,
        Flag = "TargetJobId",
        Callback = function(v)
            Config.Server.TargetJobId = v
        end
    })
    ServerTab:CreateToggle({
        Name = "Auto Join Server",
        CurrentValue = false,
        Flag = "AutoJoinServer",
        Callback = function(v)
            if v then
                if Config.Server.TargetJobId == "" then
                    Rayfield:Notify({Title="Error", Content="Set Target JobId first!", Duration=5})
                    return
                end
                startModule("AutoJoinServer")
            else
                stopModule("AutoJoinServer")
            end
        end
    })
    ServerTab:CreateToggle({Name="Auto Rejoin on Disconnect", CurrentValue=Config.Server.AutoRejoin, Flag="ServerAutoRejoin", Callback=function(v) Config.Server.AutoRejoin=v end})
    ServerTab:CreateSlider({Name="Rejoin Delay", Range={3,30}, Increment=1, Suffix="s", CurrentValue=Config.Server.RejoinDelay, Flag="ServerRejoinDelay", Callback=function(v) Config.Server.RejoinDelay=v end})
    ServerTab:CreateSlider({Name="Max Retries", Range={1,50}, Increment=1, Suffix="", CurrentValue=Config.Server.MaxRetries, Flag="ServerMaxRetries", Callback=function(v) Config.Server.MaxRetries=v end})

    -- Quick join: paste JobId + 1-click
    ServerTab:CreateSection("⚡ Quick Join")
    ServerTab:CreateButton({Name="🔄 Join Now (use input above)", Callback=function()
        if Config.Server.TargetJobId == "" then
            Rayfield:Notify({Title="Error", Content="Set Target JobId first!", Duration=5})
            return
        end
        print("[GAG Hub] Quick join →", Config.Server.TargetJobId)
        local LP = game:GetService("Players").LocalPlayer
        pcall(function()
            game:GetService("TeleportService"):TeleportToPlaceInstance(game.PlaceId, Config.Server.TargetJobId, LP)
        end)
    end})

    -------------------------------------------------------
    -- TAB 4: LIVE STATUS
    -------------------------------------------------------
    local StatusTab = Window:CreateTab("Status", 6030690) -- activity icon

    StatusTab:CreateSection("📊 Live Stats (auto-refresh)")

    local StatsParagraph = StatusTab:CreateParagraph({
        Title = "Session Overview",
        Content = "Loading stats..."
    })

    StatusTab:CreateSection("🎮 Controls")
    StatusTab:CreateButton({Name="✅ Enable All", Callback=function()
        for n in pairs(Modules) do startModule(n) end
        Rayfield:Notify({Title="GAG Hub",Content="All modules enabled",Duration=3})
    end})
    StatusTab:CreateButton({Name="❌ Disable All", Callback=function()
        for n in pairs(Modules) do stopModule(n) end
        Rayfield:Notify({Title="GAG Hub",Content="All modules disabled",Duration=3})
    end})

    -- Live update loop (every 2 seconds)
    task.spawn(function()
        while true do
            pcall(function()
                StatsParagraph:Set({Title="Session Overview", Content=Stats.buildText()})
            end)
            task.wait(2)
        end
    end)

    pcall(function() Rayfield:LoadConfiguration() end)
    return true
end

---------------------------------------------------------------
-- CONSOLE API
---------------------------------------------------------------

_G.GAGHub = {
    Config = Config, Modules = Modules, Net = Networking, Utils = Utils,
    toggle = function(name) toggleModule(name) print("[GAG Hub] " .. name .. ": " .. (Running[name] and "ON" or "OFF")) end,
    start = startModule, stop = stopModule,
    status = function() print(getFullStatus()) end,
    enableAll = function() for n in pairs(Modules) do startModule(n) end end,
    disableAll = function() for n in pairs(Modules) do stopModule(n) end end,
    stats = function(name) if Modules[name] and Modules[name].getStats then for k,v in pairs(Modules[name].getStats()) do print("  "..k..": "..tostring(v)) end end end,
}

---------------------------------------------------------------
-- STARTUP
---------------------------------------------------------------

local LP = Utils.getLocalPlayer()

LP.CharacterAdded:Connect(function()
    task.wait(3)
    for name, active in pairs(Running) do
        if active then task.spawn(function() stopModule(name) task.wait(1) startModule(name) end) end
    end
end)

task.spawn(function()
    local VirtualUser = game:GetService("VirtualUser")
    LP.Idled:Connect(function()
        VirtualUser:CaptureController()
        VirtualUser:ClickButton2(Vector2.new())
    end)
end)

task.spawn(createUI)
Config.Notify("🌿Grow A Garden 2🌿", "by Jonathan.", 5)
print("GAG HUB loaded! Console: _G.GAGHub")

Embed on website

To embed this project on your website, copy the following code and paste it into your website's HTML: