MS_NoxiousExposure = MS_NoxiousExposure or {}

local Exposure = MS_NoxiousExposure

local function tr(key, fallback)
    if getTextOrNull then
        local translated = getTextOrNull(key)
        if translated and translated ~= key then
            return translated
        end
    end

    if getText then
        local ok, translated = pcall(getText, key)
        if ok and translated and translated ~= key then
            return translated
        end
    end

    return fallback
end

Exposure.MAX_COUNT = 25
Exposure.CORPSE_THRESHOLD = 5
Exposure.BUCKET_SIZE = 8
Exposure.CACHE_TTL_TICKS = 15
Exposure.IRL_GAIN_FACTOR = 2880
Exposure._tickCounter = Exposure._tickCounter or 0
Exposure._cache = Exposure._cache or {}
Exposure._eventsRegistered = Exposure._eventsRegistered or false

local function safeCall(target, methodName, ...)
    if not target or not target[methodName] then
        return nil
    end

    local ok, result = pcall(target[methodName], target, ...)
    if ok then
        return result
    end

    return nil
end

local function getSandboxOption(optionName)
    local sandbox = nil
    if getSandboxOptions then
        sandbox = getSandboxOptions()
    elseif SandboxOptions and SandboxOptions.instance then
        sandbox = SandboxOptions.instance
    end

    if sandbox and sandbox.getOptionByName then
        local option = sandbox:getOptionByName(optionName)
        if option and option.getValue then
            local ok, value = pcall(function()
                return option:getValue()
            end)
            if ok then
                return value
            end
        end
    end

    local fieldName = optionName
    if fieldName and #fieldName > 0 then
        fieldName = fieldName:sub(1, 1):lower() .. fieldName:sub(2)
    end

    if sandbox and fieldName and sandbox[fieldName] and sandbox[fieldName].getValue then
        local ok, value = pcall(function()
            return sandbox[fieldName]:getValue()
        end)
        if ok then
            return value
        end
    end

    return nil
end

local function getCorpseImpactIncrement()
    local optionValue = tonumber(getSandboxOption("DecayingCorpseHealthImpact")) or 3
    local baseDecrease = 0.0015

    if optionValue == 1 then
        return 0.0, optionValue
    elseif optionValue == 2 then
        return baseDecrease * 0.01, optionValue
    elseif optionValue == 4 then
        return baseDecrease * 0.11, optionValue
    elseif optionValue == 5 then
        return baseDecrease, optionValue
    end

    return baseDecrease * 0.07, optionValue
end

local function getImpactLabel(optionValue)
    if optionValue == 1 then return tr("IGUI_ModernStatus_None", "None") end
    if optionValue == 2 then return tr("IGUI_ModernStatus_Low", "Low") end
    if optionValue == 4 then return tr("IGUI_ModernStatus_High", "High") end
    if optionValue == 5 then return tr("IGUI_ModernStatus_Insane", "Insane") end
    return tr("IGUI_ModernStatus_Normal", "Normal")
end

local function getBucketCoordinate(coord)
    return math.floor((tonumber(coord) or 0) / Exposure.BUCKET_SIZE)
end

local function getSourceLabel(item)
    if not item then
        return nil
    end

    local displayName = safeCall(item, "getDisplayName")
    if displayName and tostring(displayName) ~= "" then
        return tostring(displayName)
    end

    local name = safeCall(item, "getName")
    if name and tostring(name) ~= "" then
        return tostring(name)
    end

    local fullType = safeCall(item, "getFullType")
    if fullType and tostring(fullType) ~= "" then
        return tostring(fullType)
    end

    local itemType = safeCall(item, "getType")
    if itemType and tostring(itemType) ~= "" then
        return tostring(itemType)
    end

    return nil
end

local function getItemTag(tagName)
    if ItemTag and ItemTag[tagName] then
        return ItemTag[tagName]
    end
    return tagName
end

local function hasItemTag(item, tagName)
    if not item or not item.hasTag or not tagName then
        return false
    end

    local tag = getItemTag(tagName)
    local ok, result = pcall(function()
        return item:hasTag(tag)
    end)
    if ok then
        return result == true
    end

    return false
end

local function getMoodleTypeNoxiousSmell()
    if not MoodleType then
        return nil
    end

    return MoodleType.NOXIOUS_SMELL or MoodleType.NoxiousSmell
end

local function getNoxiousSmellLevel(player)
    if not player or player:isDead() then
        return 0
    end

    local moodles = safeCall(player, "getMoodles")
    local moodleType = getMoodleTypeNoxiousSmell()
    if not moodles or not moodleType or not moodles.getMoodleLevel then
        return 0
    end

    return tonumber(safeCall(moodles, "getMoodleLevel", moodleType)) or 0
end

local function getClothingProtectionData(player)
    local wornItems = player and safeCall(player, "getWornItems") or nil
    if not wornItems or not wornItems.size then
        return {
            percent = 0,
            multiplier = 1.0,
            fullProtection = false,
            source = nil,
            reason = "none",
        }
    end

    local bestDefense = 0
    local bestSource = nil

    for i = 0, wornItems:size() - 1 do
        local item = wornItems:getItemByIndex(i)
        if item and instanceof(item, "Clothing") then
            local usedDelta = tonumber(safeCall(item, "getUsedDelta")) or 0

            -- SCBA only blocks corpse sickness when it is actually worn, active,
            -- supplied with a tank, and the charge is still positive.
            if hasItemTag(item, "SCBA") and safeCall(item, "isActivated") and safeCall(item, "hasTank") and usedDelta > 0 then
                return {
                    percent = 100,
                    multiplier = 0.0,
                    fullProtection = true,
                    source = getSourceLabel(item),
                    reason = "scba",
                }
            end

            -- Gas masks and respirators only grant full protection with a working filter.
            local hasGasProtectionTag = hasItemTag(item, "GAS_MASK") or hasItemTag(item, "RESPIRATOR")
            if hasGasProtectionTag and safeCall(item, "hasFilter") and usedDelta > 0 then
                return {
                    percent = 100,
                    multiplier = 0.0,
                    fullProtection = true,
                    source = getSourceLabel(item),
                    reason = "filter",
                }
            end

            -- Clothing defense does not stack in vanilla; only the best worn item matters.
            local defense = tonumber(safeCall(item, "getCorpseSicknessDefense")) or 0
            if defense > bestDefense then
                bestDefense = defense
                bestSource = getSourceLabel(item)
            end
        end
    end

    return {
        percent = bestDefense,
        multiplier = 1.0 - math.min(100, math.max(0, bestDefense)) / 100.0,
        fullProtection = false,
        source = bestSource,
        reason = bestDefense > 0 and "clothing" or "none",
    }
end

local function playerHasTrait(player, enumValue, stringValue)
    if not player then
        return false
    end

    -- B42.13+ exposes CharacterTrait-aware checks. Only call hasTrait with a
    -- CharacterTrait object; B42.17 has no hasTrait(String) overload and pcall
    -- still prints Java errors when the overload does not exist.
    if enumValue and player.hasTrait then
        local ok, result = pcall(function()
            return player:hasTrait(enumValue)
        end)
        if ok and result == true then
            return true
        end
    end

    -- Fall back to the backing CharacterTraits container. Use get(CharacterTrait)
    -- before any collection-style fallback because B42.17 CharacterTraits is not
    -- a Java collection and does not expose contains(CharacterTrait).
    if enumValue then
        local traits = nil
        if player.getCharacterTraits then
            local ok, result = pcall(function()
                return player:getCharacterTraits()
            end)
            if ok then traits = result end
        elseif player.getTraits then
            local ok, result = pcall(function()
                return player:getTraits()
            end)
            if ok then traits = result end
        end

        if traits then
            if traits.get then
                local ok, result = pcall(function()
                    return traits:get(enumValue)
                end)
                if ok and result == true then
                    return true
                end
            end

            if traits.getTraits then
                local ok, traitMap = pcall(function()
                    return traits:getTraits()
                end)
                if ok and traitMap and traitMap.get then
                    local okGet, result = pcall(function()
                        return traitMap:get(enumValue)
                    end)
                    if okGet and result == true then
                        return true
                    end
                end
            end
        end
    end

    -- Older B42 builds can still expose string-based HasTrait. Keep this path
    -- limited to the legacy method name; never call lowercase hasTrait(String).
    if stringValue and player.HasTrait then
        local ok, result = pcall(function()
            return player:HasTrait(stringValue)
        end)
        if ok and result == true then
            return true
        end
    end

    return false
end

local function getTraitModifierData(player)
    if not player then
        return {
            multiplier = 1.0,
            source = "none",
        }
    end

    local resilientEnum = CharacterTrait and CharacterTrait.RESILIENT or nil
    if playerHasTrait(player, resilientEnum, "Resilient") then
        return {
            multiplier = 0.75,
            source = "Resilient",
        }
    end

    local proneEnum = CharacterTrait and CharacterTrait.PRONE_TO_ILLNESS or nil
    if playerHasTrait(player, proneEnum, "ProneToIllness") then
        return {
            multiplier = 1.25,
            source = "ProneToIllness",
        }
    end

    return {
        multiplier = 1.0,
        source = "none",
    }
end

local function isSameExposureContext(playerBuilding, square)
    if not square then
        return false
    end

    return playerBuilding == square:getBuilding()
end

local function countCorpsesInSquare(square)
    if not square or not square.getStaticMovingObjects then
        return 0
    end

    local objects = square:getStaticMovingObjects()
    if not objects then
        return 0
    end

    local count = 0
    for index = 0, objects:size() - 1 do
        local object = objects:get(index)
        if object and instanceof(object, "IsoDeadBody") then
            count = count + 1
        end
    end

    return count
end

local function countZombiesInSquare(square)
    if not square or not square.getMovingObjects then
        return 0
    end

    local objects = square:getMovingObjects()
    if not objects then
        return 0
    end

    local count = 0
    for index = 0, objects:size() - 1 do
        local object = objects:get(index)
        if object and instanceof(object, "IsoZombie") then
            count = count + 1
        end
    end

    return count
end

local function scanExposureSources(player)
    local square = player and safeCall(player, "getCurrentSquare") or nil
    if not square then
        return {
            corpses = 0,
            zombies = 0,
            countedZombies = 0,
            rawTotal = 0,
            countedTotal = 0,
            cappedTotal = 0,
            zombieHealthImpactEnabled = false,
        }
    end

    local cell = getCell and getCell() or nil
    if not cell then
        return {
            corpses = 0,
            zombies = 0,
            countedZombies = 0,
            rawTotal = 0,
            countedTotal = 0,
            cappedTotal = 0,
            zombieHealthImpactEnabled = false,
        }
    end

    local z = square:getZ()
    local wx = getBucketCoordinate(square:getX())
    local wy = getBucketCoordinate(square:getY())
    local baseX = wx * Exposure.BUCKET_SIZE
    local baseY = wy * Exposure.BUCKET_SIZE
    local playerBuilding = square:getBuilding()

    local corpseCount = 0
    for y = baseY - 8, baseY + 15 do
        for x = baseX - 8, baseX + 15 do
            local nearbySquare = cell:getGridSquare(x, y, z)
            if nearbySquare and isSameExposureContext(playerBuilding, nearbySquare) then
                corpseCount = corpseCount + countCorpsesInSquare(nearbySquare)
            end
        end
    end

    local zombiesNearby = 0
    for y = baseY - 12, baseY + 12 do
        for x = baseX - 12, baseX + 12 do
            local nearbySquare = cell:getGridSquare(x, y, z)
            if nearbySquare and isSameExposureContext(playerBuilding, nearbySquare) then
                zombiesNearby = zombiesNearby + countZombiesInSquare(nearbySquare)
            end
        end
    end

    local zombieHealthImpactEnabled = getSandboxOption("ZombieHealthImpact") == true
    local countedZombies = zombieHealthImpactEnabled and zombiesNearby or 0
    local countedTotal = corpseCount + countedZombies

    return {
        corpses = corpseCount,
        zombies = zombiesNearby,
        countedZombies = countedZombies,
        rawTotal = corpseCount + zombiesNearby,
        countedTotal = countedTotal,
        cappedTotal = math.min(Exposure.MAX_COUNT, countedTotal),
        zombieHealthImpactEnabled = zombieHealthImpactEnabled,
    }
end

local function getDiscreteWeightedThresholds(weightedMultiplier, impactIncrement)
    if weightedMultiplier <= 0 or impactIncrement <= 0 then
        return {
            level1 = nil,
            level2 = nil,
            level3 = nil,
        }
    end

    -- Weighted sources remain intuitive for the player, but the threshold markers
    -- should still line up with the actual vanilla moodle transitions.
    local function countForRate(rateThreshold)
        local excessNeeded = math.floor((rateThreshold / (impactIncrement * weightedMultiplier)) + 0.0000001) + 1
        local countNeeded = Exposure.CORPSE_THRESHOLD + excessNeeded
        if countNeeded > Exposure.MAX_COUNT then
            return nil
        end
        return countNeeded
    end

    local countLevel1 = Exposure.CORPSE_THRESHOLD + 1
    local countLevel2 = countForRate(0.001)
    local countLevel3 = countForRate(0.002)

    return {
        level1 = countLevel1 and (countLevel1 * weightedMultiplier) or nil,
        level2 = countLevel2 and (countLevel2 * weightedMultiplier) or nil,
        level3 = countLevel3 and (countLevel3 * weightedMultiplier) or nil,
    }
end

local function buildExposureData(player)
    local sourceData = scanExposureSources(player)
    local impactIncrement, impactValue = getCorpseImpactIncrement()
    local protection = getClothingProtectionData(player)
    local trait = getTraitModifierData(player)
    local weightedMultiplier = (tonumber(protection.multiplier) or 1.0) * (tonumber(trait.multiplier) or 1.0)

    local actualRate = tonumber(player and safeCall(player, "getCorpseSicknessRate") or 0) or 0
    if actualRate < 0 then
        actualRate = 0
    end

    local moodleLevel = getNoxiousSmellLevel(player)
    local deadlyFumesActive = moodleLevel >= 4

    local weightedSources = sourceData.cappedTotal * weightedMultiplier
    local weightedExcess = math.max(0, math.min(sourceData.cappedTotal - Exposure.CORPSE_THRESHOLD, 20)) * weightedMultiplier
    local predictedRate = weightedExcess * impactIncrement
    local displayValue = deadlyFumesActive and Exposure.MAX_COUNT or weightedSources
    if displayValue < 0 then
        displayValue = 0
    end

    local thresholds = getDiscreteWeightedThresholds(weightedMultiplier, impactIncrement)

    return {
        corpses = sourceData.corpses,
        zombies = sourceData.zombies,
        countedZombies = sourceData.countedZombies,
        rawTotal = sourceData.rawTotal,
        countedTotal = sourceData.countedTotal,
        cappedTotal = sourceData.cappedTotal,
        zombieHealthImpactEnabled = sourceData.zombieHealthImpactEnabled,
        impactIncrement = impactIncrement,
        impactValue = impactValue,
        impactLabel = getImpactLabel(impactValue),
        protection = protection,
        trait = trait,
        weightedMultiplier = weightedMultiplier,
        moodleLevel = moodleLevel,
        deadlyFumesActive = deadlyFumesActive,
        actualRate = actualRate,
        predictedRate = predictedRate,
        weightedSources = weightedSources,
        weightedExcess = weightedExcess,
        displayValue = displayValue,
        gainPerMinuteIRL = actualRate * Exposure.IRL_GAIN_FACTOR,
        effectiveExposure = weightedSources,
        normalizedValue = math.max(0, math.min(1, displayValue / Exposure.MAX_COUNT)),
        thresholdLevel1 = thresholds.level1,
        thresholdLevel2 = thresholds.level2,
        thresholdLevel3 = thresholds.level3,
    }
end

function Exposure.getData(player)
    if not player or player:isDead() then
        local impactIncrement, impactValue = getCorpseImpactIncrement()
        return {
            corpses = 0,
            zombies = 0,
            countedZombies = 0,
            rawTotal = 0,
            countedTotal = 0,
            cappedTotal = 0,
            zombieHealthImpactEnabled = getSandboxOption("ZombieHealthImpact") == true,
            impactIncrement = impactIncrement,
            impactValue = impactValue,
            impactLabel = getImpactLabel(impactValue),
            protection = { percent = 0, multiplier = 1.0, fullProtection = false, source = nil, reason = "none" },
            trait = { multiplier = 1.0, source = "none" },
            weightedMultiplier = 1.0,
            moodleLevel = 0,
            deadlyFumesActive = false,
            actualRate = 0,
            predictedRate = 0,
            weightedSources = 0,
            weightedExcess = 0,
            displayValue = 0,
            gainPerMinuteIRL = 0,
            effectiveExposure = 0,
            normalizedValue = 0,
            thresholdLevel1 = 6,
            thresholdLevel2 = nil,
            thresholdLevel3 = nil,
        }
    end

    local square = player:getCurrentSquare()
    if not square then
        return buildExposureData(player)
    end

    local playerIndex = tonumber(safeCall(player, "getPlayerNum")) or 0
    local cache = Exposure._cache[playerIndex]
    local currentBuilding = square:getBuilding()
    local currentWx = getBucketCoordinate(square:getX())
    local currentWy = getBucketCoordinate(square:getY())
    local currentZ = square:getZ()

    if cache
        and cache.player == player
        and cache.wx == currentWx
        and cache.wy == currentWy
        and cache.z == currentZ
        and cache.building == currentBuilding
        and (Exposure._tickCounter - (cache.tick or 0)) < Exposure.CACHE_TTL_TICKS then
        return cache.data
    end

    local data = buildExposureData(player)
    Exposure._cache[playerIndex] = {
        player = player,
        wx = currentWx,
        wy = currentWy,
        z = currentZ,
        building = currentBuilding,
        tick = Exposure._tickCounter,
        data = data,
    }

    return data
end

function Exposure.getNormalizedValue(player)
    return Exposure.getData(player).normalizedValue or 0
end

function Exposure.getDisplayValue(player)
    return Exposure.getData(player).displayValue or 0
end

function Exposure.getWeightedSources(player)
    return Exposure.getData(player).weightedSources or 0
end

function Exposure.getEffectiveExposure(player)
    return Exposure.getData(player).effectiveExposure or 0
end

function Exposure.getThresholdCounts(player)
    local data = Exposure.getData(player)
    return {
        impactValue = data.impactValue,
        impactLabel = data.impactLabel,
        none = data.thresholdLevel1,
        low = data.thresholdLevel1,
        medium = data.thresholdLevel2,
        high = data.thresholdLevel3,
    }
end

if not Exposure._eventsRegistered and Events and Events.OnTick then
    Events.OnTick.Add(function()
        Exposure._tickCounter = Exposure._tickCounter + 1
    end)
    Exposure._eventsRegistered = true
end

return Exposure
