ManureSteam = {}
ManureSteam.modDirectory = g_currentModDirectory

ManureSteam.CONFIG = {
    customParticleFile = "effects/manureSteamParticle.i3d",
    particleTypes = {"smoke_chimney"},
    smokeKey = "mainManureHeap",
    scanInterval = 2000,
    scanRadius = 32,
    cellSize = 2,
    typeCheckRadius = 0.45,
    maxEmitters = 1,
    missingScanTolerance = 90,
    minHeapLiters = 6000,
    minHeapCells = 1,
    heightOffset = 0.04,
}

local function manureSteamInfo(text, ...)
    Logging.info("[ManureSteam] " .. string.format(text, ...))
end

function ManureSteam.new()
    local self = {}

    setmetatable(self, {__index = ManureSteam})

    self.scanTimer = 0
    self.pool = {}
    self.activeByKey = {}
    self.rootNode = createTransformGroup("ManureSteam")
    self.particleTypeName = nil

    link(getRootNode(), self.rootNode)

    return self
end

function ManureSteam:delete()
    for _, emitter in ipairs(self.pool) do
        if emitter.particleSystem ~= nil then
            ParticleUtil.deleteParticleSystem(emitter.particleSystem)
        end
    end

    self.pool = {}
    self.activeByKey = {}

    if self.rootNode ~= nil and self.rootNode ~= 0 and entityExists(self.rootNode) then
        delete(self.rootNode)
    end

    self.rootNode = nil
end

function ManureSteam:update(dt)
    if g_currentMission == nil or g_densityMapHeightManager == nil or not g_densityMapHeightManager:getIsValid() then
        return
    end

    self.scanTimer = self.scanTimer - dt

    if self.scanTimer <= 0 then
        self.scanTimer = ManureSteam.CONFIG.scanInterval
        self:scan()
    end
end

function ManureSteam:getReferencePosition()
    local player = g_localPlayer

    if player ~= nil then
        local vehicle = player:getCurrentVehicle()

        if vehicle ~= nil and vehicle.rootNode ~= nil then
            return getWorldTranslation(vehicle.rootNode)
        end

        if player.rootNode ~= nil and player.rootNode ~= 0 then
            return getWorldTranslation(player.rootNode)
        end
    end

    if g_currentMission.controlledVehicle ~= nil and g_currentMission.controlledVehicle.rootNode ~= nil then
        return getWorldTranslation(g_currentMission.controlledVehicle.rootNode)
    end

    return nil
end

function ManureSteam:getSourceParticleSystem()
    if self.particleTypeName ~= nil then
        return g_particleSystemManager:getParticleSystem(self.particleTypeName)
    end

    for _, particleTypeName in ipairs(ManureSteam.CONFIG.particleTypes) do
        local sourceParticleSystem = g_particleSystemManager:getParticleSystem(particleTypeName)

        if sourceParticleSystem ~= nil and sourceParticleSystem.shape ~= nil then
            self.particleTypeName = particleTypeName
            manureSteamInfo("Using particle system '%s'", particleTypeName)

            return sourceParticleSystem
        end
    end

    return nil
end

function ManureSteam:createEmitter()
    local particleSystem = {}
    local particleLoaded = self:loadCustomParticleSystem(particleSystem)

    if not particleLoaded then
        local sourceParticleSystem = self:getSourceParticleSystem()

        if sourceParticleSystem == nil or sourceParticleSystem.shape == nil then
            return nil
        end

        local shape = clone(sourceParticleSystem.shape, true, false, true)

        ParticleUtil.loadParticleSystemFromNode(shape, particleSystem, false, true, sourceParticleSystem.forceFullLifespan)
    end

    if not particleSystem.isValid then
        return nil
    end

    ParticleUtil.setEmittingState(particleSystem, false)

    local emitter = {
        particleSystem = particleSystem,
        isActive = false,
        key = nil,
    }

    table.insert(self.pool, emitter)

    return emitter
end

function ManureSteam:loadCustomParticleSystem(particleSystem)
    local filename = ManureSteam.CONFIG.customParticleFile

    if filename == nil or filename == "" then
        return false
    end

    filename = Utils.getFilename(filename, ManureSteam.modDirectory)

    local i3dNode = g_i3DManager:loadI3DFile(filename, false, false)

    if i3dNode == nil or i3dNode == 0 then
        Logging.warning("[ManureSteam] Could not load custom particle file '%s'", filename)
        return false
    end

    local particleNode = i3dNode

    if getNumOfChildren(i3dNode) > 0 then
        particleNode = getChildAt(i3dNode, 0)
    end

    if particleNode ~= i3dNode then
        link(getRootNode(), particleNode)
    end

    ParticleUtil.loadParticleSystemFromNode(particleNode, particleSystem, false, true, false, filename)

    if particleNode ~= i3dNode and entityExists(i3dNode) then
        delete(i3dNode)
    end

    return particleSystem.isValid == true
end

function ManureSteam:getFreeEmitter()
    for _, emitter in ipairs(self.pool) do
        if not emitter.isActive then
            return emitter
        end
    end

    if #self.pool < ManureSteam.CONFIG.maxEmitters then
        return self:createEmitter()
    end

    return nil
end

function ManureSteam:setEmitterPosition(emitter, x, y, z)
    local particleSystem = emitter.particleSystem
    local node = particleSystem.emitterShape

    if node == nil or node == 0 or not entityExists(node) then
        node = particleSystem.shape
    end

    if node ~= nil and node ~= 0 and entityExists(node) then
        setWorldTranslation(node, x, y, z)
    end
end

function ManureSteam:activateEmitter(emitter, key, x, y, z)
    emitter.key = key
    emitter.isActive = true
    emitter.missedScans = 0
    self.activeByKey[key] = emitter

    self:setEmitterPosition(emitter, x, y, z)
    ParticleUtil.setEmittingState(emitter.particleSystem, true)
end

function ManureSteam:updateEmitter(emitter, x, y, z)
    emitter.missedScans = 0
    self:setEmitterPosition(emitter, x, y, z)
end

function ManureSteam:deactivateEmitter(emitter)
    if emitter == nil or not emitter.isActive then
        return
    end

    ParticleUtil.setEmittingState(emitter.particleSystem, false)

    if emitter.key ~= nil then
        self.activeByKey[emitter.key] = nil
    end

    emitter.key = nil
    emitter.isActive = false
end

function ManureSteam:getCellFillLevel(fillTypeIndex, x, z, halfSize)
    return DensityMapHeightUtil.getFillLevelAtArea(
        fillTypeIndex,
        x - halfSize, z - halfSize,
        x + halfSize, z - halfSize,
        x - halfSize, z + halfSize
    )
end

function ManureSteam:getIsManureAtPosition(x, y, z)
    local heightTypeDesc = DensityMapHeightUtil.getHeightTypeDescAtWorldPos(x, y or 0, z, ManureSteam.CONFIG.typeCheckRadius)

    if heightTypeDesc ~= nil then
        return heightTypeDesc.fillTypeIndex == FillType.MANURE
    end

    return DensityMapHeightUtil.getFillTypeAtArea(
        x - 0.5, z - 0.5,
        x + 0.5, z - 0.5,
        x - 0.5, z + 0.5
    ) == FillType.MANURE
end

function ManureSteam:getSteamHeight(x, y, z)
    local height = DensityMapHeightUtil.getHeightAtWorldPos(x, y or 0, z)

    if height == nil or height <= 0 then
        height = getTerrainHeightAtWorldPos(g_terrainNode, x, 0, z)
    end

    return height + ManureSteam.CONFIG.heightOffset
end

function ManureSteam:buildCandidates(centerX, centerY, centerZ)
    local manureFillType = FillType.MANURE
    local config = ManureSteam.CONFIG
    local candidates = {}
    local halfSize = config.cellSize * 0.5
    local radiusSq = config.scanRadius * config.scanRadius

    for offsetX = -config.scanRadius, config.scanRadius, config.cellSize do
        for offsetZ = -config.scanRadius, config.scanRadius, config.cellSize do
            local distanceSq = offsetX * offsetX + offsetZ * offsetZ

            if distanceSq <= radiusSq then
                local x = centerX + offsetX
                local z = centerZ + offsetZ
                local fillLevel = 0

                if self:getIsManureAtPosition(x, centerY, z) then
                    fillLevel = self:getCellFillLevel(manureFillType, x, z, halfSize)
                end

                if fillLevel ~= nil and fillLevel > 0 then
                    local keyX = math.floor((x / config.cellSize) + 0.5)
                    local keyZ = math.floor((z / config.cellSize) + 0.5)

                    table.insert(candidates, {
                        key = string.format("%d:%d", keyX, keyZ),
                        keyX = keyX,
                        keyZ = keyZ,
                        x = x,
                        y = self:getSteamHeight(x, centerY, z),
                        z = z,
                        fillLevel = fillLevel,
                        score = fillLevel / (1 + math.sqrt(distanceSq) * 0.08),
                    })
                end
            end
        end
    end

    table.sort(candidates, function(a, b)
        return a.score > b.score
    end)

    return candidates
end

function ManureSteam:findDominantHeapSpot(candidates)
    local config = ManureSteam.CONFIG
    local bestCluster = nil
    local visited = {}

    for _, candidate in ipairs(candidates) do
        if not visited[candidate.key] then
            local cluster = {
                cells = 0,
                totalFillLevel = 0,
                weightedX = 0,
                weightedY = 0,
                weightedZ = 0,
            }
            local queue = {candidate}
            visited[candidate.key] = true

            while #queue > 0 do
                local current = table.remove(queue)
                local weight = current.fillLevel

                cluster.cells = cluster.cells + 1
                cluster.totalFillLevel = cluster.totalFillLevel + weight
                cluster.weightedX = cluster.weightedX + current.x * weight
                cluster.weightedY = cluster.weightedY + current.y * weight
                cluster.weightedZ = cluster.weightedZ + current.z * weight

                for _, other in ipairs(candidates) do
                    if not visited[other.key] then
                        local isNeighbor = math.abs(current.keyX - other.keyX) <= 1 and math.abs(current.keyZ - other.keyZ) <= 1

                        if isNeighbor then
                            visited[other.key] = true
                            table.insert(queue, other)
                        end
                    end
                end
            end

            if cluster.cells >= config.minHeapCells and cluster.totalFillLevel >= config.minHeapLiters then
                if bestCluster == nil or cluster.totalFillLevel > bestCluster.totalFillLevel then
                    bestCluster = cluster
                end
            end
        end
    end

    if bestCluster == nil then
        return nil
    end

    return {
        key = config.smokeKey,
        x = bestCluster.weightedX / bestCluster.totalFillLevel,
        y = bestCluster.weightedY / bestCluster.totalFillLevel,
        z = bestCluster.weightedZ / bestCluster.totalFillLevel,
    }
end

function ManureSteam:scan()
    if FillType == nil or FillType.MANURE == nil or g_particleSystemManager == nil then
        return
    end

    local centerX, centerY, centerZ = self:getReferencePosition()

    if centerX == nil then
        return
    end

    local candidates = self:buildCandidates(centerX, centerY, centerZ)
    local wantedKeys = {}
    local spot = self:findDominantHeapSpot(candidates)

    if spot ~= nil then
        local emitter = self.activeByKey[spot.key]
        wantedKeys[spot.key] = true

        if emitter ~= nil then
            self:updateEmitter(emitter, spot.x, spot.y, spot.z)
        else
            emitter = self:getFreeEmitter()

            if emitter ~= nil then
                self:activateEmitter(emitter, spot.key, spot.x, spot.y, spot.z)
            end
        end
    end

    local staleEmitters = {}

    for key, emitter in pairs(self.activeByKey) do
        if not wantedKeys[key] then
            emitter.missedScans = (emitter.missedScans or 0) + 1

            if emitter.missedScans >= ManureSteam.CONFIG.missingScanTolerance then
                table.insert(staleEmitters, emitter)
            end
        end
    end

    for _, emitter in ipairs(staleEmitters) do
        self:deactivateEmitter(emitter)
    end
end

function ManureSteam.install()
    if Mission00 == nil or Utils == nil then
        return
    end

    Mission00.onStartMission = Utils.appendedFunction(Mission00.onStartMission, function(mission)
        if g_client == nil then
            return
        end

        if ManureSteam.instance == nil then
            ManureSteam.instance = ManureSteam.new()
            mission:addUpdateable(ManureSteam.instance, "ManureSteam")
            manureSteamInfo("Started")
        end
    end)

    Mission00.delete = Utils.prependedFunction(Mission00.delete, function(mission)
        if ManureSteam.instance ~= nil then
            mission:removeUpdateable("ManureSteam")
            ManureSteam.instance:delete()
            ManureSteam.instance = nil
        end
    end)
end

ManureSteam.install()
