
local MOD = "AutoMetalFarmCollector"
local VERSION = "1.0.0"

local C = {
    EnableGrowthBoost = false,
    GrowthSpeedMultiplier = 1.0,

    DefaultBaseGrowthSeconds = 120,
    ResourceBaseGrowthSeconds = { Titanium = 120 },
    DepositDelayAfterReadySeconds = 5,
    DepositAmountDefault = 5,
    DepositAmounts = {},
    RetryDepositSeconds = 30,

    StartupDelayMs = 5000,
    TickMs = 1000,
    ScanSeconds = 10,
    MaxFarmsPerTick = 4,

    PauseDuringMapUnload = true,
    MapUnloadPauseSeconds = 20,
    ClearRuntimeStateOnEndPlay = true,
    UseLifecycleHooks = false,
    ProcessOnlyFreshScans = true,
    ClearObjectListsAfterTick = true,
    LogShutdown = false,

    StorageRadius = 20000.0,
    MaxStorageTargetsTried = 12,
    StrictNearestStorage = true,
    ReacquireObjectsBeforeDeposit = true,
    TryBoundMemberCalls = true,

    UseDirectClassScan = true,
    UseComponentOwnerScan = true,
    UseBeginPlayHook = false,
    UseActorNameFallbackScan = false,

    FarmClassNames = {
        "BP_MetalFarm_C",
        "BP_MetalFarm",
        "/Game/Blueprints/BaseBuilding/BP_MetalFarm.BP_MetalFarm_C",
        "BlueprintGeneratedClass'/Game/Blueprints/BaseBuilding/BP_MetalFarm.BP_MetalFarm_C'",
    },
    FarmComponentClassNames = {
        "UWESeedGrowerComponent",
        "UWESeedGrowerReplicatorComponent",
        "UWEInventoryComponent",
        "UWEInventoryInteractionComponent",
        "UWEPowerSystemComponent",
        "UWESaveComponent",
    },

    StorageClassNames = {
        "BP_Locker_Wall_C",
        "BP_Locker_Floor_C",
        "BP_Storage_Cache_C",
        "BP_Tailing_Chest_C",
        "SN2Locker",
        "BP_FloatingLocker_Carryable_C",
        "BP_HeavyFloatingLocker_Carryable_C",
        "BP_SuperHeavyFloatingLocker_Carryable_C",
        "BP_WorldSupplyLocker_C",
        "BP_BoxOfHolding_C",
    },
    StorageComponentClassNames = {
        "UWEInventoryComponent",
        "UWEInventoryInteractionComponent",
        "UWESaveComponent",
    },

    ItemTypePaths = {
        Titanium = { "/Game/Data/ItemType/Resource/DA_Titanium_ItemType.DA_Titanium_ItemType", "/Game/Data/ItemType/Resource/DA_Titanium_ItemType" },
        Copper   = { "/Game/Data/ItemType/DA_Copper_ItemType.DA_Copper_ItemType", "/Game/Data/ItemType/DA_Copper_ItemType", "/Game/Data/ItemType/Resource/DA_Copper_ItemType.DA_Copper_ItemType", "/Game/Data/ItemType/Resource/DA_Copper_ItemType" },
        Silver   = { "/Game/Data/ItemType/Resource/DA_Silver_ItemType.DA_Silver_ItemType", "/Game/Data/ItemType/Resource/DA_Silver_ItemType" },
        Gold     = { "/Game/Data/ItemType/Resource/DA_Gold_ItemType.DA_Gold_ItemType", "/Game/Data/ItemType/Resource/DA_Gold_ItemType" },
        Quartz   = { "/Game/Data/ItemType/Resource/DA_Quartz_ItemType.DA_Quartz_ItemType", "/Game/Data/ItemType/Resource/DA_Quartz_ItemType" },
        Lead     = { "/Game/Data/ItemType/Resource/DA_Lead_ItemType.DA_Lead_ItemType", "/Game/Data/ItemType/Resource/DA_Lead_ItemType" },
        Lithium  = { "/Game/Data/ItemType/Resource/DA_Lithium_ItemType.DA_Lithium_ItemType", "/Game/Data/ItemType/Resource/DA_Lithium_ItemType" },
        Diamond  = { "/Game/Data/ItemType/Resource/DA_Diamond_ItemType.DA_Diamond_ItemType", "/Game/Data/ItemType/Resource/DA_Diamond_ItemType" },
        Nickel   = { "/Game/Data/ItemType/Resource/DA_Nickel_ItemType.DA_Nickel_ItemType", "/Game/Data/ItemType/Resource/DA_Nickel_ItemType" },
        Salt     = { "/Game/Data/ItemType/Resource/DA_Salt_ItemType.DA_Salt_ItemType", "/Game/Data/ItemType/Resource/DA_Salt_ItemType" },
        Sulfur   = { "/Game/Data/ItemType/Resource/DA_Sulfur_ItemType.DA_Sulfur_ItemType", "/Game/Data/ItemType/Resource/DA_Sulfur_ItemType" },
        Celestine = { "/Game/Data/ItemType/Resource/DA_Celestine_ItemType.DA_Celestine_ItemType", "/Game/Data/ItemType/Resource/DA_Celestine_ItemType" },
        Atacamite = { "/Game/Data/ItemType/Resource/DA_Atacamite_ItemType.DA_Atacamite_ItemType", "/Game/Data/ItemType/Resource/DA_Atacamite_ItemType" },
        Troilite = { "/Game/Data/ItemType/Resource/DA_Troilite_ItemType.DA_Troilite_ItemType", "/Game/Data/ItemType/Resource/DA_Troilite_ItemType" },
        Enamel = { "/Game/Data/ItemType/Resource/DA_Enamel_ItemType.DA_Enamel_ItemType", "/Game/Data/ItemType/Resource/DA_Enamel_ItemType" },
        ConduitCrystal = { "/Game/Data/ItemType/Resource/DA_ConduitCrystal_ItemType.DA_ConduitCrystal_ItemType", "/Game/Data/ItemType/Resource/DA_ConduitCrystal_ItemType" },
    },
    ItemActorClassPaths = {
        Titanium = { "/Game/Blueprints/Items/Resources/BP_Titanium.BP_Titanium_C" },
        Copper   = { "/Game/Blueprints/Items/Resources/BP_Copper.BP_Copper_C" },
        Silver   = { "/Game/Blueprints/Items/Resources/BP_Silver.BP_Silver_C" },
        Gold     = { "/Game/Blueprints/Items/Resources/BP_Gold.BP_Gold_C" },
        Quartz   = { "/Game/Blueprints/Items/Resources/BP_Quartz.BP_Quartz_C" },
        Lead     = { "/Game/Blueprints/Items/Resources/BP_Lead.BP_Lead_C" },
        Lithium  = { "/Game/Blueprints/Items/Resources/BP_Lithium.BP_Lithium_C" },
        Diamond  = { "/Game/Blueprints/Items/Resources/BP_Diamond.BP_Diamond_C" },
        Nickel   = { "/Game/Blueprints/Items/Resources/BP_Nickel.BP_Nickel_C" },
        Salt     = { "/Game/Blueprints/Items/Resources/BP_Salt.BP_Salt_C" },
        Sulfur   = { "/Game/Blueprints/Items/Resources/BP_Sulfur.BP_Sulfur_C" },
        Celestine = { "/Game/Blueprints/Items/Resources/BP_Celestine.BP_Celestine_C" },
        Atacamite = { "/Game/Blueprints/Items/Resources/BP_Atacamite.BP_Atacamite_C" },
        Troilite = { "/Game/Blueprints/Items/Resources/BP_Troilite.BP_Troilite_C" },
        Enamel = { "/Game/Blueprints/Items/Resources/BP_Enamel.BP_Enamel_C" },
        ConduitCrystal = { "/Game/Blueprints/Items/Resources/BP_ConduitCrystal.BP_ConduitCrystal_C" },
    },

    RestartAfterSuccessfulDeposit = true,
    ClearOreBlockAfterDeposit = true,
    CallBreakOnSeedAfterDeposit = true,
    HideSeedAfterDeposit = true,
    DestroySeedActorAfterDeposit = true,
    ResetMetalFarmMeshesAfterDeposit = true,
    CallCollectHarvestAfterDeposit = false,
    HideDepositAfterSuccess = false,

    LockFarmAfterSuccessfulDeposit = true,
    RestartCooldownUsesCycleSeconds = true,

    LogStartup = true,
    LogScans = false,
    LogNewFarms = false,
    LogStorage = false,
    LogTimerDone = false,
    LogSuccess = false,
    LogFailures = true,
    LogDiagnostics = false,
    PreferResourceNameFallback = true,
    Verbose = false,
}

local farms = {}
local storageCache = {}
local states = {}
local assetCache = {}
local classCache = {}
local subsystemCache = nil
local started = false
local lastScan = 0
local farmCursor = 1
local warnedNoFarm = false
local warnedNoStorage = false
local hooksRegistered = false
local diagLoggedAt = 0
local rateLogs = {}
local pausedUntil = 0
local lifecycleHooksRegistered = false


local function log(msg)
    print("[" .. MOD .. "] " .. tostring(msg) .. "\n")
end

local function shortErr(e)
    local t = tostring(e or "")
    t = string.gsub(t, "\r", " ")
    t = string.gsub(t, "\n.*", "")
    if #t > 220 then t = string.sub(t, 1, 220) .. "..." end
    return t
end

local function safe(fn, ...)
    local ok, a, b, c, d = pcall(fn, ...)
    if not ok then return nil, shortErr(a) end
    return a, b, c, d
end

local function mergeConfig(dst, src)
    if type(src) ~= "table" then return dst end
    for k, v in pairs(src) do dst[k] = v end
    return dst
end

local function tryLoadConfig()
    local paths = {
        "Mods/AutoMetalFarmCollector/Scripts/config.lua",
        "ue4ss/Mods/AutoMetalFarmCollector/Scripts/config.lua",
        "./Mods/AutoMetalFarmCollector/Scripts/config.lua",
        "./ue4ss/Mods/AutoMetalFarmCollector/Scripts/config.lua",
        "AutoMetalFarmCollector/Scripts/config.lua",
        "Scripts/config.lua",
    }
    for _, p in ipairs(paths) do
        local ok, cfg = pcall(dofile, p)
        if ok and type(cfg) == "table" then C = mergeConfig(C, cfg); return true end
    end
    return false
end

local function now() return os.time() end
local function lower(s) return string.lower(tostring(s or "")) end
local function contains(s, p) return string.find(lower(s), lower(p), 1, true) ~= nil end

local function logEvery(key, seconds, msg)
    local t = now()
    seconds = tonumber(seconds) or 60
    if (rateLogs[key] or 0) + seconds <= t then
        rateLogs[key] = t
        log(msg)
    end
end

local function clearRuntimeState(reason)
    farms = {}
    storageCache = {}
    states = {}
    subsystemCache = nil
    farmCursor = 1
    lastScan = 0
    warnedNoFarm = false
    warnedNoStorage = false
    diagLoggedAt = 0
    if C.LogShutdown or C.Verbose then log("cleared runtime state" .. (reason and (": " .. tostring(reason)) or "")) end
end

local function pauseForWorldUnload(reason, seconds)
    if C.PauseDuringMapUnload == false then return end
    seconds = tonumber(seconds or C.MapUnloadPauseSeconds) or 20
    local untilTime = now() + seconds
    if untilTime > pausedUntil then pausedUntil = untilTime end
    clearRuntimeState(reason or "world unload")
    if C.LogShutdown or C.Verbose then log("paused during map unload for " .. tostring(seconds) .. "s: " .. tostring(reason or "unknown")) end
end

local function isPausedForUnload()
    return (tonumber(pausedUntil) or 0) > now()
end

local function rawName(obj)
    if obj == nil then return "nil" end
    local ok, r = pcall(function()
        if obj.GetFullName then return obj:GetFullName() end
        if obj.GetPathName then return obj:GetPathName() end
        if obj.GetName then return obj:GetName() end
        return tostring(obj)
    end)
    if ok then return tostring(r or obj) end
    return "<invalid UObject: " .. shortErr(r) .. ">"
end

local function objectUsable(obj)
    if obj == nil then return false end
    local n = rawName(obj)
    if n == "nil" or contains(n, "nullptr") or contains(n, "invalid uobject") then return false end
    local ok, valid = pcall(function()
        if obj.IsValid then return obj:IsValid() end
        if obj.IsValidLowLevel then return obj:IsValidLowLevel() end
        if obj.GetClass then return obj:GetClass() ~= nil end
        return true
    end)
    return ok and valid ~= false and valid ~= nil
end

local function isBadTemplateName(n)
    return contains(n, "Default__") or contains(n, "GEN_VARIABLE") or contains(n, "Archetype") or contains(n, "CDO") or contains(n, "Ghost") or contains(n, "Scannable")
end

local function hasCallable(obj, name)
    if not objectUsable(obj) then return false end
    local ok, value = pcall(function() return obj[name] end)
    return ok and value ~= nil
end

local function tryCall(obj, name, ...)
    if obj == nil then return false, "invalid/null UObject" end

    local okGet, fn = pcall(function() return obj[name] end)
    if not okGet or fn == nil then return false, "missing " .. tostring(name) end

    local args = { ... }

    local ok, a, b, c, d = pcall(function() return fn(obj, table.unpack(args)) end)
    if ok then return true, a, b, c, d end
    local err1 = shortErr(a)

    if C.TryBoundMemberCalls ~= false then
        local ok2, r1, r2, r3, r4 = pcall(function() return fn(table.unpack(args)) end)
        if ok2 then return true, r1, r2, r3, r4 end
        return false, err1 .. " | bound=" .. shortErr(r1)
    end

    return false, err1
end

local function accepted(ok, ret)
    if not ok then return false end
    if ret == false then return false end
    return true
end

local function getProperty(obj, name)
    if not objectUsable(obj) then return nil end
    local ok, val = pcall(function()
        if obj.GetPropertyValue then return obj:GetPropertyValue(name) end
        return obj[name]
    end)
    if ok and objectUsable(val) then return val end
    return nil
end

local function setProperty(obj, name, value)
    if not objectUsable(obj) then return false end
    local ok = pcall(function()
        if obj.SetPropertyValue then obj:SetPropertyValue(name, value) else obj[name] = value end
    end)
    return ok
end

local function loadAsset(path)
    if not path then return nil end
    if assetCache[path] ~= nil then return assetCache[path] ~= false and assetCache[path] or nil end
    local candidates = { path, "Object'" .. path .. "'", "DataAsset'" .. path .. "'", "Blueprint'" .. path .. "'" }
    for _, p in ipairs(candidates) do
        local obj = nil
        if StaticFindObject then obj = safe(function() return StaticFindObject(p) end) end
        if not objectUsable(obj) and StaticLoadObject then obj = safe(function() return StaticLoadObject(p) end) end
        if not objectUsable(obj) and LoadAsset then obj = safe(function() return LoadAsset(p) end) end
        if objectUsable(obj) then assetCache[path] = obj; return obj end
    end
    assetCache[path] = false
    return nil
end

local function loadClass(path)
    if not path then return nil end
    if classCache[path] ~= nil then return classCache[path] ~= false and classCache[path] or nil end
    local candidates = { path, "Class'" .. path .. "'", "BlueprintGeneratedClass'" .. path .. "'" }
    for _, p in ipairs(candidates) do
        local obj = nil
        if StaticFindObject then obj = safe(function() return StaticFindObject(p) end) end
        if not objectUsable(obj) and StaticLoadObject then obj = safe(function() return StaticLoadObject(p) end) end
        if not objectUsable(obj) and LoadAsset then obj = safe(function() return LoadAsset(p) end) end
        if objectUsable(obj) then classCache[path] = obj; return obj end
    end
    classCache[path] = false
    return nil
end

local function liveObjectLike(obj)
    if not objectUsable(obj) then return false end
    local n = rawName(obj)
    if isBadTemplateName(n) then return false end
    if contains(n, "PersistentLevel") then return true end
    local ok = pcall(function()
        if obj.K2_GetActorLocation then return obj:K2_GetActorLocation() end
        if obj.GetActorLocation then return obj:GetActorLocation() end
        if obj.K2_GetComponentLocation then return obj:K2_GetComponentLocation() end
        if obj.GetComponentLocation then return obj:GetComponentLocation() end
        return nil
    end)
    return ok
end

local function isComponentObject(obj)
    local n = rawName(obj)
    return contains(n, "Component") or contains(n, ":Inventory") or contains(n, ".Inventory") or contains(n, ".UWE")
end

local function isRealActor(obj)
    if not objectUsable(obj) then return false end
    local n = rawName(obj)
    if isComponentObject(obj) then return false end
    if hasCallable(obj, "K2_GetActorLocation") or hasCallable(obj, "GetActorLocation") then return true end
    return contains(n, "PersistentLevel") and not contains(n, ".Inventory") and not contains(n, ".UWE") and not contains(n, ".SaveComponent")
end

local function getLocation(obj)
    if not objectUsable(obj) then return nil end
    return safe(function()
        if obj.K2_GetActorLocation then return obj:K2_GetActorLocation() end
        if obj.GetActorLocation then return obj:GetActorLocation() end
        if obj.K2_GetComponentLocation then return obj:K2_GetComponentLocation() end
        if obj.GetComponentLocation then return obj:GetComponentLocation() end
        return nil
    end)
end

local function dist2(a, b)
    local la, lb = getLocation(a), getLocation(b)
    if la == nil or lb == nil then return 999999999999 end
    local ax, ay, az = tonumber(la.X or 0) or 0, tonumber(la.Y or 0) or 0, tonumber(la.Z or 0) or 0
    local bx, by, bz = tonumber(lb.X or 0) or 0, tonumber(lb.Y or 0) or 0, tonumber(lb.Z or 0) or 0
    local dx, dy, dz = ax - bx, ay - by, az - bz
    return dx * dx + dy * dy + dz * dz
end

local function getOwnerLike(obj)
    if not objectUsable(obj) then return nil end
    for _, fn in ipairs({ "GetOwner", "K2_GetOwner", "GetTypedOuter", "GetOuter" }) do
        if hasCallable(obj, fn) then
            local ok, owner = tryCall(obj, fn)
            if ok and objectUsable(owner) then return owner end
        end
    end
    for _, prop in ipairs({ "Owner", "Outer", "OuterPrivate" }) do
        local ok, owner = pcall(function() return obj[prop] end)
        if ok and objectUsable(owner) then return owner end
    end
    return nil
end

local function isMetalFarmName(n)
    if isBadTemplateName(n) then return false end
    if contains(n, "MetalFarmTank") or contains(n, "BP_Resource_MetalFarmSeed") or contains(n, "MetalFarmSeed") then return false end
    return contains(n, "BP_MetalFarm_C") or contains(n, "/Game/Blueprints/BaseBuilding/BP_MetalFarm")
end

local function isStorageName(n)
    if isBadTemplateName(n) then return false end
    if contains(n, "Blocker") or contains(n, "Label") then return false end
    return contains(n, "BP_Locker_Wall_C")
        or contains(n, "BP_Locker_Floor_C")
        or contains(n, "BP_Storage_Cache_C")
        or contains(n, "BP_Tailing_Chest_C")
        or contains(n, "BP_FloatingLocker_Carryable_C")
        or contains(n, "BP_HeavyFloatingLocker_Carryable_C")
        or contains(n, "BP_SuperHeavyFloatingLocker_Carryable_C")
        or contains(n, "BP_WorldSupplyLocker_C")
        or contains(n, "BP_BoxOfHolding_C")
        or (contains(n, "SN2Locker") and not contains(n, "Default__"))
end

local function actorKeyFromName(n, baseNames)
    for _, base in ipairs(baseNames or {}) do
        local p1 = "(PersistentLevel%." .. base .. "_[%w_]+)"
        local k = string.match(n, p1)
        if k ~= nil then return k end
        local p2 = "(" .. base .. "_[%w_]+)"
        k = string.match(n, p2)
        if k ~= nil then return k end
    end
    return nil
end

local function farmActorKey(obj)
    local n = rawName(obj)
    return actorKeyFromName(n, { "BP_MetalFarm_C" }) or n
end

local function storageActorKey(obj)
    local n = rawName(obj)
    return actorKeyFromName(n, {
        "BP_Locker_Wall_C",
        "BP_Locker_Floor_C",
        "BP_Storage_Cache_C",
        "BP_Tailing_Chest_C",
        "BP_FloatingLocker_Carryable_C",
        "BP_HeavyFloatingLocker_Carryable_C",
        "BP_SuperHeavyFloatingLocker_Carryable_C",
        "BP_WorldSupplyLocker_C",
        "BP_BoxOfHolding_C",
    }) or n
end

local function dedupeByKey(list, keyFn, acceptFn)
    local out, index = {}, {}
    for _, obj in ipairs(list or {}) do
        if objectUsable(obj) and acceptFn(rawName(obj)) then
            local k = keyFn(obj)
            local existingIndex = index[k]
            if not existingIndex then
                index[k] = #out + 1
                table.insert(out, obj)
            else
                local old = out[existingIndex]
                if (not isRealActor(old)) and isRealActor(obj) then out[existingIndex] = obj end
            end
        end
    end
    return out
end

local function findAllRaw(className)
    local out = {}
    if not FindAllOf then return out, "FindAllOf missing" end
    local result, err = safe(function() return FindAllOf(className) end)
    if type(result) ~= "table" then return out, tostring(err or "not table") end
    for _, obj in pairs(result) do if liveObjectLike(obj) then table.insert(out, obj) end end
    return out, nil
end

local function scanDirect(classNames, acceptFn, stats)
    local out = {}
    for _, cls in ipairs(classNames or {}) do
        local found, err = findAllRaw(cls)
        if stats then stats[cls] = #found end
        if err and C.Verbose then log("scan " .. tostring(cls) .. " err=" .. tostring(err)) end
        for _, obj in ipairs(found) do if acceptFn(rawName(obj)) then table.insert(out, obj) end end
    end
    return out
end

local function scanComponentOwners(componentClasses, acceptFn, stats)
    local out = {}
    for _, cls in ipairs(componentClasses or {}) do
        local comps, err = findAllRaw(cls)
        local total, acceptedCount = 0, 0
        for _, comp in ipairs(comps or {}) do
            total = total + 1
            local owner = getOwnerLike(comp)
            if objectUsable(owner) and acceptFn(rawName(owner)) then
                table.insert(out, owner)
                acceptedCount = acceptedCount + 1
            elseif acceptFn(rawName(comp)) then
                table.insert(out, comp)
                acceptedCount = acceptedCount + 1
            end
        end
        if stats then stats[cls] = { count = total, accepted = acceptedCount, err = err } end
    end
    return out
end

local function cycleSeconds(resource)
    local base = tonumber((C.ResourceBaseGrowthSeconds and C.ResourceBaseGrowthSeconds[resource]) or C.DefaultBaseGrowthSeconds) or 120
    local delay = tonumber(C.DepositDelayAfterReadySeconds) or 5
    return base + delay
end

local function pathFromFullName(n)
    n = tostring(n or "")
    local p = string.match(n, "%s([^%s]+)$")
    if p then return p end
    return n
end

local function classPrefixFromFullName(n)
    n = tostring(n or "")
    local c = string.match(n, "^([^%s]+)%s")
    return c
end

local function refreshObject(obj)
    if C.ReacquireObjectsBeforeDeposit == false then return obj end
    if obj == nil then return nil end
    local n = rawName(obj)
    if n == nil or n == "nil" or contains(n, "invalid UObject") then return obj end
    local path = pathFromFullName(n)
    local cls = classPrefixFromFullName(n)
    local tries = { path, n, "Object'" .. path .. "'" }
    if cls and path then table.insert(tries, cls .. "'" .. path .. "'") end
    for _, candidate in ipairs(tries) do
        local found = nil
        if StaticFindObject then found = safe(function() return StaticFindObject(candidate) end) end
        if objectUsable(found) then return found end
    end
    return obj
end

local function getViaMethods(obj, methods)
    for _, fn in ipairs(methods or {}) do
        if hasCallable(obj, fn) then
            local ok, res = tryCall(obj, fn)
            if ok and objectUsable(res) then return res end
        end
    end
    return nil
end

local function firstComponentByClass(actor, classPaths, hints)
    if not objectUsable(actor) then return nil end
    for _, classPath in ipairs(classPaths or {}) do
        local cls = loadClass(classPath)
        if objectUsable(cls) then
            for _, fn in ipairs({ "GetComponentByClass", "K2_GetComponentByClass" }) do
                if hasCallable(actor, fn) then
                    local ok, comp = tryCall(actor, fn, cls)
                    if ok and objectUsable(comp) then return comp end
                end
            end
        end
    end
    for _, prop in ipairs({ "BlueprintCreatedComponents", "InstanceComponents", "OwnedComponents", "Components" }) do
        local ok, comps = pcall(function() return actor[prop] end)
        if ok and type(comps) == "table" then
            for _, comp in pairs(comps) do
                if objectUsable(comp) then
                    local n = rawName(comp)
                    for _, h in ipairs(hints or {}) do if contains(n, h) then return comp end end
                end
            end
        end
    end
    return nil
end

local function getComponent(actor, props, methods, classPaths, hints)
    if not objectUsable(actor) then return nil end
    local actorName = rawName(actor)
    for _, h in ipairs(hints or {}) do
        if contains(actorName, h) and contains(actorName, "Component") then return actor end
    end
    for _, p in ipairs(props or {}) do
        local v = getProperty(actor, p)
        if objectUsable(v) then return v end
    end
    local via = getViaMethods(actor, methods or {})
    if objectUsable(via) then return via end
    return firstComponentByClass(actor, classPaths or {}, hints or props or {})
end

local function getGrower(farm)
    return getComponent(farm,
        { "UWESeedGrower", "SeedGrower", "UWESeedGrowerComponent", "Grower", "SeedGrowerComponent" },
        { "GetSeedGrower", "GetGrower", "GetUWESeedGrower" },
        { "/Script/UWEFarming.UWESeedGrowerComponent" },
        { "UWESeedGrower", "SeedGrower", "Grower" })
end

local function getFarmInventory(farm)
    return getComponent(farm,
        { "InventoryComponent", "Inventory", "StorageInventory" },
        { "GetInventory", "GetInventoryComponent", "GetStorageInventory" },
        { "/Script/UWEInventory.UWEInventoryComponent" },
        { "UWEInventoryComponent", "InventoryComponent", "Inventory" })
end

local function getStorageInventory(storage)
    return getComponent(storage,
        { "Inventory", "InventoryComponent", "StorageInventory" },
        { "GetInventory", "GetInventoryComponent", "GetStorageInventory", "GetContainerInventory" },
        { "/Script/UWEInventory.UWEInventoryComponent" },
        { "UWEInventoryComponent", "Inventory" })
end

local function getStorageInteraction(storage)
    return getComponent(storage,
        { "InventoryInteraction", "StorageInteraction", "UWEInventoryInteraction" },
        { "GetInventoryInteraction", "GetStorageInteraction" },
        { "/Script/UWEInventory.UWEInventoryInteractionComponent" },
        { "UWEInventoryInteraction", "InventoryInteraction", "Interaction" })
end

local function getSeedActor(farm, grower)
    for _, obj in ipairs({ grower, farm }) do
        if objectUsable(obj) then
            for _, fn in ipairs({ "GetSeedActor", "GetCurrentSeed", "GetCurrentSeedActor", "GetSpawnedSeed", "GetGrownSeed" }) do
                if hasCallable(obj, fn) then
                    local ok, res = tryCall(obj, fn)
                    if ok and objectUsable(res) then return res end
                end
            end
            for _, prop in ipairs({ "SeedActor", "CurrentSeed", "SpawnedSeed", "GrownSeed" }) do
                local res = getProperty(obj, prop)
                if objectUsable(res) then return res end
            end
        end
    end
    return nil
end

local function detectResource(farm, grower, seed)
    local names = { rawName(seed), rawName(grower), rawName(farm) }
    for _, s in ipairs(names) do
        for _, r in ipairs({ "Titanium", "Copper", "Silver", "Gold", "Quartz", "Lead", "Lithium", "Diamond", "Nickel", "Salt", "Sulfur", "Celestine", "Atacamite", "Troilite", "Enamel", "ConduitCrystal" }) do
            if contains(s, r) then return r end
        end
    end
    return "Titanium"
end

local function refreshFarms()
    if isPausedForUnload() then return end
    local list, directStats, compStats = {}, {}, {}
    if C.UseDirectClassScan then
        for _, obj in ipairs(scanDirect(C.FarmClassNames, isMetalFarmName, directStats)) do table.insert(list, obj) end
    end
    if C.UseComponentOwnerScan then
        for _, obj in ipairs(scanComponentOwners(C.FarmComponentClassNames, isMetalFarmName, compStats)) do table.insert(list, obj) end
    end

    farms = dedupeByKey(list, farmActorKey, isMetalFarmName)

    if C.LogScans then log("scan farms=" .. tostring(#farms) .. " action=deposit direct=" .. tostring(C.UseDirectClassScan) .. " componentOwner=" .. tostring(C.UseComponentOwnerScan) .. " actorFallback=false") end

    if #farms == 0 then
        if not warnedNoFarm then
            warnedNoFarm = true
            log("no Metal Farm found yet; using direct BP_MetalFarm_C scan plus component-owner scan")
        end
    else
        warnedNoFarm = false
    end

    local t = now()
    for _, farm in ipairs(farms) do
        local key = farmActorKey(farm)
        local grower = getGrower(farm)
        local seed = getSeedActor(farm, grower)
        local resource = detectResource(farm, grower, seed)
        if not states[key] then
            states[key] = { resource = resource, readyAt = t + cycleSeconds(resource), lastTry = 0, lastDepositAt = 0, timerDoneAt = nil, depositAt = nil, lockUntil = nil, depositInProgress = false, failKey = nil, source = "scan" }
            if C.LogNewFarms then log("tracking Metal Farm " .. key .. " resource=" .. tostring(resource) .. " cycle=" .. tostring(cycleSeconds(resource)) .. "s") end
        else
            states[key].resource = resource or states[key].resource
        end
    end
end

local function refreshStorage()
    if isPausedForUnload() then return end
    local list = {}
    if C.UseDirectClassScan then
        for _, obj in ipairs(scanDirect(C.StorageClassNames, isStorageName, nil)) do table.insert(list, obj) end
    end
    if C.UseComponentOwnerScan then
        for _, obj in ipairs(scanComponentOwners(C.StorageComponentClassNames, isStorageName, nil)) do table.insert(list, obj) end
    end
    storageCache = dedupeByKey(list, storageActorKey, isStorageName)
    if C.LogStorage then log("scan storage=" .. tostring(#storageCache)) end
    if #storageCache == 0 and not warnedNoStorage then
        warnedNoStorage = true
        log("no nearby storage found yet; scanning wall lockers, floor lockers, storage cache, tailing chest, floating/world lockers")
    elseif #storageCache > 0 then
        warnedNoStorage = false
    end
end

local function storagePriority(actor)
    local n = rawName(actor)
    if contains(n, "Locker_Wall") then return 0 end
    if contains(n, "Storage_Cache") or contains(n, "StorageCache") then return 1 end
    if contains(n, "Locker_Floor") then return 2 end
    if contains(n, "Tailing_Chest") or contains(n, "Chest") then return 3 end
    if contains(n, "FloatingLocker") then return 4 end
    if contains(n, "Locker") or contains(n, "Storage") then return 5 end
    return 9
end

local function findNearbyStorage(farm)
    refreshStorage()
    local r = tonumber(C.StorageRadius) or 20000.0
    local r2 = r * r
    local targets = {}
    for _, s in ipairs(storageCache or {}) do
        local d = dist2(farm, s)
        if d <= r2 then table.insert(targets, { actor = s, d = d, p = storagePriority(s) }) end
    end
    table.sort(targets, function(a, b)
        if C.StrictNearestStorage ~= false then
            if a.d ~= b.d then return a.d < b.d end
            return a.p < b.p
        end
        if a.p ~= b.p then return a.p < b.p end
        return a.d < b.d
    end)
    return targets
end

local function findInventorySubsystems()
    if subsystemCache ~= nil then return subsystemCache end
    local out = {}
    for _, cls in ipairs({ "UWEInventorySubsystem", "SN2InventorySubsystem", "InventorySubsystem" }) do
        for _, obj in ipairs(findAllRaw(cls)) do table.insert(out, obj) end
    end
    local defaults = {
        "/Script/UWEInventory.Default__UWEInventorySubsystem",
        "/Script/UWEInventory.Default__UWEInventoryComponent",
        "/Script/UWEInventory.Default__UWEInventoryFunctionLibrary",
        "/Script/UWEInventory.Default__UWEInventoryBlueprintLibrary",
        "/Script/UWEInventory.Default__UWEInventoryBPLibrary",
    }
    for _, path in ipairs(defaults) do
        local obj = nil
        if StaticFindObject then obj = safe(function() return StaticFindObject(path) end) end
        if objectUsable(obj) then table.insert(out, obj) end
        if StaticFindObject then
            obj = safe(function() return StaticFindObject("Object'" .. path .. "'") end)
            if objectUsable(obj) then table.insert(out, obj) end
        end
    end
    subsystemCache = dedupeByKey(out, function(o) return rawName(o) end, function(n) return not isBadTemplateName(n) end)
    return subsystemCache
end

local function buildDepositArgs(target, itemType, itemClass, itemTypePath, itemClassPath, amount, resourceName)
    local sigs = {}
    local function add(t) table.insert(sigs, t) end
    if objectUsable(itemType) then
        add({ itemType, amount }); add({ itemType, amount, true }); add({ itemType, amount, false })
        add({ itemType, amount, 0 }); add({ itemType, amount, 1 }); add({ itemType, 1 }); add({ itemType })
        add({ amount, itemType }); add({ amount, itemType, true }); add({ amount, itemType, false })
        add({ target, itemType, amount }); add({ target, itemType, amount, true }); add({ target, itemType, amount, false })
        add({ itemType, target, amount }); add({ itemType, amount, target })
    end
    if objectUsable(itemClass) then
        add({ itemClass, amount }); add({ itemClass, amount, true }); add({ itemClass, amount, false })
        add({ itemClass, 1 }); add({ itemClass })
        add({ target, itemClass, amount }); add({ itemClass, target, amount }); add({ itemClass, amount, target })
    end
    if itemTypePath then
        add({ itemTypePath, amount }); add({ itemTypePath, 1 }); add({ itemTypePath })
        add({ target, itemTypePath, amount }); add({ itemTypePath, target, amount })
    end
    if itemClassPath then
        add({ itemClassPath, amount }); add({ itemClassPath, 1 }); add({ itemClassPath })
        add({ target, itemClassPath, amount }); add({ itemClassPath, target, amount })
    end

    if resourceName then
        local r = tostring(resourceName)
        local rl = string.lower(r)
        add({ r, amount }); add({ r, amount, true }); add({ r, amount, false })
        add({ amount, r }); add({ r, 1 }); add({ r })
        add({ rl, amount }); add({ amount, rl }); add({ rl, 1 }); add({ rl })
        add({ target, r, amount }); add({ r, target, amount }); add({ r, amount, target })
        add({ target, rl, amount }); add({ rl, target, amount })
    end
    return sigs
end

local function depositOnTarget(target, itemType, itemClass, itemTypePath, itemClassPath, amount, resourceName)
    target = refreshObject(target)
    itemType = refreshObject(itemType)
    itemClass = refreshObject(itemClass)
    if not objectUsable(target) then return false, "invalid target" end
    local methods = {
        "AddItemTypeToInventory", "TryAddItemTypeToInventory", "K2_AddItemTypeToInventory", "K2_TryAddItemTypeToInventory",
        "ServerAddItemTypeToInventory", "Server_AddItemTypeToInventory",
        "AddItemType", "TryAddItemType", "K2_AddItemType", "K2_TryAddItemType", "ServerAddItemType", "AddItemByType", "TryAddItemByType",
        "AddItemClassToInventory", "TryAddItemClassToInventory", "K2_AddItemClassToInventory", "AddItemClass", "TryAddItemClass", "AddItemByClass", "TryAddItemByClass",
        "CreateAndAddItem", "CreateItemAndAdd", "SpawnAndAddItem", "SpawnItemAndAdd", "CreatePickupAndAdd",
        "AddResource", "TryAddResource", "AddResourceToInventory", "TryAddResourceToInventory",
        "AddItemByName", "TryAddItemByName", "AddItemID", "TryAddItemID", "AddTechType", "AddItemTypeByName",
        "AddItems", "TryAddItems", "AddItem", "TryAddItem",
        "ServerAddItem", "ServerTryAddItem", "ServerAddItems", "ServerAddResource",
        "GiveItem", "GiveItems", "GiveResource", "ReceiveItem", "TryReceiveItem", "ReceiveResource", "TryReceiveResource",
    }
    local sigs = buildDepositArgs(target, itemType, itemClass, itemTypePath, itemClassPath, amount, resourceName)
    local last = ""
    for _, fn in ipairs(methods) do
        if true then
            for _, args in ipairs(sigs) do
                local ok, ret = tryCall(target, fn, table.unpack(args))
                if accepted(ok, ret) then return true, rawName(target) .. "." .. fn end
                if not ok and tostring(ret) ~= "missing " .. tostring(fn) then last = tostring(ret) end
            end
        end
    end
    return false, last ~= "" and last or "no accepted add method on " .. rawName(target)
end

local function tryMoveFarmInventoryToStorage(farm, storage)
    farm = refreshObject(farm)
    storage = refreshObject(storage)
    local farmInv = refreshObject(getFarmInventory(farm))
    local storageInv = refreshObject(getStorageInventory(storage))
    if not objectUsable(farmInv) then return false, "no live farm inventory" end
    if not objectUsable(storageInv) then return false, "no live storage inventory" end

    local last = ""
    local allMethods = {
        "MoveAllItemsTo", "TransferAllItemsTo", "MoveAllTo", "TransferAllTo",
        "MoveAllItemsToInventory", "TransferAllItemsToInventory",
        "MoveItemsTo", "TransferItemsTo", "MoveInventoryTo", "TransferInventoryTo",
        "ServerMoveAllItemsTo", "ServerTransferAllItemsTo", "K2_MoveAllItemsTo", "K2_TransferAllItemsTo",
        "MoveAllItems", "TransferAllItems", "MoveToInventory", "TransferToInventory", "DumpToInventory"
    }
    local allSigs = { { storageInv }, { storageInv, true }, { storageInv, false }, { storage }, { storage, true }, { storage, false } }
    for _, fn in ipairs(allMethods) do
        if true then
            for _, args in ipairs(allSigs) do
                local ok, ret = tryCall(farmInv, fn, table.unpack(args))
                if accepted(ok, ret) then return true, "farmInv." .. fn end
                if not ok and tostring(ret) ~= "missing " .. tostring(fn) then last = tostring(ret) end
            end
        end
    end

    local pullMethods = { "MoveAllItemsFrom", "TransferAllItemsFrom", "TakeAllItemsFrom", "PullAllItemsFrom" }
    local pullSigs = { { farmInv }, { farmInv, true }, { farmInv, false }, { farm }, { farm, true }, { farm, false } }
    for _, fn in ipairs(pullMethods) do
        if true then
            for _, args in ipairs(pullSigs) do
                local ok, ret = tryCall(storageInv, fn, table.unpack(args))
                if accepted(ok, ret) then return true, "storageInv." .. fn end
                if not ok and tostring(ret) ~= "missing " .. tostring(fn) then last = tostring(ret) end
            end
        end
    end

    for _, getFn in ipairs({ "GetItems", "GetAllItems", "GetItemStacks", "GetInventoryItems", "GetContents" }) do
        if hasCallable(farmInv, getFn) then
            local okItems, items = tryCall(farmInv, getFn)
            if okItems and items ~= nil then
                for _, fn in ipairs({ "MoveItemsTo", "TransferItemsTo", "MoveItemsToInventory", "TransferItemsToInventory", "MoveItemStacksTo", "TransferItemStacksTo" }) do
                    if true then
                        for _, args in ipairs({
                            { items, storageInv }, { items, storage }, { storageInv, items }, { storage, items },
                            { items, storageInv, true }, { items, storageInv, false }, { items, storage, true }, { items, storage, false }
                        }) do
                            local ok, ret = tryCall(farmInv, fn, table.unpack(args))
                            if accepted(ok, ret) then return true, "farmInv." .. fn .. "(" .. getFn .. ")" end
                            if not ok and tostring(ret) ~= "missing " .. tostring(fn) then last = tostring(ret) end
                        end
                    end
                end
                for _, fn in ipairs({ "AddItems", "TryAddItems", "AddItemStacks", "TryAddItemStacks", "ReceiveItems", "TryReceiveItems" }) do
                    if true then
                        for _, args in ipairs({ { items }, { items, true }, { items, false }, { farmInv, items }, { items, farmInv }, { farm, items } }) do
                            local ok, ret = tryCall(storageInv, fn, table.unpack(args))
                            if accepted(ok, ret) then return true, "storageInv." .. fn .. "(" .. getFn .. ")" end
                            if not ok and tostring(ret) ~= "missing " .. tostring(fn) then last = tostring(ret) end
                        end
                    end
                end
                if type(items) == "table" then
                    local moved = 0
                    for _, item in pairs(items) do
                        if objectUsable(item) then
                            for _, fn in ipairs({ "MoveItemTo", "TransferItemTo", "MoveItemToInventory", "TransferItemToInventory" }) do
                                if true then
                                    for _, args in ipairs({ { item, storageInv }, { item, storage }, { storageInv, item }, { storage, item } }) do
                                        local ok, ret = tryCall(farmInv, fn, table.unpack(args))
                                        if accepted(ok, ret) then moved = moved + 1; break end
                                        if not ok and tostring(ret) ~= "missing " .. tostring(fn) then last = tostring(ret) end
                                    end
                                end
                                if moved > 0 then break end
                            end
                        end
                    end
                    if moved > 0 then return true, "moved " .. tostring(moved) .. " farm inventory stack(s)" end
                end
            end
        end
    end

    for _, sub in ipairs(findInventorySubsystems()) do
        for _, fn in ipairs({ "MoveAllItems", "TransferAllItems", "MoveAllItemsTo", "TransferAllItemsTo" }) do
            if true then
                for _, args in ipairs({ { farmInv, storageInv }, { storageInv, farmInv }, { farm, storage }, { storage, farm } }) do
                    local ok, ret = tryCall(sub, fn, table.unpack(args))
                    if accepted(ok, ret) then return true, "subsystem." .. fn end
                    if not ok and tostring(ret) ~= "missing " .. tostring(fn) then last = tostring(ret) end
                end
            end
        end
    end

    return false, last ~= "" and last or "no farm inventory transfer method worked"
end

local function resolveItemTypeFromObject(obj)
    if not objectUsable(obj) then return nil end
    local n = rawName(obj)
    if contains(n, "ItemType") or contains(n, "UWEItemType") then return obj end
    for _, fn in ipairs({ "GetItemType", "GetResourceItemType", "GetCurrentSeedItemType", "GetSeedItemType", "GetCurrentItemType" }) do
        if hasCallable(obj, fn) then
            local ok, res = tryCall(obj, fn)
            if ok and objectUsable(res) and (contains(rawName(res), "ItemType") or contains(rawName(res), "UWEItemType")) then return res end
        end
    end
    for _, prop in ipairs({ "ItemType", "ResourceItemType", "CurrentSeedItemType", "SeedItemType", "CachedItemType" }) do
        local res = getProperty(obj, prop)
        if objectUsable(res) and (contains(rawName(res), "ItemType") or contains(rawName(res), "UWEItemType")) then return res end
    end
    return nil
end

local function findItemType(resource, farm, grower, seed)
    local fromObject = resolveItemTypeFromObject(seed) or resolveItemTypeFromObject(grower) or resolveItemTypeFromObject(farm)
    if objectUsable(fromObject) then return fromObject, rawName(fromObject) end
    local paths = (C.ItemTypePaths and C.ItemTypePaths[resource]) or {}
    for _, path in ipairs(paths) do
        local obj = loadAsset(path)
        if objectUsable(obj) then return obj, path end
    end
    return nil, paths[1]
end

local function findItemActorClass(resource)
    local paths = (C.ItemActorClassPaths and C.ItemActorClassPaths[resource]) or {}
    for _, path in ipairs(paths) do
        local cls = loadClass(path)
        if objectUsable(cls) then return cls, path end
        local obj = loadAsset(path)
        if objectUsable(obj) then return obj, path end
    end
    return nil, paths[1]
end

local function tryDepositResource(farm, resource, amount)
    farm = refreshObject(farm)
    local grower = getGrower(farm)
    local seed = getSeedActor(farm, grower)
    local targets = findNearbyStorage(farm)
    if #targets == 0 then return false, "no nearby storage" end
    if targets[1] and (C.LogStorage or C.Verbose) then log("nearest storage candidate: " .. rawName(targets[1].actor) .. " d2=" .. tostring(math.floor(targets[1].d or 0))) end
    local last = ""
    local maxTargets = math.min(#targets, tonumber(C.MaxStorageTargetsTried) or 12)

    for i = 1, maxTargets do
        local storage = refreshObject(targets[i].actor)
        local ok, msg = tryMoveFarmInventoryToStorage(farm, storage)
        if ok then return true, tostring(msg) .. " -> " .. rawName(storage) end
        last = msg or last
    end

    local itemType, itemTypePath = findItemType(resource, farm, grower, seed)
    local itemClass, itemClassPath = findItemActorClass(resource)
    if not objectUsable(itemType) and not objectUsable(itemClass) and not itemTypePath and not itemClassPath then
        return false, (last ~= "" and (last .. "; ") or "") .. "no item type/class/path for " .. tostring(resource)
    end

    for i = 1, maxTargets do
        local storage = refreshObject(targets[i].actor)
        local inv = getStorageInventory(storage)
        local inter = getStorageInteraction(storage)
        local candidates = { inv, inter, storage }
        for _, sub in ipairs(findInventorySubsystems()) do table.insert(candidates, sub) end
        local seen = {}
        for _, target in ipairs(candidates) do
            local k = rawName(target)
            if objectUsable(target) and not seen[k] then
                seen[k] = true
                local ok, msg = depositOnTarget(target, itemType, itemClass, itemTypePath, itemClassPath, amount, resource)
                if ok then return true, tostring(msg) .. " -> " .. rawName(storage) end
                last = msg or last
            end
        end
    end
    return false, last ~= "" and last or "all nearby storage rejected deposit"
end

local function numberFromValue(v)
    if type(v) == "number" then return v end
    if type(v) == "string" then return tonumber(v) end
    if type(v) == "table" then
        for _, k in ipairs({ "Seconds", "Time", "Remaining", "Value", "X" }) do
            if type(v[k]) == "number" then return v[k] end
        end
    end
    return nil
end

local function getRemainingGrowthSeconds(farm, grower, seed)
    for _, obj in ipairs({ grower, farm, seed }) do
        if objectUsable(obj) then
            for _, fn in ipairs({
                "GetRemainingGrowthTime", "GetGrowthTimeRemaining", "GetTimeRemaining",
                "GetRemainingTime", "GetRemainingSeconds", "GetGrowTimeRemaining",
                "GetGrowthRemainingSeconds", "GetSecondsRemaining", "GetCountdownSeconds"
            }) do
                if hasCallable(obj, fn) then
                    local ok, ret = tryCall(obj, fn)
                    local num = ok and numberFromValue(ret) or nil
                    if num ~= nil then return num, rawName(obj) .. "." .. fn end
                end
            end
            for _, prop in ipairs({
                "RemainingGrowthTime", "GrowthTimeRemaining", "TimeRemaining", "RemainingTime",
                "RemainingSeconds", "GrowTimeRemaining", "GrowthRemainingSeconds", "CountdownSeconds"
            }) do
                local num = numberFromValue(getProperty(obj, prop))
                if num ~= nil then return num, rawName(obj) .. "." .. prop end
            end
        end
    end
    return nil, nil
end

local function readyForDeposit(st, farm, grower, seed, resource, n)
    if st.lockUntil and n < st.lockUntil then
        st.timerDoneAt = nil
        st.depositAt = nil
        return false, "post-deposit cycle lock"
    elseif st.lockUntil and n >= st.lockUntil then
        st.lockUntil = nil
    end

    if st.depositInProgress then
        return false, "deposit already in progress"
    end

    local delay = tonumber(C.DepositDelayAfterReadySeconds) or 5
    local remaining, src = getRemainingGrowthSeconds(farm, grower, seed)
    if remaining ~= nil then
        if remaining > 0.25 then
            st.timerDoneAt = nil
            st.depositAt = nil
            st.readyAt = n + math.floor(remaining) + delay
            return false, "actual timer " .. tostring(math.floor(remaining)) .. "s remaining"
        end
        if not st.timerDoneAt then
            st.timerDoneAt = n
            st.depositAt = n + delay
            if C.LogTimerDone then log("timer done for " .. tostring(resource) .. "; depositing in " .. tostring(delay) .. "s") end
        end
        return n >= (st.depositAt or (n + delay)), src or "actual timer"
    end
    return n >= (st.readyAt or 0), "fallback cycle"
end

local function callAny(obj, methods, argSets)
    if not objectUsable(obj) then return false, "invalid object" end
    local did, last = false, ""
    for _, fn in ipairs(methods or {}) do
        if hasCallable(obj, fn) then
            for _, args in ipairs(argSets or { {} }) do
                local ok, ret = tryCall(obj, fn, table.unpack(args))
                if ok then did = true else last = tostring(ret or last) end
            end
        end
    end
    return did, last
end

local function setVisualHidden(obj, hidden)
    if not objectUsable(obj) then return false end
    local did = false
    local visible = not hidden
    for _, args in ipairs({ { visible }, { visible, true } }) do
        local ok = select(1, tryCall(obj, "SetVisibility", table.unpack(args)))
        if ok then did = true end
    end
    for _, args in ipairs({ { hidden }, { hidden, true } }) do
        local ok = select(1, tryCall(obj, "SetHiddenInGame", table.unpack(args)))
        if ok then did = true end
    end
    for _, fn in ipairs({ "SetActorHiddenInGame", "SetHidden", "SetVisibility" }) do
        if hasCallable(obj, fn) then
            local ok = select(1, tryCall(obj, fn, hidden))
            if ok then did = true end
        end
    end
    return did
end

local function clearMetalFarmMeshes(farm)
    if not C.ResetMetalFarmMeshesAfterDeposit or not objectUsable(farm) then return false end
    local did = false
    for _, fn in ipairs({ "UpdateMeshesForProgress", "UpdateVisuals", "SetTimeText", "UpdateSeed" }) do
        if hasCallable(farm, fn) then
            if fn == "UpdateMeshesForProgress" then
                local ok = select(1, tryCall(farm, fn, 1.0))
                if ok then did = true end
            else
                local ok = select(1, tryCall(farm, fn))
                if ok then did = true end
            end
        end
    end
    for _, prop in ipairs({ "StaticMesh_Top", "StaticMesh_Mid", "StaticMesh_Bottom", "StaticMesh_Full" }) do
        local mesh = getProperty(farm, prop)
        if objectUsable(mesh) then
            if setVisualHidden(mesh, true) then did = true end
        end
    end
    return did
end

local function clearOreOutputBlock(farm, grower, seed)
    if not C.ClearOreBlockAfterDeposit then return false, "disabled" end
    local did = false
    local seedNow = seed
    if not objectUsable(seedNow) then seedNow = getSeedActor(farm, grower) end

    if objectUsable(seedNow) then
        if C.CallBreakOnSeedAfterDeposit ~= false then
            local methods = { "OnBreak", "Break", "K2_OnBreak", "ReceiveBreak", "TryBreak", "BreakResource", "BreakDeposit", "OnResonated", "OnResonatorBreak" }
            local argSets = { { farm }, { grower }, { seedNow }, {} }
            local ok = select(1, callAny(seedNow, methods, argSets))
            if ok then did = true end
        end

        if C.HideSeedAfterDeposit ~= false then
            if setVisualHidden(seedNow, true) then did = true end
            for _, fn in ipairs({ "SetActorEnableCollision", "SetCollisionEnabled" }) do
                if hasCallable(seedNow, fn) then
                    local ok = select(1, tryCall(seedNow, fn, false))
                    if ok then did = true end
                    ok = select(1, tryCall(seedNow, fn, 0))
                    if ok then did = true end
                end
            end
        end

        if C.DestroySeedActorAfterDeposit ~= false then
            for _, fn in ipairs({ "K2_DestroyActor", "DestroyActor", "Destroy", "ConditionalBeginDestroy" }) do
                if hasCallable(seedNow, fn) then
                    local ok = select(1, tryCall(seedNow, fn))
                    if ok then did = true; break end
                end
            end
        end
    end

    if clearMetalFarmMeshes(farm) then did = true end
    return did, objectUsable(seedNow) and rawName(seedNow) or "no seed actor"
end

local function restartMinerCycle(farm, grower, seed)
    if not C.RestartAfterSuccessfulDeposit then return false end
    local did = false

    local cleared = select(1, clearOreOutputBlock(farm, grower, seed))
    if cleared then did = true end

    local baseSeconds = tonumber(C.DefaultBaseGrowthSeconds) or 120
    for _, obj in ipairs({ grower, farm }) do
        if objectUsable(obj) then
            for _, fn in ipairs({
                "RestartGrowth", "ResetGrowth", "StartGrowth", "BeginGrowth",
                "RestartGrowTimer", "ResetGrowTimer", "StartGrowTimer", "BeginGrowTimer",
                "RestartTimer", "ResetTimer", "StartTimer", "BeginTimer",
                "RestartSpawnTimer", "ResetSpawnTimer", "StartSpawnTimer", "BeginSpawnTimer",
                "RestartSpawning", "ResetSpawning", "StartSpawning", "BeginSpawning",
                "Reset", "Restart", "Start"
            }) do
                if hasCallable(obj, fn) then
                    local ok = select(1, tryCall(obj, fn))
                    if ok then did = true end
                end
            end

            for _, fn in ipairs({
                "SetGrowth", "SetGrowthPercentage", "SetGrowthPercent", "SetProgress", "SetProgressPercent",
                "SetTimeRemaining", "SetRemainingTime", "SetRemainingGrowthTime", "SetGrowTimeRemaining", "SetCountdownSeconds"
            }) do
                if hasCallable(obj, fn) then
                    local value = 0.0
                    if contains(fn, "Time") or contains(fn, "Seconds") or contains(fn, "Countdown") then value = baseSeconds end
                    local ok = select(1, tryCall(obj, fn, value))
                    if ok then did = true end
                end
            end

            for _, prop in ipairs({ "Growth", "GrowthPercent", "GrowthPercentage", "Progress", "ProgressPercent" }) do
                if setProperty(obj, prop, 0.0) then did = true end
            end
            for _, prop in ipairs({ "TimeRemaining", "RemainingTime", "RemainingGrowthTime", "GrowTimeRemaining", "CountdownSeconds" }) do
                if setProperty(obj, prop, baseSeconds) then did = true end
            end

            if C.CallCollectHarvestAfterDeposit then
                for _, fn in ipairs({ "Collect", "TryCollect", "Harvest", "TryHarvest", "FinishHarvest", "CompleteHarvest", "ClearHarvest", "ClearOutput", "ClearSeed", "RemoveSeed", "ClearCurrentSeed", "RemoveCurrentSeed" }) do
                    if hasCallable(obj, fn) then
                        local ok = select(1, tryCall(obj, fn))
                        if ok then did = true end
                    end
                end
            end
        end
    end

    if objectUsable(farm) then
        for _, fn in ipairs({ "SetTimeText", "UpdateVisuals", "UpdatePoweredState" }) do
            if hasCallable(farm, fn) then
                local ok = select(1, tryCall(farm, fn))
                if ok then did = true end
            end
        end
        if hasCallable(farm, "UpdateMeshesForProgress") then
            local ok = select(1, tryCall(farm, "UpdateMeshesForProgress", 1.0))
            if ok then did = true end
        end
    end

    return did
end

local function processFarm(farm)
    if isPausedForUnload() then return end
    if not objectUsable(farm) then return end
    local key = farmActorKey(farm)
    local st = states[key]
    if not st then return end
    local grower = getGrower(farm)
    local seed = getSeedActor(farm, grower)
    local resource = detectResource(farm, grower, seed)
    st.resource = resource
    local n = now()
    local ready = readyForDeposit(st, farm, grower, seed, resource, n)
    if not ready then return end
    if st.lastTry and (n - st.lastTry) < (tonumber(C.RetryDepositSeconds) or 30) then return end
    st.lastTry = n
    st.depositInProgress = true
    local amount = tonumber((C.DepositAmounts and C.DepositAmounts[resource]) or C.DepositAmountDefault) or 5
    local ok, msg = tryDepositResource(farm, resource, amount)
    st.depositInProgress = false
    if ok then
        local restarted = restartMinerCycle(farm, grower, seed)
        local nextCycle = cycleSeconds(resource)
        st.readyAt = n + nextCycle
        if C.LockFarmAfterSuccessfulDeposit ~= false then
            st.lockUntil = st.readyAt
        end
        st.timerDoneAt = nil
        st.depositAt = nil
        st.failKey = nil
        st.lastDepositAt = n
        if C.LogSuccess then log("SUCCESS: deposited " .. tostring(amount) .. "x " .. tostring(resource) .. "; " .. tostring(msg) .. "; restart=" .. tostring(restarted) .. "; next cycle locked for " .. tostring(nextCycle) .. "s") end
    else
        if C.LogFailures and st.failKey ~= tostring(msg) then log("deposit failed for " .. tostring(resource) .. "; " .. tostring(msg) .. "; retry in " .. tostring(C.RetryDepositSeconds) .. "s") end
        st.failKey = tostring(msg)
    end
end

local function diagnostics()
    if not C.LogDiagnostics then return end
    local n = now()
    if (n - diagLoggedAt) < 30 then return end
    diagLoggedAt = n
    if #farms > 0 then
        local farm = farms[1]
        local grower = getGrower(farm)
        local seed = getSeedActor(farm, grower)
        log("diag farm=" .. rawName(farm))
        log("diag grower=" .. rawName(grower) .. " seed=" .. rawName(seed) .. " farmInv=" .. rawName(getFarmInventory(farm)))
    end
    if #storageCache > 0 then
        local s = storageCache[1]
        log("diag storage=" .. rawName(s) .. " inv=" .. rawName(getStorageInventory(s)) .. " inter=" .. rawName(getStorageInteraction(s)))
    end
end

local function tick()
    if isPausedForUnload() then return end
    local t = now()
    local scanned = false
    if (t - lastScan) >= (tonumber(C.ScanSeconds) or 5) then
        lastScan = t
        refreshFarms()
        refreshStorage()
        diagnostics()
        scanned = true
    end
    if C.ProcessOnlyFreshScans ~= false and not scanned then return end
    if #farms == 0 then return end
    local max = tonumber(C.MaxFarmsPerTick) or 1
    for _ = 1, max do
        if #farms == 0 then break end
        if farmCursor > #farms then farmCursor = 1 end
        local farm = farms[farmCursor]
        farmCursor = farmCursor + 1
        local ok, err = pcall(function() processFarm(farm) end)
        if not ok then logEvery("farm_process_error", 60, "farm process error: " .. shortErr(err)) end
    end
    if C.ClearObjectListsAfterTick ~= false then
        farms = {}
        storageCache = {}
        farmCursor = 1
    end
end

local function registerFarm(obj, source)
    if isPausedForUnload() then return end
    if not objectUsable(obj) or not isMetalFarmName(rawName(obj)) then return end
    local merged = dedupeByKey({ table.unpack(farms), obj }, farmActorKey, isMetalFarmName)
    farms = merged
    local key = farmActorKey(obj)
    if not states[key] then
        states[key] = { resource = "Titanium", readyAt = now() + cycleSeconds("Titanium"), lastTry = 0, lastDepositAt = 0, timerDoneAt = nil, depositAt = nil, lockUntil = nil, depositInProgress = false, failKey = nil, source = source or "hook" }
        if C.LogNewFarms then log("tracking Metal Farm " .. key .. " source=" .. tostring(source or "hook") .. " resource=Titanium cycle=" .. tostring(cycleSeconds("Titanium")) .. "s") end
    end
end

local function extractHookActor(ctx)
    local actor = ctx
    if type(ctx) == "table" then
        local ok, got = pcall(function()
            if ctx.get then return ctx:get() end
            return ctx[1]
        end)
        if ok and got ~= nil then actor = got end
    end
    return actor
end

local function tryRegisterLifecycleHooks()
    if C.UseLifecycleHooks ~= true then return end
    if lifecycleHooksRegistered or not RegisterHook then return end
    lifecycleHooksRegistered = true
    for _, hp in ipairs({
        "/Script/Engine.PlayerController:ClientTravel",
        "/Script/Engine.GameplayStatics:OpenLevel",
    }) do
        pcall(function()
            RegisterHook(hp, function()
                pauseForWorldUnload(hp, C.MapUnloadPauseSeconds)
            end)
        end)
    end
end

local function tryRegisterBeginPlayHook()
    if hooksRegistered or not C.UseBeginPlayHook or not RegisterHook then return end
    hooksRegistered = true
    for _, hp in ipairs({ "/Game/Blueprints/BaseBuilding/BP_MetalFarm.BP_MetalFarm_C:ReceiveBeginPlay", "/Script/Engine.Actor:ReceiveBeginPlay" }) do
        pcall(function()
            RegisterHook(hp, function(ctx)
                local actor = ctx
                if type(ctx) == "table" then
                    local ok, got = pcall(function() if ctx.get then return ctx:get() end return ctx[1] end)
                    if ok and got ~= nil then actor = got end
                end
                if not isPausedForUnload() and objectUsable(actor) and isMetalFarmName(rawName(actor)) then registerFarm(actor, "beginplay") end
            end)
            log("registered farm beginplay hook: " .. hp)
        end)
    end
end

local function schedule()
    if ExecuteWithDelay then
        ExecuteWithDelay(tonumber(C.TickMs) or 1000, function()
            local ok, err = pcall(tick)
            if not ok then logEvery("tick_error", 60, "tick error: " .. shortErr(err)) end
            schedule()
        end)
    elseif LoopAsync then
        LoopAsync(tonumber(C.TickMs) or 1000, function()
            local ok, err = pcall(tick)
            if not ok then logEvery("tick_error", 60, "tick error: " .. shortErr(err)) end
        end)
    else
        log("ERROR: ExecuteWithDelay/LoopAsync unavailable")
    end
end

local function start()
    if started then return end
    started = true
    tryLoadConfig()
    C.EnableGrowthBoost = false
    C.GrowthSpeedMultiplier = 1.0
    C.UseActorNameFallbackScan = false
    C.UseLifecycleHooks = false
    C.UseBeginPlayHook = false
    if C.LogStartup then
        log("loaded")
    end
    tryRegisterLifecycleHooks()
    tryRegisterBeginPlayHook()
    schedule()
end

if ExecuteWithDelay then
    ExecuteWithDelay(tonumber(C.StartupDelayMs) or 5000, function()
        local ok, err = pcall(start)
        if not ok then log("startup error: " .. shortErr(err)) end
    end)
else
    local ok, err = pcall(start)
    if not ok then log("startup error: " .. shortErr(err)) end
end
