HDLevelSyncEvent = {}
local HDLevelSyncEvent_mt = Class(HDLevelSyncEvent, Event)
InitEventClass(HDLevelSyncEvent, "HDLevelSyncEvent")

HDLevelSyncEvent.MAX_FILL_LEVELS = 255
HDLevelSyncEvent.MAX_STORAGE_INDEX = 255
HDLevelSyncEvent.MAX_PRODUCTIVITY_ENTRIES = 255
HDLevelSyncEvent.QUEUE_INTERVAL = 100
HDLevelSyncEvent.QUEUE_BATCH_SIZE = 1

HDLevelSyncEvent.queue = {}
HDLevelSyncEvent.queueHead = 1
HDLevelSyncEvent.queueTail = 0
HDLevelSyncEvent.queuedPlaceables = setmetatable({}, {__mode = "k"})
HDLevelSyncEvent.autoRequested = setmetatable({}, {__mode = "k"})
HDLevelSyncEvent.updateableRegistered = false
HDLevelSyncEvent.updateableMission = nil
HDLevelSyncEvent.sessionMission = nil
HDLevelSyncEvent.timer = 0
HDLevelSyncEvent.queueDispatched = 0
HDLevelSyncEvent.queueSkipped = 0
HDLevelSyncEvent.queueHadEntries = false
HDLevelSyncEvent.sessionFillLevelsApplied = 0
HDLevelSyncEvent.sessionProductivityEntriesApplied = 0

function HDLevelSyncEvent.emptyNew()
    return Event.new(HDLevelSyncEvent_mt)
end

function HDLevelSyncEvent.new(placeable, level, isRequest, fillLevels, refreshMenu, productivities)
    local self = HDLevelSyncEvent.emptyNew()
    self.placeable = placeable
    self.level = level or 1
    self.isRequest = isRequest == true
    self.fillLevels = fillLevels
    self.refreshMenu = refreshMenu == true
    self.productivities = productivities
    return self
end

function HDLevelSyncEvent:writeStream(streamId, connection)
    NetworkUtil.writeNodeObject(streamId, self.placeable)
    streamWriteBool(streamId, self.isRequest)
    streamWriteBool(streamId, self.refreshMenu)
    streamWriteUIntN(streamId, self.level, 5)
    if not self.isRequest then
        local fillLevels = self.fillLevels or {}
        local count = math.min(#fillLevels, HDLevelSyncEvent.MAX_FILL_LEVELS)
        streamWriteUIntN(streamId, count, 8)
        for i = 1, count do
            local entry = fillLevels[i]
            streamWriteUIntN(streamId, entry.storageIndex, 8)
            streamWriteUIntN(streamId, entry.fillType, FillTypeManager.SEND_NUM_BITS)
            streamWriteFloat32(streamId, entry.level)
        end

        local productivities = self.productivities or {}
        local productivityCount = math.min(#productivities, HDLevelSyncEvent.MAX_PRODUCTIVITY_ENTRIES)
        streamWriteUIntN(streamId, productivityCount, 8)
        for i = 1, productivityCount do
            local entry = productivities[i]
            streamWriteString(streamId, entry.id)
            streamWriteUIntN(streamId, entry.productivity, 9)
        end
    end
end

function HDLevelSyncEvent:readStream(streamId, connection)
    self.placeable = NetworkUtil.readNodeObject(streamId)
    self.isRequest = streamReadBool(streamId)
    self.refreshMenu = streamReadBool(streamId)
    self.level = streamReadUIntN(streamId, 5)
    if not self.isRequest then
        local count = streamReadUIntN(streamId, 8)
        local fillLevels = {}
        for i = 1, count do
            local storageIndex = streamReadUIntN(streamId, 8)
            local fillType = streamReadUIntN(streamId, FillTypeManager.SEND_NUM_BITS)
            local level = streamReadFloat32(streamId)
            fillLevels[i] = { storageIndex = storageIndex, fillType = fillType, level = level }
        end
        self.fillLevels = fillLevels

        local productivityCount = streamReadUIntN(streamId, 8)
        local productivities = {}
        for i = 1, productivityCount do
            local id = streamReadString(streamId)
            local productivity = streamReadUIntN(streamId, 9)
            productivities[i] = { id = id, productivity = productivity }
        end
        self.productivities = productivities
    end
    self:run(connection)
end

-- Storage order follows HDPlaceableUpgrade.getStorageEntries()'s stable, deduplicated, first-seen
-- order; fillType indices within each storage are explicitly sorted ascending, since iterating
-- storage.fillLevels via pairs() would otherwise be nondeterministic between calls/machines.
function HDLevelSyncEvent.captureFillLevels(placeable)
    local storages = HDPlaceableUpgrade.getStorageEntries(placeable)
    local fillLevels = {}
    local omittedCount = 0
    local omittedIndex = 0
    for storageIndex, entry in ipairs(storages or {}) do
        local storage = entry.storage
        local zeroBasedIndex = storageIndex - 1
        if storage ~= nil and storage.fillLevels ~= nil then
            local fillTypes = {}
            for fillType, fillLevel in pairs(storage.fillLevels) do
                if fillLevel ~= nil and fillLevel > 0 then
                    table.insert(fillTypes, fillType)
                end
            end
            table.sort(fillTypes)
            if zeroBasedIndex > HDLevelSyncEvent.MAX_STORAGE_INDEX then
                omittedIndex = omittedIndex + #fillTypes
            else
                for _, fillType in ipairs(fillTypes) do
                    if #fillLevels < HDLevelSyncEvent.MAX_FILL_LEVELS then
                        table.insert(fillLevels, {
                            storageIndex = zeroBasedIndex,
                            fillType = fillType,
                            level = storage.fillLevels[fillType]
                        })
                    else
                        omittedCount = omittedCount + 1
                    end
                end
            end
        end
    end
    if omittedCount > 0 or omittedIndex > 0 then
        Logging.warning("[HD Building Upgrades] Sync: '%s' fill level snapshot truncated (%d over the %d entry limit, %d beyond storage index %d)",
            tostring(placeable.uniqueId), omittedCount, HDLevelSyncEvent.MAX_FILL_LEVELS, omittedIndex, HDLevelSyncEvent.MAX_STORAGE_INDEX)
    end
    return fillLevels
end

-- Productivity affects capacity (HDPlaceableUpgrade.getCapacityMultiplier() factors in
-- getMaxProductivity(), which reads spec.productivities), so a client that applied a level
-- without also knowing about a non-default productivity would compute too small a capacity and
-- clamp an otherwise-correct fill level down. Only non-default (~= 100%) entries are sent, sorted
-- alphabetically by production id for a deterministic wire order; the client rebuilds its whole
-- productivity table from this snapshot, so any production not listed here correctly defaults to
-- 100% on the client too.
function HDLevelSyncEvent.captureProductivities(placeable)
    local spec = placeable[HDPlaceableUpgrade.SPEC_TABLE_NAME]
    local source = spec ~= nil and spec.productivities or nil

    local ids = {}
    for productionId, productivity in pairs(source or {}) do
        if productivity ~= 100 and HDPlaceableUpgrade.isValidProductivity(productivity) then
            table.insert(ids, productionId)
        end
    end
    table.sort(ids)

    local entries = {}
    local omitted = 0
    for _, productionId in ipairs(ids) do
        if #entries < HDLevelSyncEvent.MAX_PRODUCTIVITY_ENTRIES then
            table.insert(entries, { id = productionId, productivity = source[productionId] })
        else
            omitted = omitted + 1
        end
    end
    if omitted > 0 then
        Logging.warning("[HD Building Upgrades] Sync: '%s' productivity snapshot truncated (%d over the %d entry limit)",
            tostring(placeable.uniqueId), omitted, HDLevelSyncEvent.MAX_PRODUCTIVITY_ENTRIES)
    end
    return entries
end

-- Must run after HDPlaceableUpgrade.applyLevel() on the client so the snapshot is clamped against the upgraded capacity, not the original XML capacity.
function HDLevelSyncEvent.applyFillLevels(placeable, fillLevels)
    if fillLevels == nil or #fillLevels == 0 then
        return 0
    end

    local storages = HDPlaceableUpgrade.getStorageEntries(placeable)
    if storages == nil or #storages == 0 then
        return 0
    end

    local restored = 0
    local seen = {}
    for _, entry in ipairs(fillLevels) do
        local storageEntry = storages[entry.storageIndex + 1]
        local storage = storageEntry ~= nil and storageEntry.storage or nil
        if storage == nil then
            Logging.warning("[HD Building Upgrades] Sync: storage index %s not found, skipping", tostring(entry.storageIndex))
        elseif storage.fillLevels == nil or storage.fillLevels[entry.fillType] == nil then
            Logging.warning("[HD Building Upgrades] Sync: fill type %s not supported by storage, skipping", tostring(entry.fillType))
        else
            local dedupeKey = entry.storageIndex .. ":" .. entry.fillType
            if seen[dedupeKey] then
                Logging.warning("[HD Building Upgrades] Sync: duplicate fill level entry, skipping")
            else
                seen[dedupeKey] = true
                local capacity = (storage.capacities ~= nil and storage.capacities[entry.fillType]) or storage.capacity or 0
                local clampedLevel = math.clamp(entry.level or 0, 0, capacity)
                if storage.setFillLevel ~= nil then
                    storage:setFillLevel(clampedLevel, entry.fillType)
                else
                    storage.fillLevels[entry.fillType] = clampedLevel
                end
                restored = restored + 1
            end
        end
    end
    return restored
end

function HDLevelSyncEvent:run(connection)
    if self.placeable == nil then
        return
    end

    if HDPlaceableUpgrade.isRelayProductionPoint(self.placeable) then
        return
    end

    if self.isRequest then
        if g_server == nil or connection == nil then
            return
        end
        local spec = HDPlaceableUpgrade.ensureRuntimeSpec(self.placeable)
        HDUpgradeSavegame.applyPendingLevel(self.placeable)
        if spec ~= nil then
            local fillLevels = HDLevelSyncEvent.captureFillLevels(self.placeable)
            local productivities = HDLevelSyncEvent.captureProductivities(self.placeable)
            connection:sendEvent(HDLevelSyncEvent.new(self.placeable, spec.level, false, fillLevels, self.refreshMenu, productivities))
        end
        return
    end

    -- Mandatory order: resolve spec, replace its productivity table with the validated snapshot,
    -- THEN apply the level (capacity depends on productivity), and only then restore fill levels
    -- (which must clamp against the now-correct, productivity-aware capacity).
    local spec = HDPlaceableUpgrade.ensureRuntimeSpec(self.placeable)
    local knownProductionIds = {}
    if spec ~= nil and spec.base ~= nil and spec.base.production ~= nil then
        for _, entry in ipairs(spec.base.production) do
            knownProductionIds[entry.id] = true
        end
    end
    local newProductivities = {}
    local productivitiesApplied = 0
    for _, entry in ipairs(self.productivities or {}) do
        if entry.id ~= nil and knownProductionIds[entry.id] and HDPlaceableUpgrade.isValidProductivity(entry.productivity) then
            newProductivities[entry.id] = entry.productivity
            productivitiesApplied = productivitiesApplied + 1
        end
    end
    if spec ~= nil then
        spec.productivities = newProductivities
    end
    HDLevelSyncEvent.sessionProductivityEntriesApplied = HDLevelSyncEvent.sessionProductivityEntriesApplied + productivitiesApplied

    HDPlaceableUpgrade.applyLevel(self.placeable, self.level)
    HDLevelSyncEvent.sessionFillLevelsApplied = HDLevelSyncEvent.sessionFillLevelsApplied
        + HDLevelSyncEvent.applyFillLevels(self.placeable, self.fillLevels)

    -- The level/fill-level snapshot above is applied for every response. Once it has landed,
    -- opening the menu should not re-request the same building, whether this response came from
    -- the automatic join queue or from the menu itself.
    if HDProductionMenu ~= nil and HDProductionMenu.requestedLevels ~= nil then
        HDProductionMenu.requestedLevels[self.placeable] = true
    end

    -- Automatic join responses must never touch the production GUI: InGameMenuProductionFrame's
    -- lists are only valid once the menu is open and productionChainManager is initialized, and
    -- refreshing them unconditionally caused 'attempt to index nil with getProductionPointsForFarmId'
    -- when a response for an auto-queued building arrived before the menu was ready.
    if self.refreshMenu
        and g_gui ~= nil and g_gui.currentGuiName == "InGameMenu"
        and g_currentMission ~= nil and g_currentMission.productionChainManager ~= nil
        and g_inGameMenu ~= nil and g_inGameMenu.pageProduction ~= nil then
        local page = g_inGameMenu.pageProduction
        if page.updateProductionLists ~= nil then
            page:updateProductionLists()
        end
        if page.updateMenuButtons ~= nil then
            page:updateMenuButtons()
        end
    end
end

function HDLevelSyncEvent.sendRequest(placeable, refreshMenu)
    if g_server == nil and g_client ~= nil and placeable ~= nil then
        g_client:getServerConnection():sendEvent(HDLevelSyncEvent.new(placeable, 1, true, nil, refreshMenu == true))
    end
end

function HDLevelSyncEvent.isEligibleForAutoSync(placeable)
    if placeable == nil then
        return false
    end
    if placeable.spec_silo == nil
        and placeable.spec_productionPoint == nil
        and placeable.spec_extendedProductionPoint == nil then
        return false
    end
    if HDPlaceableUpgrade.isRelayProductionPoint(placeable) then
        return false
    end
    local spec = HDPlaceableUpgrade.ensureRuntimeSpec(placeable)
    return spec ~= nil and HDPlaceableUpgrade.hasUpgradeableBase(spec.base)
end

-- Tears down every session-scoped table (queue, dedupe sets, counters) and, if we still hold an
-- updateable registration, removes it from the mission that actually owns it -- never from
-- g_currentMission, which may already differ from the mission being torn down. Only acts when
-- `mission` is the mission our current session actually belongs to, so an unrelated mission
-- teardown (or the bootstrap case where nothing has been queued yet) is a safe no-op.
function HDLevelSyncEvent.cleanupForMission(mission)
    if mission == nil or mission ~= HDLevelSyncEvent.sessionMission then
        return
    end

    if HDLevelSyncEvent.updateableRegistered and HDLevelSyncEvent.updateableMission ~= nil then
        HDLevelSyncEvent.updateableMission:removeUpdateable(HDLevelSyncEvent)
    end

    HDLevelSyncEvent.queue = {}
    HDLevelSyncEvent.queueHead = 1
    HDLevelSyncEvent.queueTail = 0
    HDLevelSyncEvent.queuedPlaceables = setmetatable({}, {__mode = "k"})
    HDLevelSyncEvent.autoRequested = setmetatable({}, {__mode = "k"})
    HDLevelSyncEvent.updateableRegistered = false
    HDLevelSyncEvent.updateableMission = nil
    HDLevelSyncEvent.sessionMission = nil
    HDLevelSyncEvent.timer = 0
    HDLevelSyncEvent.queueDispatched = 0
    HDLevelSyncEvent.queueSkipped = 0
    HDLevelSyncEvent.queueHadEntries = false
    HDLevelSyncEvent.sessionFillLevelsApplied = 0
    HDLevelSyncEvent.sessionProductivityEntriesApplied = 0
end

-- Queues eligible placeables instead of requesting immediately: joining a server with many
-- upgraded buildings finalizes them all in one burst, and firing a network request per object
-- synchronously from that burst previously stalled the join. HDLevelSyncEvent:update() below
-- drains this queue in small throttled batches, only once the mission is actually playable.
--
-- Ownership is tracked by mission identity (sessionMission), not by the onStartMission event:
-- finalizePlacement() for join-in-progress placeables can fire before onStartMission runs, so a
-- reset hooked to that event could wipe a queue that was just populated. Comparing against
-- g_currentMission here only ever triggers a cleanup when the mission object itself has actually
-- changed (i.e. a genuine reconnect/new session), never merely because a lifecycle event fired.
function HDLevelSyncEvent.queueForAutoSync(placeable)
    if g_server ~= nil or g_client == nil or placeable == nil or g_currentMission == nil then
        return
    end

    if HDLevelSyncEvent.sessionMission ~= g_currentMission then
        HDLevelSyncEvent.cleanupForMission(HDLevelSyncEvent.sessionMission)
        HDLevelSyncEvent.sessionMission = g_currentMission
    end

    if HDLevelSyncEvent.queuedPlaceables[placeable] or HDLevelSyncEvent.autoRequested[placeable] then
        return
    end
    if not HDLevelSyncEvent.isEligibleForAutoSync(placeable) then
        return
    end

    HDLevelSyncEvent.queuedPlaceables[placeable] = true
    HDLevelSyncEvent.queueTail = HDLevelSyncEvent.queueTail + 1
    HDLevelSyncEvent.queue[HDLevelSyncEvent.queueTail] = placeable
    HDLevelSyncEvent.queueHadEntries = true

    if not HDLevelSyncEvent.updateableRegistered then
        g_currentMission:addUpdateable(HDLevelSyncEvent)
        HDLevelSyncEvent.updateableRegistered = true
        HDLevelSyncEvent.updateableMission = g_currentMission
    end
end

function HDLevelSyncEvent:update(dt)
    if HDLevelSyncEvent.queueHead > HDLevelSyncEvent.queueTail then
        if HDLevelSyncEvent.queueHadEntries then
            print(string.format(
                "[HD Building Upgrades] Join sync complete: %d building(s) requested, %d skipped, %d storage fill level(s) restored, %d productivity entries applied",
                HDLevelSyncEvent.queueDispatched,
                HDLevelSyncEvent.queueSkipped,
                HDLevelSyncEvent.sessionFillLevelsApplied,
                HDLevelSyncEvent.sessionProductivityEntriesApplied
            ))
            HDLevelSyncEvent.queueHadEntries = false
            HDLevelSyncEvent.queueDispatched = 0
            HDLevelSyncEvent.queueSkipped = 0
        end
        -- Drained: reclaim the backing array and stop taking per-frame updates until something
        -- new is queued (e.g. a building constructed mid-session re-registers us later).
        HDLevelSyncEvent.queue = {}
        HDLevelSyncEvent.queueHead = 1
        HDLevelSyncEvent.queueTail = 0
        if HDLevelSyncEvent.updateableRegistered and HDLevelSyncEvent.updateableMission ~= nil then
            HDLevelSyncEvent.updateableMission:removeUpdateable(HDLevelSyncEvent)
        end
        HDLevelSyncEvent.updateableRegistered = false
        HDLevelSyncEvent.updateableMission = nil
        HDLevelSyncEvent.timer = 0
        return
    end

    -- Wait until the mission is fully playable, still owned by us, and the production GUI's data
    -- source (productionChainManager) is initialized before sending anything: dispatching while
    -- g_currentMission.player ~= nil (the previous, looser fallback) fired before
    -- productionChainManager existed and produced 'attempt to index nil with
    -- getProductionPointsForFarmId' once a response arrived. A connection is required too, since
    -- sendRequest() needs one to actually send.
    local missionReady = g_currentMission ~= nil
        and g_currentMission == HDLevelSyncEvent.sessionMission
        and g_currentMission.isMissionStarted == true
        and g_currentMission.productionChainManager ~= nil
        and g_client ~= nil
        and g_client:getServerConnection() ~= nil
    if not missionReady then
        return
    end

    HDLevelSyncEvent.timer = HDLevelSyncEvent.timer + dt
    if HDLevelSyncEvent.timer < HDLevelSyncEvent.QUEUE_INTERVAL then
        return
    end
    HDLevelSyncEvent.timer = 0

    for _ = 1, HDLevelSyncEvent.QUEUE_BATCH_SIZE do
        if HDLevelSyncEvent.queueHead > HDLevelSyncEvent.queueTail then
            break
        end
        local placeable = HDLevelSyncEvent.queue[HDLevelSyncEvent.queueHead]
        HDLevelSyncEvent.queue[HDLevelSyncEvent.queueHead] = nil
        HDLevelSyncEvent.queueHead = HDLevelSyncEvent.queueHead + 1
        HDLevelSyncEvent.queuedPlaceables[placeable] = nil
        if placeable.isDeleted then
            HDLevelSyncEvent.queueSkipped = HDLevelSyncEvent.queueSkipped + 1
        else
            HDLevelSyncEvent.autoRequested[placeable] = true
            HDLevelSyncEvent.sendRequest(placeable, false)
            HDLevelSyncEvent.queueDispatched = HDLevelSyncEvent.queueDispatched + 1
        end
    end
end

Placeable.finalizePlacement = Utils.appendedFunction(
    Placeable.finalizePlacement,
    HDLevelSyncEvent.queueForAutoSync
)

-- Prepended (runs before the mission tears itself down) so the mission instance is still valid
-- when we call removeUpdateable on it. Receives the mission being deleted explicitly; cleanup
-- only proceeds if that mission is the one our session actually belongs to.
FSBaseMission.delete = Utils.prependedFunction(
    FSBaseMission.delete,
    HDLevelSyncEvent.cleanupForMission
)
