require "ISBaseObject"
require "MS/MS_NoxiousExposure"
require "MS/MS_NaturesCallCompat"

-- Centralized visual configuration for indicator thresholds, visual ranges,
-- and optional step-based color palettes.
MS_IndicatorVisualConfig = MS_IndicatorVisualConfig or {}

local CFG = MS_IndicatorVisualConfig

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

local function trTarget(key, fallback, target)
    local textValue = tostring(tr(key, fallback) or fallback or "")
    return textValue:gsub("{target}", tostring(target), 1)
end

local function hexToColor(hex)
    hex = tostring(hex or "#7A7A7A")
    hex = hex:gsub("#", "")
    if #hex ~= 6 then
        return { r = 0.478, g = 0.478, b = 0.478 }
    end

    return {
        r = tonumber(hex:sub(1, 2), 16) / 255,
        g = tonumber(hex:sub(3, 4), 16) / 255,
        b = tonumber(hex:sub(5, 6), 16) / 255,
    }
end

local function cloneThresholds(items)
    local result = {}
    for _, item in ipairs(items or {}) do
        result[#result + 1] = {
            value = item.value,
            major = item.major == true,
        }
    end
    return result
end

local function hasCharacterTrait(player, trait, stringValue)
    if not player then return false end

    -- Only pass CharacterTrait objects to lowercase hasTrait. B42.17 removed the
    -- hasTrait(String) shape, and Kahlua logs failed Java overload resolution
    -- even when the call is wrapped in pcall.
    if trait and player.hasTrait then
        local ok, result = pcall(function()
            return player:hasTrait(trait)
        end)
        if ok and result == true then
            return true
        end
    end

    -- Prefer CharacterTraits:get(CharacterTrait) when the backing trait set is
    -- available. This is valid in B42.17 and avoids probing unsupported methods.
    if trait 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(trait)
                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(trait)
                    end)
                    if okGet and result == true then return true end
                end
            end
        end
    end

    -- Legacy B42 fallback. Keep string ids on HasTrait only; do not call
    -- lowercase hasTrait(String), which is invalid in modern B42 builds.
    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 getNutrition(player)
    return player and player.getNutrition and player:getNutrition() or nil
end

local function getStats(player)
    return player and player.getStats and player:getStats() or nil
end

local function getBodyDamage(player)
    return player and player.getBodyDamage and player:getBodyDamage() or nil
end

local COLORS = {
    -- Normalized RGB values are stored directly to avoid repeated hexadecimal
    -- parsing while building threshold color bands.
    grey = { r = 0.478431, g = 0.478431, b = 0.478431 }, -- #7A7A7A
    fine = { r = 0.070588, g = 0.788235, b = 0.549020 }, -- #12C98C
    warning = { r = 0.372549, g = 0.490196, b = 0.584314 }, -- #5F7D95
    full = { r = 0.000000, g = 1.000000, b = 0.000000 },

    physical1 = { r = 0.788235, g = 0.705882, b = 0.345098 }, -- #C9B458
    physical2 = { r = 0.850980, g = 0.549020, b = 0.227451 }, -- #D98C3A
    physical3 = { r = 0.850980, g = 0.352941, b = 0.219608 }, -- #D95A38
    physical4 = { r = 0.776471, g = 0.156863, b = 0.156863 }, -- #C62828
    physical5 = { r = 0.545098, g = 0.117647, b = 0.117647 }, -- #8B1E1E
    poison2 = { r = 0.850980, g = 0.627451, b = 0.227451 }, -- #D9A03A

    mental1 = { r = 0.549020, g = 0.482353, b = 0.721569 }, -- #8C7BB8
    mental2 = { r = 0.478431, g = 0.388235, b = 0.721569 }, -- #7A63B8
    mental3 = { r = 0.411765, g = 0.282353, b = 0.721569 }, -- #6948B8
    mental4 = { r = 0.298039, g = 0.184314, b = 0.658824 }, -- #4C2FA8
    mental5 = { r = 0.690196, g = 0.188235, b = 0.478431 }, -- #B0307A
    mentalSoft1 = { r = 0.603922, g = 0.564706, b = 0.721569 }, -- #9A90B8

    timed1 = { r = 0.431373, g = 0.529412, b = 0.580392 }, -- #6E8794
    timed2 = { r = 0.372549, g = 0.623529, b = 0.709804 }, -- #5F9FB5
    timed3 = { r = 0.239216, g = 0.717647, b = 0.839216 }, -- #3DB7D6
    timed4 = { r = 0.309804, g = 0.764706, b = 0.968627 }, -- #4FC3F7

    cold1 = { r = 0.152941, g = 0.298039, b = 0.607843 }, -- #274C9B
    cold2 = { r = 0.372549, g = 0.490196, b = 0.584314 }, -- #5F7D95
    cold3 = { r = 0.603922, g = 0.705882, b = 0.768627 }, -- #9AB4C4
    cold4 = { r = 0.788235, g = 0.843137, b = 0.874510 }, -- #C9D7DF
}

local function buildBands(items)
    local result = {}
    for _, item in ipairs(items or {}) do
        result[#result + 1] = {
            min = item.min,
            max = item.max,
            includeMax = item.includeMax == true,
            color = type(item.color) == "table" and item.color or hexToColor(item.color),
        }
    end
    return result
end

local function physicalBands(l1, l2, l3, l4)
    return buildBands({
        { min = nil, max = l1, color = COLORS.fine },
        { min = l1, max = l2, color = COLORS.physical1 },
        { min = l2, max = l3, color = COLORS.physical2 },
        { min = l3, max = l4, color = COLORS.physical3 },
        { min = l4, max = nil, color = COLORS.physical4, includeMax = true },
    })
end

local function physicalBandsWithEffectfulLevelOne(l1, l2, l3, l4)
    return buildBands({
        { min = nil, max = l1, color = COLORS.fine },
        { min = l1, max = l2, color = COLORS.physical2 },
        { min = l2, max = l3, color = COLORS.physical3 },
        { min = l3, max = l4, color = COLORS.physical4 },
        { min = l4, max = nil, color = COLORS.physical5, includeMax = true },
    })
end

local function physicalBandsWithNoEffectDeepDanger(l1, l2, l3, l4)
    return buildBands({
        { min = nil, max = l1, color = COLORS.fine },
        { min = l1, max = l2, color = COLORS.physical1 },
        { min = l2, max = l3, color = COLORS.physical2 },
        { min = l3, max = l4, color = COLORS.physical3 },
        { min = l4, max = nil, color = COLORS.physical5, includeMax = true },
    })
end

local function poisonBands(l1, l2, l3, l4)
    return buildBands({
        { min = nil, max = 0.0001, color = COLORS.full, includeMax = true },
        { min = 0.0001, max = l1, color = COLORS.physical1, includeMax = true },
        { min = l1, max = l2, color = COLORS.poison2, includeMax = true },
        { min = l2, max = l3, color = COLORS.physical2, includeMax = true },
        { min = l3, max = l4, color = COLORS.physical4, includeMax = true },
        { min = l4, max = nil, color = COLORS.physical5, includeMax = true },
    })
end

local function mentalBands(l1, l2, l3, l4)
    return buildBands({
        { min = nil, max = l1, color = COLORS.fine },
        { min = l1, max = l2, color = COLORS.mental1 },
        { min = l2, max = l3, color = COLORS.mental2 },
        { min = l3, max = l4, color = COLORS.mental3 },
        { min = l4, max = nil, color = COLORS.mental4, includeMax = true },
    })
end

local function softMentalBands(l1, l2, l3, l4)
    return buildBands({
        { min = nil, max = l1, color = COLORS.fine },
        { min = l1, max = l2, color = COLORS.mentalSoft1 },
        { min = l2, max = l3, color = COLORS.mental1 },
        { min = l3, max = l4, color = COLORS.mental2 },
        { min = l4, max = nil, color = COLORS.mental3, includeMax = true },
    })
end

local function reserveBands(normalThreshold, l1, l2, l3, l4)
    return buildBands({
        { min = 0.999, max = nil, color = COLORS.full, includeMax = true },
        { min = normalThreshold, max = 0.999, color = COLORS.fine },
        { min = l1, max = normalThreshold, color = COLORS.physical1 },
        { min = l2, max = l1, color = COLORS.physical2 },
        { min = l3, max = l2, color = COLORS.physical3 },
        { min = nil, max = l3, color = COLORS.physical4, includeMax = true },
    })
end

local function reserveBandsWithNoEffectCaution(safeThreshold, l2, l3, l4, deepDanger)
    return buildBands({
        { min = 0.999, max = nil, color = COLORS.full, includeMax = true },
        { min = safeThreshold, max = 0.999, color = COLORS.fine },
        { min = l2, max = safeThreshold, color = COLORS.physical1 },
        { min = l3, max = l2, color = COLORS.physical2 },
        { min = l4, max = l3, color = COLORS.physical3 },
        { min = deepDanger, max = l4, color = COLORS.physical4 },
        { min = nil, max = deepDanger, color = COLORS.physical5, includeMax = true },
    })
end

local function reserveBandsWithEffectfulLevelOne(safeThreshold, l2, l3, l4, deepDanger)
    return buildBands({
        { min = 0.999, max = nil, color = COLORS.full, includeMax = true },
        { min = safeThreshold, max = 0.999, color = COLORS.fine },
        { min = l2, max = safeThreshold, color = COLORS.physical2 },
        { min = l3, max = l2, color = COLORS.physical3 },
        { min = l4, max = l3, color = COLORS.physical4 },
        { min = deepDanger, max = l4, color = COLORS.physical5 },
        { min = nil, max = deepDanger, color = COLORS.physical5, includeMax = true },
    })
end

local function getStepColor(value, bands)
    if value == nil then
        return COLORS.fine
    end

    for _, band in ipairs(bands or {}) do
        local minOk = (band.min == nil) or (value >= band.min)
        local maxOk = (band.max == nil) or (value < band.max) or (band.includeMax and value <= band.max)
        if minOk and maxOk then
            return band.color
        end
    end

    return COLORS.fine
end

local function getCurrentValue(_, _, currentValue)
    return currentValue
end

local function getRawTemperature(_, player)
    local stats = getStats(player)
    if not stats then return 37.0 end
    return stats:get(CharacterStat.TEMPERATURE)
end

local function getRawWeight(_, player)
    local nutrition = getNutrition(player)
    if not nutrition then return 75 end
    return nutrition:getWeight()
end

local function getRawCalories(_, player)
    local nutrition = getNutrition(player)
    if not nutrition then return 0 end
    return nutrition:getCalories()
end

local function getRawCarbs(_, player)
    local nutrition = getNutrition(player)
    if not nutrition then return 0 end
    return nutrition:getCarbohydrates()
end

local function getRawProteins(_, player)
    local nutrition = getNutrition(player)
    if not nutrition then return 0 end
    return nutrition:getProteins()
end

local function getRawLipids(_, player)
    local nutrition = getNutrition(player)
    if not nutrition then return 0 end
    return nutrition:getLipids()
end


local function getNoxiousExposureImpactIncrement()
    local sandbox = getSandboxOptions and getSandboxOptions() or nil
    local optionValue = sandbox and sandbox.getOptionByName and sandbox:getOptionByName("DecayingCorpseHealthImpact") or nil
    optionValue = optionValue and optionValue.getValue and optionValue:getValue() or 3
    local baseDecrease = 0.0015

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

local function getNoxiousExposureThresholds(player)
    local data = MS_NoxiousExposure and MS_NoxiousExposure.getData and MS_NoxiousExposure.getData(player) or nil
    if not data then
        return nil
    end

    if data.deadlyFumesActive then
        return {
            { value = 25.0, major = true },
        }
    end

    local values = {
        data.thresholdLevel1,
        data.thresholdLevel2,
        data.thresholdLevel3,
    }

    local thresholds = {}
    local seen = {}
    for index, value in ipairs(values) do
        local numericValue = tonumber(value)
        if numericValue and numericValue <= 25.0 then
            local roundedKey = string.format("%.4f", numericValue)
            if not seen[roundedKey] then
                thresholds[#thresholds + 1] = {
                    value = numericValue,
                    major = index ~= 1,
                }
                seen[roundedKey] = true
            end
        end
    end

    return #thresholds > 0 and thresholds or nil
end

local function getNoxiousExposureBands(player)
    local data = MS_NoxiousExposure and MS_NoxiousExposure.getData and MS_NoxiousExposure.getData(player) or nil
    if not data then
        local inc = getNoxiousExposureImpactIncrement()
        if inc <= 0 then
            return buildBands({
                { min = nil, max = nil, color = COLORS.fine, includeMax = true },
            })
        end
        return buildBands({
            { min = nil, max = 6.0, color = COLORS.fine },
            { min = 6.0, max = nil, color = COLORS.physical3, includeMax = true },
        })
    end

    if data.deadlyFumesActive then
        return buildBands({
            { min = nil, max = nil, color = COLORS.physical5, includeMax = true },
        })
    end

    local thresholds = {
        tonumber(data.thresholdLevel1),
        tonumber(data.thresholdLevel2),
        tonumber(data.thresholdLevel3),
    }

    local level1 = thresholds[1]
    local level2 = thresholds[2]
    local level3 = thresholds[3]

    if not level1 then
        return buildBands({
            { min = nil, max = nil, color = COLORS.fine, includeMax = true },
        })
    end

    local bands = {
        { min = nil, max = level1, color = COLORS.fine },
    }

    if level2 and level2 > level1 then
        bands[#bands + 1] = { min = level1, max = level2, color = COLORS.physical1 }
        if level3 and level3 > level2 then
            bands[#bands + 1] = { min = level2, max = level3, color = COLORS.physical2 }
            bands[#bands + 1] = { min = level3, max = nil, color = COLORS.physical4, includeMax = true }
        else
            bands[#bands + 1] = { min = level2, max = nil, color = COLORS.physical3, includeMax = true }
        end
    else
        if level3 and level3 > level1 then
            bands[#bands + 1] = { min = level1, max = level3, color = COLORS.physical2 }
            bands[#bands + 1] = { min = level3, max = nil, color = COLORS.physical4, includeMax = true }
        else
            bands[#bands + 1] = { min = level1, max = nil, color = COLORS.physical3, includeMax = true }
        end
    end

    return buildBands(bands)
end

local function getCalorieThresholds(player)
    local weight = getRawWeight(nil, player)
    local gain = 1000.0
    local weightGainTrait = CharacterTrait and CharacterTrait.WEIGHT_GAIN or nil
    local weightLossTrait = CharacterTrait and CharacterTrait.WEIGHT_LOSS or nil

    if weight < 90.0 and hasCharacterTrait(player, weightGainTrait, "WeightGain") then
        gain = 700.0
    end
    if weight > 70.0 and hasCharacterTrait(player, weightLossTrait, "WeightLoss") then
        gain = 1800.0
    end
    gain = gain + ((weight - 80.0) * 40.0)

    local lose = (weight - 70.0) * 30.0
    if lose > 0 then
        lose = 0
    end

    return lose, gain
end



local function getNCBladderFill(_, player)
    return MS_NaturesCallCompat and MS_NaturesCallCompat.getBladderFill and MS_NaturesCallCompat.getBladderFill(player, false) or 0
end

local function getNCBowelFill(_, player)
    return MS_NaturesCallCompat and MS_NaturesCallCompat.getBowelFill and MS_NaturesCallCompat.getBowelFill(player, false) or 0
end

local function getNCGutHealth(_, player)
    return MS_NaturesCallCompat and MS_NaturesCallCompat.getGutHealth and MS_NaturesCallCompat.getGutHealth(player, false) or 5
end

local function getNCSoilageValue(_, player)
    local data = MS_NaturesCallCompat and MS_NaturesCallCompat.getSoilageData and MS_NaturesCallCompat.getSoilageData(player) or nil
    return data and (tonumber(data.value) or 0) or 0
end

local function getNCDigestiveEffectsValue(indicator, _, currentValue)
    -- The indicator already computes the normalized 0..1 fill value. Convert it
    -- to a 0..100 domain for thresholds/colors without recalculating NC data.
    return (tonumber(currentValue) or 0) * 100
end

local function getBloodinessValue(_, player)
    return MS_PlayerStatus and MS_PlayerStatus.Get and MS_PlayerStatus.Get.Bloodiness and MS_PlayerStatus.Get.Bloodiness(player) or 0
end

local function getWindchillValue(_, player)
    return MS_PlayerStatus and MS_PlayerStatus.Get and MS_PlayerStatus.Get.Windchill and MS_PlayerStatus.Get.Windchill(player) or 0
end

local function getFoodHealingTimer(indicator, player)
    if indicator and indicator.getFoodHealingData then
        local data = indicator:getFoodHealingData()
        return tonumber(data and data.timer or 0) or 0
    end
    local data = MS_PlayerStatus and MS_PlayerStatus.Get and MS_PlayerStatus.Get.FoodHealingData and MS_PlayerStatus.Get.FoodHealingData(player) or nil
    return tonumber(data and data.timer or 0) or 0
end

local THRESHOLD_META = {
    ["MS_HealthIndicator"] = { mode = "reserve", alarm = 20, autoHide = 0 },
    ["MS_HungerIndicator"] = { mode = "reserve", alarm = 25, autoHide = 15 },
    ["MS_ThirstIndicator"] = { mode = "reserve", alarm = 25, autoHide = 12 },
    ["MS_FatigueIndicator"] = { mode = "low", alarm = 60, autoHide = 0 },
    ["MS_EnduranceIndicator"] = { mode = "reserve", alarm = 15, autoHide = 0 },
    ["MS_StressIndicator"] = { mode = "low", alarm = 25, autoHide = 0 },
    ["MS_PanicIndicator"] = { mode = "low", alarm = 6, autoHide = 0 },
    ["MS_BoredomIndicator"] = { mode = "low", alarm = 50, autoHide = 0 },
    ["MS_DiscomfortIndicator"] = { mode = "low", alarm = 20, autoHide = 0 },
    ["MS_UnhappynessIndicator"] = { mode = "low", alarm = 20, autoHide = 0 },
    ["MS_AngerIndicator"] = { mode = "low", alarm = 10, autoHide = 0 },
    ["MS_DrunkennessIndicator"] = { mode = "low", alarm = 10, autoHide = 0 },
    ["MS_MedicineIndicator"] = { mode = "active", alarm = 30, autoHide = 0, unit = "seconds", inputMin = 0, inputMax = 6600, inputStep = 5, inputPrecision = 0 },
    ["MS_StiffnessIndicator"] = { mode = "low", alarm = 25, autoHide = 0 },
    ["MS_PainIndicator"] = { mode = "low", alarm = 10, autoHide = 0 },
    ["MS_BleedingIndicator"] = { mode = "rawLow", alarm = 0, autoHide = 0, inputMin = 0, inputMax = 4, inputStep = 1, inputPrecision = 0 },
    ["MS_PoisonIndicator"] = { mode = "low", alarm = 25, autoHide = 0 },
    ["MS_SicknessIndicator"] = { mode = "low", alarm = 25, autoHide = 0 },
    ["MS_WetnessIndicator"] = { mode = "low", alarm = 15, autoHide = 0 },
    ["MS_WindchillIndicator"] = { mode = "rawLow", alarm = 5, autoHide = 0, inputMin = 0, inputMax = 20, inputStep = 0.5, inputPrecision = 1 },
    ["ISWindchillStatusIndicator"] = { mode = "rawLow", alarm = 5, autoHide = 0, inputMin = 0, inputMax = 20, inputStep = 0.5, inputPrecision = 1 },
    ["MS_TemperatureIndicator"] = { mode = "center", center = 37.0, alarm = 5.0, autoHide = 2.0, inputMin = 0, inputMax = 10, inputStep = 0.1, inputPrecision = 1 },
    ["MS_CatchAColdIndicator"] = { mode = "low", alarm = 20, autoHide = 0 },
    ["MS_FoodHealingIndicator"] = { mode = "active", alarm = 30, autoHide = 0, inputMin = 0, inputMax = 100, inputStep = 5, inputPrecision = 0 },
    ["ISFoodHealingStatusIndicator"] = { mode = "active", alarm = 30, autoHide = 0, inputMin = 0, inputMax = 100, inputStep = 5, inputPrecision = 0 },
    ["MS_WeightIndicator"] = { mode = "center", center = 80.0, alarm = 5.0, autoHide = 2.0, inputMin = 0, inputMax = 30, inputStep = 0.5, inputPrecision = 1 },
    ["MS_CalorieIndicator"] = { mode = "high", alarm = 1000, autoHide = 0, inputMin = -2200, inputMax = 3700, inputStep = 50, inputPrecision = 0 },
    ["MS_CarbohydratesIndicator"] = { mode = "high", alarm = 400, autoHide = 0, inputMin = -500, inputMax = 1000, inputStep = 25, inputPrecision = 0 },
    ["MS_ProteinsIndicator"] = { mode = "high", alarm = 300, autoHide = 0, inputMin = -500, inputMax = 1000, inputStep = 25, inputPrecision = 0 },
    ["MS_LipidsIndicator"] = { mode = "high", alarm = 400, autoHide = 0, inputMin = -500, inputMax = 1000, inputStep = 25, inputPrecision = 0 },
    ["MS_DirtinessIndicator"] = { mode = "low", alarm = 25, autoHide = 0 },
    ["MS_BloodinessIndicator"] = { mode = "low", alarm = 25, autoHide = 0 },
    ["ISBloodinessStatusIndicator"] = { mode = "low", alarm = 25, autoHide = 0 },
    ["MS_NoxiousExposureIndicator"] = { mode = "low", alarm = 6, autoHide = 0 },
    ["ISHygieneNeedStatusIndicator"] = { mode = "low", alarm = 60, autoHide = 0, autoHideMode = "center", autoHideCenter = 50, autoHidePercent = true },
    ["ISBathroomNeedStatusIndicator"] = { mode = "low", alarm = 30, autoHide = 0 },
    ["ISTABASBodyGrimeStatusIndicator"] = { mode = "low", alarm = 45, autoHide = 0 },
    ["ISWORCombinedStatusIndicator"] = { mode = "low", alarm = 50, autoHide = 0 },
    ["ISExcStomachStatusIndicator"] = { mode = "low", alarm = 100, autoHide = 0 },
    ["ISExcColonStatusIndicator"] = { mode = "low", alarm = 80, autoHide = 0 },
    ["ISExcUrineStatusIndicator"] = { mode = "low", alarm = 30, autoHide = 0 },
    ["ISExcSmellStatusIndicator"] = { mode = "low", alarm = 50, autoHide = 0 },
    ["ISExcShameStatusIndicator"] = { mode = "low", alarm = 50, autoHide = 0 },
    ["MS_CarryWeightIndicator"] = { mode = "carry", alarm = 125, autoHide = 0, inputMin = 0, inputMax = 200, inputStep = 5, inputPrecision = 0 },
    ["ISCarryWeightStatusIndicator"] = { mode = "carry", alarm = 125, autoHide = 0, inputMin = 0, inputMax = 200, inputStep = 5, inputPrecision = 0 },
    ["ISNCBladderStatusIndicator"] = { mode = "rawLow", alarm = 95, autoHide = 70, inputMin = 0, inputMax = 120, inputStep = 5, inputPrecision = 0 },
    ["ISNCBowelStatusIndicator"] = { mode = "rawLow", alarm = 95, autoHide = 70, inputMin = 0, inputMax = 120, inputStep = 5, inputPrecision = 0 },
    ["ISNCGutHealthStatusIndicator"] = { mode = "center", center = 5, alarm = 2, autoHide = 0.5, inputMin = 0, inputMax = 4, inputStep = 0.5, inputPrecision = 1 },
    ["ISNCSoilageStatusIndicator"] = { mode = "rawLow", alarm = 25, autoHide = 0, inputMin = 0, inputMax = 100, inputStep = 5, inputPrecision = 0 },
    ["ISNCDigestiveEffectsStatusIndicator"] = { mode = "rawLow", alarm = 0, autoHide = 0, inputMin = 0, inputMax = 100, inputStep = 5, inputPrecision = 0 },
}

local TYPE_ALIASES = {
    ["ISThirstStatusIndicator"] = "MS_ThirstIndicator",
    ["ISHungerStatusIndicator"] = "MS_HungerIndicator",
    ["ISEnduranceStatusIndicator"] = "MS_EnduranceIndicator",
    ["ISFatigueStatusIndicator"] = "MS_FatigueIndicator",
    ["ISStressStatusIndicator"] = "MS_StressIndicator",
    ["ISPanicStatusIndicator"] = "MS_PanicIndicator",
    ["ISTemperatureStatusIndicator"] = "MS_TemperatureIndicator",
    ["ISWindchillStatusIndicator"] = "MS_WindchillIndicator",
    ["ISHealthStatusIndicator"] = "MS_HealthIndicator",
    ["ISBoredomStatusIndicator"] = "MS_BoredomIndicator",
    ["ISUnhappynessStatusIndicator"] = "MS_UnhappynessIndicator",
    ["ISDiscomfortStatusIndicator"] = "MS_DiscomfortIndicator",
    ["ISSicknessStatusIndicator"] = "MS_SicknessIndicator",
    ["ISCatchAColdStatusIndicator"] = "MS_CatchAColdIndicator",
    ["ISFoodHealingStatusIndicator"] = "MS_FoodHealingIndicator",
    ["ISPoisonStatusIndicator"] = "MS_PoisonIndicator",
    ["ISCalorieStatusIndicator"] = "MS_CalorieIndicator",
    ["ISPainStatusIndicator"] = "MS_PainIndicator",
    ["ISBleedingStatusIndicator"] = "MS_BleedingIndicator",
    ["ISCarryWeightStatusIndicator"] = "MS_CarryWeightIndicator",
    ["ISProteinsStatusIndicator"] = "MS_ProteinsIndicator",
    ["ISCarbohydratesStatusIndicator"] = "MS_CarbohydratesIndicator",
    ["ISLipidsStatusIndicator"] = "MS_LipidsIndicator",
    ["ISWeightStatusIndicator"] = "MS_WeightIndicator",
    ["ISDirtinessStatusIndicator"] = "MS_DirtinessIndicator",
    ["ISBloodinessStatusIndicator"] = "MS_BloodinessIndicator",
    ["ISAngerStatusIndicator"] = "MS_AngerIndicator",
    ["ISWetnessStatusIndicator"] = "MS_WetnessIndicator",
    ["ISDrunkennessStatusIndicator"] = "MS_DrunkennessIndicator",
    ["ISStiffnessStatusIndicator"] = "MS_StiffnessIndicator",
    ["ISMedicineStatusIndicator"] = "MS_MedicineIndicator",
    ["ISNoxiousExposureStatusIndicator"] = "MS_NoxiousExposureIndicator",
}

local function normalizeIndicatorType(indicatorType)
    return TYPE_ALIASES[indicatorType] or indicatorType
end

-- Default value refresh cadence for each indicator. Rendering still happens normally,
-- but expensive value/tooltip data is only rebuilt after this interval expires.
local DEFAULT_UPDATE_INTERVAL_MS = {
    MS_HealthIndicator = 150,
    MS_EnduranceIndicator = 150,
    MS_BleedingIndicator = 250,
    MS_PanicIndicator = 250,
    MS_PainIndicator = 500,
    MS_HungerIndicator = 500,
    MS_ThirstIndicator = 500,
    MS_FatigueIndicator = 750,
    MS_SicknessIndicator = 750,
    MS_PoisonIndicator = 750,
    MS_CarryWeightIndicator = 1000,
    MS_NoxiousExposureIndicator = 1500,
    MS_StressIndicator = 1000,
    MS_BoredomIndicator = 1000,
    MS_DiscomfortIndicator = 1000,
    MS_UnhappynessIndicator = 1000,
    MS_AngerIndicator = 1000,
    MS_DrunkennessIndicator = 1000,
    MS_MedicineIndicator = 1000,
    MS_StiffnessIndicator = 1000,
    MS_WetnessIndicator = 1500,
    MS_WindchillIndicator = 1500,
    MS_TemperatureIndicator = 1500,
    MS_CatchAColdIndicator = 2000,
    MS_FoodHealingIndicator = 1000,
    MS_DirtinessIndicator = 2000,
    MS_BloodinessIndicator = 2000,
    MS_WeightIndicator = 5000,
    MS_CalorieIndicator = 5000,
    MS_CarbohydratesIndicator = 5000,
    MS_ProteinsIndicator = 5000,
    MS_LipidsIndicator = 5000,
    ISHygieneNeedStatusIndicator = 1000,
    ISBathroomNeedStatusIndicator = 1000,
    ISTABASBodyGrimeStatusIndicator = 2000,
    ISWORCombinedStatusIndicator = 2000,
    ISExcStomachStatusIndicator = 1000,
    ISExcColonStatusIndicator = 1000,
    ISExcUrineStatusIndicator = 1000,
    ISExcSmellStatusIndicator = 1000,
    ISExcShameStatusIndicator = 1000,
    ISNCBladderStatusIndicator = 1500,
    ISNCBowelStatusIndicator = 1500,
    ISNCGutHealthStatusIndicator = 5000,
    ISNCSoilageStatusIndicator = 3000,
    ISNCDigestiveEffectsStatusIndicator = 3000,
}





local function getThresholdMeta(indicatorType)
    return THRESHOLD_META[normalizeIndicatorType(indicatorType)]
end


-- Indicators that should snap to pure green when they are visually full in inverted mode.
local PURE_FULL_INVERTED_TYPES = {
    ["MS_FatigueIndicator"] = true,
    ["MS_StressIndicator"] = true,
    ["MS_PanicIndicator"] = true,
    ["MS_BoredomIndicator"] = true,
    ["MS_DiscomfortIndicator"] = true,
    ["MS_UnhappynessIndicator"] = true,
    ["MS_AngerIndicator"] = true,
    ["MS_DrunkennessIndicator"] = true,
    ["MS_StiffnessIndicator"] = true,
    ["MS_PainIndicator"] = true,
    ["MS_BleedingIndicator"] = true,
    ["MS_PoisonIndicator"] = true,
    ["MS_SicknessIndicator"] = true,
    ["MS_WetnessIndicator"] = true,
    ["MS_WindchillIndicator"] = true,
    ["MS_CatchAColdIndicator"] = true,
    ["MS_FoodHealingIndicator"] = true,
    ["MS_DirtinessIndicator"] = true,
    ["MS_BloodinessIndicator"] = true,
    ["ISHygieneNeedStatusIndicator"] = true,
    ["ISBathroomNeedStatusIndicator"] = true,
    ["ISTABASBodyGrimeStatusIndicator"] = true,
    ["ISWORCombinedStatusIndicator"] = true,
    ["ISExcStomachStatusIndicator"] = true,
    ["ISExcColonStatusIndicator"] = true,
    ["ISExcUrineStatusIndicator"] = true,
    ["ISExcSmellStatusIndicator"] = true,
    ["ISExcShameStatusIndicator"] = true,
    ["ISNCBladderStatusIndicator"] = true,
    ["ISNCBowelStatusIndicator"] = true,
    ["ISNCSoilageStatusIndicator"] = true,
    ["ISNCDigestiveEffectsStatusIndicator"] = true,
}

local function isPureFullInvertedType(indicatorType)
    return PURE_FULL_INVERTED_TYPES[normalizeIndicatorType(indicatorType)] == true
end


-- Fixed base colors used when Gradient Colors is disabled. Values are stored as
-- normalized RGB tables so rendering and reset/preset paths do not need to
-- convert hexadecimal strings at runtime.
local BASE_COLORS = {
    ["MS_HealthIndicator"] = { r = 0.913725, g = 0.321569, b = 0.321569 }, -- #E95252
    ["MS_ThirstIndicator"] = { r = 0.482353, g = 0.607843, b = 0.643137 }, -- #7B9BA4
    ["MS_FatigueIndicator"] = { r = 0.403922, g = 0.498039, b = 0.709804 }, -- #677FB5
    ["MS_EnduranceIndicator"] = { r = 0.878431, g = 0.564706, b = 0.658824 }, -- #E090A8
    ["MS_StressIndicator"] = { r = 0.878431, g = 0.533333, b = 0.313725 }, -- #E08850
    ["MS_PanicIndicator"] = { r = 0.909804, g = 0.878431, b = 0.501961 }, -- #E8E080
    ["MS_DiscomfortIndicator"] = { r = 0.501961, g = 0.721569, b = 0.847059 }, -- #80B8D8
    ["MS_AngerIndicator"] = { r = 0.878431, g = 0.250980, b = 0.313725 }, -- #E04050
    ["MS_DrunkennessIndicator"] = { r = 0.815686, g = 0.439216, b = 0.470588 }, -- #D07078
    ["MS_MedicineIndicator"] = { r = 0.000000, g = 0.615686, b = 0.392157 }, -- #009D64
    ["MS_StiffnessIndicator"] = { r = 0.960784, g = 0.290196, b = 0.231373 }, -- #F54A3B
    ["MS_PainIndicator"] = { r = 0.909804, g = 0.678431, b = 0.247059 }, -- #E8AD3F
    ["MS_BleedingIndicator"] = { r = 0.796078, g = 0.000000, b = 0.000000 }, -- #CB0000
    ["MS_PoisonIndicator"] = { r = 0.564706, g = 0.690196, b = 0.407843 }, -- #90B068
    ["MS_NoxiousExposureIndicator"] = { r = 0.721569, g = 0.784314, b = 0.627451 }, -- #B8C8A0
    ["MS_SicknessIndicator"] = { r = 0.839216, g = 0.341176, b = 0.258824 }, -- #D65742
    ["MS_WindchillIndicator"] = { r = 0.372549, g = 0.623529, b = 0.709804 }, -- #5F9FB5
    ["MS_TemperatureIndicator"] = { r = 0.941176, g = 0.815686, b = 0.156863 }, -- #F0D028
    ["MS_CatchAColdIndicator"] = { r = 0.564706, g = 0.658824, b = 0.596078 }, -- #90A898
    ["MS_FoodHealingIndicator"] = { r = 0.392157, g = 0.784314, b = 0.470588 }, -- #64C878
    ["MS_BloodinessIndicator"] = { r = 0.796078, g = 0.000000, b = 0.000000 }, -- #CB0000
    ["ISNCBladderStatusIndicator"] = { r = 1.000000, g = 0.850980, b = 0.000000 }, -- #FFD900
    ["ISNCBowelStatusIndicator"] = { r = 0.796078, g = 0.505882, b = 0.200000 }, -- #CB8133
    ["ISNCGutHealthStatusIndicator"] = { r = 0.350000, g = 0.780000, b = 0.450000 },
    ["ISNCSoilageStatusIndicator"] = { r = 0.937255, g = 0.576471, b = 0.364706 }, -- #EF935D
    ["ISNCDigestiveEffectsStatusIndicator"] = { r = 0.900000, g = 0.520000, b = 0.220000 },
}

local function cloneColor(color)
    return {
        r = color and color.r or 1,
        g = color and color.g or 1,
        b = color and color.b or 1,
    }
end

function CFG.getThresholdMeta(indicatorType)
    return getThresholdMeta(indicatorType)
end

function CFG.getFullColor()
    return cloneColor(COLORS.full)
end

function CFG.getDefaultBaseColor(indicatorType)
    local color = BASE_COLORS[normalizeIndicatorType(indicatorType)]
    if not color then
        return nil
    end

    return cloneColor(color)
end

function CFG.usesPureFullColorWhenInverted(indicatorType)
    return isPureFullInvertedType(indicatorType)
end

function CFG.getDefaultAlarmThreshold(indicatorType)
    local meta = getThresholdMeta(indicatorType)
    return meta and meta.alarm or 20
end

function CFG.getDefaultAutoHideThreshold(indicatorType)
    local meta = getThresholdMeta(indicatorType)
    return meta and meta.autoHide or 0
end

function CFG.getDefaultUpdateIntervalMs(indicatorType)
    local normalizedType = normalizeIndicatorType(indicatorType)
    return DEFAULT_UPDATE_INTERVAL_MS[normalizedType] or DEFAULT_UPDATE_INTERVAL_MS[indicatorType] or 1000
end

function CFG.getThresholdInputSpec(indicatorType)
    local meta = getThresholdMeta(indicatorType) or {}
    return {
        min = meta.inputMin or 0,
        max = meta.inputMax or 100,
        step = meta.inputStep or 5,
        precision = meta.inputPrecision or 0,
    }
end

function CFG.supportsDirectionInversion(indicatorType)
    local meta = getThresholdMeta(indicatorType)
    if not meta then
        return false
    end

    return meta.mode ~= "center" and meta.mode ~= "carry"
end

function CFG.isDisplayDirectionInverted(indicator)
    if not indicator or not indicator.getType then
        return false
    end

    return indicator.invertDirection == true and CFG.supportsDirectionInversion(indicator:getType())
end

function CFG.isThresholdExceeded(indicator, currentValue, thresholdValue)
    local indicatorType = indicator and indicator:getType()
    local meta = getThresholdMeta(indicatorType)
    if not meta then
        return nil
    end

    local threshold = tonumber(thresholdValue) or 0

    if meta.mode == "center" then
        local domainValue = tonumber(CFG.getDomainValue(indicator, currentValue)) or 0
        local deviation = math.abs(domainValue - (meta.center or 0))
        if threshold <= 0 then
            return deviation > 0.0001
        end
        return deviation >= threshold
    end

    if meta.mode == "carry" then
        local ratio = 0
        if indicator and indicator.getCarryWeightData then
            local data = indicator:getCarryWeightData()
            -- Thresholds for Encumbrance are entered as current load / carry capacity.
            -- The indicator can still render on its wider visual scale, where 100% load is half fill.
            ratio = tonumber(data and data.loadRatio or 0) or 0
        else
            ratio = tonumber(currentValue) or 0
        end
        if threshold <= 0 then
            return ratio > 0.0001
        end
        return ratio >= (threshold / 100)
    end

    local value = tonumber(currentValue) or 0
    if meta.mode == "reserve" then
        if threshold <= 0 then
            return value < 0.9999
        end
        return value <= (1 - (threshold / 100))
    end

    if meta.mode == "rawLow" then
        local domainValue = tonumber(CFG.getDomainValue(indicator, currentValue)) or tonumber(currentValue) or 0
        if threshold <= 0 then
            return domainValue > 0.0001
        end
        return domainValue >= threshold
    end

    if meta.mode == "active" then
        if meta.unit == "seconds" then
            -- Medicine thresholds can be configured in raw remaining-effect units
            -- instead of the normalized fill ratio used by the indicator itself.
            local remaining = value
            if indicator and indicator.getCurrentMedicineEffectSeconds then
                local ok, result = pcall(function()
                    return indicator:getCurrentMedicineEffectSeconds()
                end)
                if ok then
                    remaining = tonumber(result) or 0
                end
            end
            if threshold <= 0 then
                return remaining > 0.0001
            end
            return remaining > 0.0001 and remaining <= threshold
        end
        if threshold <= 0 then
            return value > 0.0001
        end
        return value > 0.0001 and value <= (threshold / 100)
    end

    if meta.mode == "high" then
        -- Nutrition indicators use raw domain units for user-facing thresholds.
        local domainValue = tonumber(CFG.getDomainValue(indicator, currentValue)) or value
        if threshold <= 0 then
            return domainValue > 0.0001
        end
        return domainValue <= threshold
    end

    if meta.mode == "low" then
        if threshold <= 0 then
            return value > 0.0001
        end
        return value >= (threshold / 100)
    end

    return nil
end

function CFG.isAutoHideExceeded(indicator, currentValue, thresholdValue)
    local indicatorType = indicator and indicator:getType()
    local meta = getThresholdMeta(indicatorType)
    if not meta then
        return nil
    end

    if meta.autoHideMode == "center" then
        local threshold = tonumber(thresholdValue) or 0
        local value = tonumber(currentValue) or 0
        if meta.autoHidePercent == true then
            value = value * 100
        else
            value = tonumber(CFG.getDomainValue(indicator, currentValue)) or value
        end

        local deviation = math.abs(value - (meta.autoHideCenter or meta.center or 0))
        if threshold <= 0 then
            return deviation > 0.0001
        end
        return deviation >= threshold
    end

    return CFG.isThresholdExceeded(indicator, currentValue, thresholdValue)
end


local function getGenericOptionTooltip(optionKey)
    local generic = {
        useGradient = tr("IGUI_ModernStatus_Tooltip_UseGradient", "Use the indicator's special palette instead of the fixed base color."),
        alarmEnabled = tr("IGUI_ModernStatus_Tooltip_AlarmEnabled", "Enable or disable the alarm animation without changing the threshold value."),
        iconPosition = tr("IGUI_ModernStatus_Tooltip_IconPosition", "Bar styles only. Unchecked = left/bottom, checked = right/top."),
        displayThresholds = tr("IGUI_ModernStatus_Tooltip_DisplayThresholds", "Show threshold markers on the indicator."),
        showIcon = tr("IGUI_ModernStatus_Tooltip_ShowIcon", "Show the indicator icon."),
        alwaysShowValue = tr("IGUI_ModernStatus_Tooltip_AlwaysShowValue", "Keep the numeric value visible."),
        invertDirection = tr("IGUI_ModernStatus_Tooltip_InvertDirection", "Flip the displayed value direction for monotonic indicators. Good states can be shown near either 100% or 0%."),
        useMonoIcon = tr("IGUI_ModernStatus_Tooltip_UseMonoIcon", "Use the monochrome icon variant."),
        autoHide = tr("IGUI_ModernStatus_Tooltip_AutoHide", "Hide the indicator until its value passes the Auto Hide Threshold."),
        textScale = tr("IGUI_ModernStatus_Tooltip_TextScale", "Scale the indicator text."),
        opacity = tr("IGUI_ModernStatus_Tooltip_Opacity", "Change the opacity of the main indicator."),
        iconOpacity = tr("IGUI_ModernStatus_Tooltip_IconOpacity", "Change the opacity of the icon."),
        showName = tr("IGUI_ModernStatus_Tooltip_ShowName", "Show the indicator name inside the bar."),
        isVertical = tr("IGUI_ModernStatus_Tooltip_IsVertical", "Switch between horizontal and vertical bar layout."),
        mode = tr("IGUI_ModernStatus_Tooltip_Mode", "Switch between circular, horizontal bar, and vertical bar styles."),
        x = tr("IGUI_ModernStatus_Tooltip_X", "Horizontal screen position."),
        y = tr("IGUI_ModernStatus_Tooltip_Y", "Vertical screen position."),
        width = tr("IGUI_ModernStatus_Tooltip_Width", "Indicator width."),
        height = tr("IGUI_ModernStatus_Tooltip_Height", "Indicator height."),
        size = tr("IGUI_ModernStatus_Tooltip_Size", "Indicator size."),
        color = tr("IGUI_ModernStatus_Tooltip_Color", "Base color used when Gradient Colors is disabled."),
        columns = tr("IGUI_ModernStatus_Tooltip_Columns", "Number of columns used by the matrix layout."),
        deltaX = tr("IGUI_ModernStatus_Tooltip_DeltaX", "Horizontal spacing between matrix columns."),
        deltaY = tr("IGUI_ModernStatus_Tooltip_DeltaY", "Vertical spacing between matrix rows."),
        hide = tr("IGUI_ModernStatus_Tooltip_Hide", "Show or hide the affected indicators."),
        lock = tr("IGUI_ModernStatus_Tooltip_Lock", "Lock or unlock movement of the affected indicators."),
        updateIntervalMs = tr("IGUI_ModernStatus_Tooltip_UpdateIntervalMs", "How often this indicator recalculates its value. Higher values can improve performance but make updates less immediate."),
        compactHiddenIndicators = tr("IGUI_ModernStatus_Tooltip_FillHiddenMatrixGaps", "When enabled, hidden or auto-hidden matrix indicators stop reserving slots, so later indicators move up to fill the gaps while preserving order."),
    }
    return generic[optionKey]
end

function CFG.getOptionTooltip(indicatorType, optionKey, isMultiIndicator)
    local generic = getGenericOptionTooltip(optionKey)
    if optionKey ~= "animationThreshold" and optionKey ~= "autoHideThreshold" then
        return generic
    end

    if isMultiIndicator then
        if optionKey == "animationThreshold" then
            return tr("IGUI_ModernStatus_Tooltip_AnimationThresholdMulti", "Alarm trigger. Reserves alert when they fall below it, risks alert when they rise above it, and centered indicators use distance from targets like 37 (Temperature) or 80 (Weight).")
        end
        return tr("IGUI_ModernStatus_Tooltip_AutoHideThresholdMulti", "Auto-hide trigger. Reserves appear when they fall below it, risks appear when they rise above it, and centered indicators use distance from targets like 37 (Temperature) or 80 (Weight).")
    end

    local meta = getThresholdMeta(indicatorType) or {}
    if optionKey == "autoHideThreshold" and meta.autoHideMode == "center" then
        local target = tostring(meta.autoHideCenter or meta.center or 0) .. "%"
        return trTarget("IGUI_ModernStatus_Tooltip_AutoHideThresholdCenter", "Shows this indicator when the value moves this far away from {target}.", target)
    end

    if meta.mode == "reserve" then
        if optionKey == "animationThreshold" then
            return tr("IGUI_ModernStatus_Tooltip_AnimationThresholdReserve", "Starts the alarm when this reserve falls below the threshold. 0 means alarm whenever it is not full.")
        end
        return tr("IGUI_ModernStatus_Tooltip_AutoHideThresholdReserve", "Shows this reserve when it falls below the threshold. 0 means show whenever it is not full.")
    elseif meta.mode == "low" then
        if optionKey == "animationThreshold" then
            return tr("IGUI_ModernStatus_Tooltip_AnimationThresholdLow", "Starts the alarm when this value rises above the threshold. 0 means alarm on any non-zero value.")
        end
        return tr("IGUI_ModernStatus_Tooltip_AutoHideThresholdLow", "Shows this indicator when the value rises above the threshold. 0 means show on any non-zero value.")
    elseif meta.mode == "center" then
        local target = meta.center or 0
        if indicatorType == "MS_TemperatureIndicator" then
            target = "37"
        elseif indicatorType == "MS_WeightIndicator" then
            target = "80"
        end
        if optionKey == "animationThreshold" then
            return trTarget("IGUI_ModernStatus_Tooltip_AnimationThresholdCenter", "Starts the alarm when the value moves this far away from {target}.", target)
        end
        return trTarget("IGUI_ModernStatus_Tooltip_AutoHideThresholdCenter", "Shows this indicator when the value moves this far away from {target}.", target)
    elseif meta.mode == "rawLow" then
        if optionKey == "animationThreshold" then
            return tr("IGUI_ModernStatus_Tooltip_AnimationThresholdRawLow", "Starts the alarm when this value reaches the threshold or higher.")
        end
        return tr("IGUI_ModernStatus_Tooltip_AutoHideThresholdRawLow", "Shows this indicator when the value reaches the threshold or higher. 0 means show on any non-zero value.")
    elseif meta.mode == "active" then
        if optionKey == "animationThreshold" then
            return tr("IGUI_ModernStatus_Tooltip_AnimationThresholdActive", "Starts the alarm when an active effect has this much time left or less.")
        end
        return tr("IGUI_ModernStatus_Tooltip_AutoHideThresholdActive", "Shows this indicator while the effect is active. If above 0, it can wait until the remaining time drops to the threshold.")
    elseif meta.mode == "carry" then
        if optionKey == "animationThreshold" then
            return tr("IGUI_ModernStatus_Tooltip_AnimationThresholdCarry", "Starts the alarm when the current load reaches this percentage of carry capacity.")
        end
        return tr("IGUI_ModernStatus_Tooltip_AutoHideThresholdCarry", "Shows Encumbrance when the current load reaches this percentage of carry capacity. 0 means show only when carrying something.")
    end

    if optionKey == "animationThreshold" then
        return tr("IGUI_ModernStatus_Tooltip_AnimationThresholdDefault", "Alarm trigger threshold.")
    end
    return tr("IGUI_ModernStatus_Tooltip_AutoHideThresholdDefault", "Auto-hide trigger threshold.")
end

CFG.Config = {
    MS_HealthIndicator = {
        -- Health keeps a safe pale-green zone above the first Injury moodle,
        -- then escalates through caution/danger colors as effects worsen.
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.25, major = true },
            { value = 0.40, major = false },
            { value = 0.60, major = false },
            { value = 0.80, major = true },
        },
        colorBands = reserveBandsWithNoEffectCaution(0.80, 0.60, 0.40, 0.25, 0.125),
    },
    MS_EnduranceIndicator = {
        -- Endurance level 1 already has gameplay effects, so the caution color
        -- starts immediately after the no-moodle safe zone.
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.10, major = true },
            { value = 0.25, major = false },
            { value = 0.50, major = false },
            { value = 0.75, major = true },
        },
        colorBands = reserveBandsWithEffectfulLevelOne(0.75, 0.50, 0.25, 0.10, 0.05),
    },
    MS_HungerIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.30, major = true },
            { value = 0.55, major = false },
            { value = 0.75, major = true },
            { value = 0.85, major = false },
        },
        colorBands = reserveBandsWithNoEffectCaution(0.85, 0.75, 0.55, 0.30, 0.15),
    },
    MS_ThirstIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.16, major = true },
            { value = 0.30, major = false },
            { value = 0.75, major = true },
            { value = 0.88, major = false },
        },
        colorBands = reserveBandsWithNoEffectCaution(0.88, 0.75, 0.30, 0.16, 0.08),
    },
    MS_FatigueIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.60, major = true },
            { value = 0.70, major = false },
            { value = 0.80, major = false },
            { value = 0.90, major = true },
        },
        -- Fatigue level 1 already has gameplay effect, so it skips the yellow caution color.
        colorBands = physicalBandsWithEffectfulLevelOne(0.60, 0.70, 0.80, 0.90),
    },
    MS_StressIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.25, major = false },
            { value = 0.50, major = true },
            { value = 0.75, major = false },
            { value = 0.90, major = true },
        },
        colorBands = mentalBands(0.25, 0.50, 0.75, 0.90),
    },
    MS_PanicIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.06, major = false },
            { value = 0.30, major = true },
            { value = 0.65, major = false },
            { value = 0.80, major = true },
        },
        colorBands = mentalBands(0.06, 0.30, 0.65, 0.80),
    },
    MS_BoredomIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.25, major = false },
            { value = 0.50, major = true },
            { value = 0.75, major = false },
            { value = 0.90, major = true },
        },
        colorBands = mentalBands(0.25, 0.50, 0.75, 0.90),
    },
    MS_DiscomfortIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.20, major = true },
            { value = 0.40, major = false },
            { value = 0.60, major = false },
            { value = 0.80, major = true },
        },
        colorBands = mentalBands(0.20, 0.40, 0.60, 0.80),
    },
    MS_UnhappynessIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.20, major = true },
            { value = 0.45, major = false },
            { value = 0.60, major = false },
            { value = 0.80, major = true },
        },
        colorBands = mentalBands(0.20, 0.45, 0.60, 0.80),
    },
    MS_AngerIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.10, major = false },
            { value = 0.25, major = false },
            { value = 0.50, major = false },
            { value = 0.75, major = false },
        },
        colorBands = softMentalBands(0.10, 0.25, 0.50, 0.75),
    },
    MS_DrunkennessIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.10, major = true },
            { value = 0.30, major = false },
            { value = 0.50, major = false },
            { value = 0.70, major = true },
        },
        colorBands = mentalBands(0.10, 0.30, 0.50, 0.70),
    },
    MS_MedicineIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        showThresholds = false,
        colorBands = buildBands({
            { min = nil, max = 0.0001, color = COLORS.fine, includeMax = true },
            { min = 0.0001, max = 0.25, color = COLORS.timed1, includeMax = true },
            { min = 0.25, max = 0.50, color = COLORS.timed2, includeMax = true },
            { min = 0.50, max = 0.75, color = COLORS.timed3, includeMax = true },
            { min = 0.75, max = nil, color = COLORS.timed4, includeMax = true },
        }),
    },
    MS_StiffnessIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.25, major = false },
            { value = 0.50, major = true },
            { value = 0.75, major = false },
            { value = 0.90, major = true },
        },
        -- Muscle strain starts having gameplay impact from the first moodle level.
        colorBands = physicalBandsWithEffectfulLevelOne(0.25, 0.50, 0.75, 0.90),
    },
    MS_NoxiousExposureIndicator = {
        minValue = 0, maxValue = 25, valueProvider = function(_, _, currentValue)
            return (tonumber(currentValue) or 0) * 25
        end,
        getThresholds = function(player)
            return getNoxiousExposureThresholds(player)
        end,
        getColorBands = function(player)
            return getNoxiousExposureBands(player)
        end,
    },
    MS_PainIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.10, major = false },
            { value = 0.20, major = true },
            { value = 0.50, major = false },
            { value = 0.75, major = true },
        },
        -- Pain level 1 already has gameplay effect, so it skips the yellow caution color.
        colorBands = physicalBandsWithEffectfulLevelOne(0.10, 0.20, 0.50, 0.75),
    },
    MS_BleedingIndicator = {
        minValue = 0, maxValue = 4, valueProvider = getCurrentValue,
        thresholds = {
            { value = 1, major = false },
            { value = 2, major = true },
            { value = 3, major = false },
            { value = 4, major = true },
        },
        -- Bleeding has gameplay impact from the first moodle level.
        colorBands = physicalBandsWithEffectfulLevelOne(1, 2, 3, 4),
    },
    MS_PoisonIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.25, major = false },
            { value = 0.50, major = true },
            { value = 0.75, major = false },
            { value = 0.90, major = true },
        },
        colorBands = poisonBands(0.25, 0.50, 0.75, 0.90),
    },
    MS_SicknessIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.25, major = false },
            { value = 0.50, major = true },
            { value = 0.75, major = false },
            { value = 0.90, major = true },
        },
        colorBands = physicalBands(0.25, 0.50, 0.75, 0.90),
    },
    MS_WetnessIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.15, major = false },
            { value = 0.40, major = true },
            { value = 0.70, major = false },
            { value = 0.90, major = true },
        },
        -- Wetness level 1 already increases discomfort, so it skips the yellow caution color.
        colorBands = physicalBandsWithEffectfulLevelOne(0.15, 0.40, 0.70, 0.90),
    },
    MS_WindchillIndicator = {
        minValue = 0, maxValue = 20, valueProvider = getWindchillValue,
        thresholds = {
            { value = 5, major = false },
            { value = 10, major = true },
            { value = 15, major = false },
            { value = 20, major = true },
        },
        colorBands = buildBands({
            { min = nil, max = 5, color = COLORS.full },
            { min = 5, max = 10, color = COLORS.cold2 },
            { min = 10, max = 15, color = COLORS.cold3 },
            { min = 15, max = 20, color = COLORS.cold4 },
            { min = 20, max = nil, color = COLORS.physical2, includeMax = true },
        }),
    },
    MS_TemperatureIndicator = {
        minValue = 25.0, maxValue = 41.0, valueProvider = getRawTemperature,
        thresholds = {
            { value = 30.0, major = false },
            { value = 35.0, major = false },
            { value = 36.5, major = true },
            { value = 37.5, major = true },
            { value = 39.0, major = false },
            { value = 40.0, major = false },
            { value = 41.0, major = true },
        },
        colorBands = buildBands({
            { min = nil, max = 25.0, color = COLORS.cold1 },
            { min = 25.0, max = 30.0, color = COLORS.cold2 },
            { min = 30.0, max = 35.0, color = COLORS.cold3 },
            { min = 35.0, max = 36.5, color = COLORS.cold4 },
            { min = 36.5, max = 37.5, color = COLORS.full },
            { min = 37.5, max = 39.0, color = COLORS.physical1 },
            { min = 39.0, max = 40.0, color = COLORS.physical2 },
            { min = 40.0, max = 41.0, color = COLORS.physical3 },
            { min = 41.0, max = nil, color = COLORS.physical4, includeMax = true },
        }),
    },
    MS_CatchAColdIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.20, major = false },
            { value = 0.40, major = true },
            { value = 0.60, major = false },
            { value = 0.75, major = true },
        },
        -- Catch a Cold level 1 has no direct effect, so it keeps a caution yellow before stronger danger colors.
        colorBands = physicalBandsWithNoEffectDeepDanger(0.20, 0.40, 0.60, 0.75),
    },
    MS_FoodHealingIndicator = {
        minValue = 0, maxValue = 11000, valueProvider = getFoodHealingTimer,
        thresholds = {
            { value = 0, major = true },
            { value = 1600, major = false },
            { value = 3200, major = true },
            { value = 4800, major = false },
            { value = 11000, major = true },
        },
        colorBands = buildBands({
            { min = nil, max = 0.0001, color = COLORS.fine, includeMax = true },
            { min = 0.0001, max = 1600, color = COLORS.timed1, includeMax = true },
            { min = 1600, max = 3200, color = COLORS.timed2, includeMax = true },
            { min = 3200, max = 4800, color = COLORS.timed3, includeMax = true },
            { min = 4800, max = nil, color = COLORS.timed4, includeMax = true },
        }),
    },
    MS_WeightIndicator = {
        minValue = 35.0, maxValue = 100.0, valueProvider = getRawWeight,
        thresholds = {
            { value = 35.0, major = false },
            { value = 50.0, major = true },
            { value = 65.0, major = false },
            { value = 75.0, major = true },
            { value = 85.0, major = true },
            { value = 100.0, major = true },
        },
        colorBands = buildBands({
            { min = nil, max = 50.0, color = COLORS.physical4, includeMax = true },
            { min = 50.0, max = 65.0, color = COLORS.physical3, includeMax = true },
            { min = 65.0, max = 75.0, color = COLORS.physical2, includeMax = true },
            { min = 75.0, max = 85.0, color = COLORS.full },
            { min = 85.0, max = 100.0, color = COLORS.physical2 },
            { min = 100.0, max = nil, color = COLORS.physical4, includeMax = true },
        }),
    },
    MS_CalorieIndicator = {
        minValue = -2200.0, maxValue = 3700.0, valueProvider = getRawCalories,
        getThresholds = function(player)
            local lose, gain = getCalorieThresholds(player)
            return {
                { value = lose, major = true },
                { value = 0.0, major = false },
                { value = gain, major = true },
            }
        end,
        getColorBands = function(player)
            -- Show the weight-loss side with cold tones so low calories are
            -- visually distinct from the high-calorie weight-gain side.
            local loseThreshold, _ = getCalorieThresholds(player)
            local healthyStart = 50.0
            local healthyEnd = 300.0
            local highCaution = 700.0
            local highDanger = 1000.0
            return buildBands({
                { min = nil, max = loseThreshold, color = COLORS.cold2 },
                { min = loseThreshold, max = healthyStart, color = COLORS.cold4 },
                { min = healthyStart, max = healthyEnd, color = COLORS.full },
                { min = healthyEnd, max = highCaution, color = COLORS.physical1 },
                { min = highCaution, max = highDanger, color = COLORS.physical2 },
                { min = highDanger, max = nil, color = COLORS.physical4, includeMax = true },
            })
        end,
    },
    MS_CarbohydratesIndicator = {
        minValue = -500.0, maxValue = 1000.0, valueProvider = getRawCarbs,
        thresholds = {
            { value = 0.0, major = false },
            { value = 400.0, major = true },
            { value = 700.0, major = true },
        },
        colorBands = buildBands({
            { min = nil, max = 0.0, color = COLORS.cold4 },
            { min = 0.0, max = 400.0, color = COLORS.full },
            { min = 400.0, max = 700.0, color = COLORS.physical1 },
            { min = 700.0, max = 900.0, color = COLORS.physical2 },
            { min = 900.0, max = nil, color = COLORS.physical4, includeMax = true },
        }),
    },
    MS_ProteinsIndicator = {
        minValue = -500.0, maxValue = 1000.0, valueProvider = getRawProteins,
        thresholds = {
            { value = -300.0, major = true },
            { value = 0.0, major = false },
            { value = 50.0, major = true },
            { value = 300.0, major = true },
        },
        colorBands = buildBands({
            { min = nil, max = -300.0, color = COLORS.physical4 },
            { min = -300.0, max = 50.0, color = COLORS.fine, includeMax = true },
            { min = 50.0, max = 300.0, color = COLORS.full },
            { min = 300.0, max = 500.0, color = COLORS.physical1 },
            { min = 500.0, max = 800.0, color = COLORS.physical2 },
            { min = 800.0, max = nil, color = COLORS.physical4, includeMax = true },
        }),
    },
    MS_LipidsIndicator = {
        minValue = -500.0, maxValue = 1000.0, valueProvider = getRawLipids,
        thresholds = {
            { value = 0.0, major = false },
            { value = 400.0, major = true },
            { value = 700.0, major = true },
        },
        colorBands = buildBands({
            { min = nil, max = 0.0, color = COLORS.cold4 },
            { min = 0.0, max = 400.0, color = COLORS.full },
            { min = 400.0, max = 700.0, color = COLORS.physical1 },
            { min = 700.0, max = 900.0, color = COLORS.physical2 },
            { min = 900.0, max = nil, color = COLORS.physical4, includeMax = true },
        }),
    },
    MS_DirtinessIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.25, major = false },
            { value = 0.50, major = true },
            { value = 0.75, major = false },
            { value = 0.90, major = true },
        },
        colorBands = physicalBands(0.25, 0.50, 0.75, 0.90),
    },
    MS_BloodinessIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getBloodinessValue,
        thresholds = {
            { value = 0.25, major = false },
            { value = 0.50, major = true },
            { value = 0.75, major = false },
            { value = 0.90, major = true },
        },
        colorBands = physicalBands(0.25, 0.50, 0.75, 0.90),
    },
    ISHygieneNeedStatusIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.10, major = false },
            { value = 0.20, major = false },
            { value = 0.30, major = false },
            { value = 0.40, major = false },
            { value = 0.50, major = true },
            { value = 0.60, major = true },
            { value = 0.70, major = false },
            { value = 0.80, major = false },
            { value = 0.90, major = true },
        },
        colorBands = buildBands({
            -- Hygiene Need is centered around 50%. Values below 40 are
            -- beneficial, 40-60 is neutral/normal, and values above 60 are risk.
            { min = nil, max = 0.10, color = COLORS.timed4 },
            { min = 0.10, max = 0.20, color = COLORS.timed3 },
            { min = 0.20, max = 0.30, color = COLORS.timed2 },
            { min = 0.30, max = 0.40, color = COLORS.timed1 },
            { min = 0.40, max = 0.60, color = COLORS.full, includeMax = true },
            { min = 0.60, max = 0.70, color = COLORS.physical1 },
            { min = 0.70, max = 0.80, color = COLORS.physical2 },
            { min = 0.80, max = 0.90, color = COLORS.physical4 },
            { min = 0.90, max = nil, color = COLORS.physical5, includeMax = true },
        }),
    },
    ISBathroomNeedStatusIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.30, major = false },
            { value = 0.60, major = true },
            { value = 0.80, major = false },
            { value = 0.90, major = true },
        },
        colorBands = buildBands({
            { min = nil, max = 0.30, color = COLORS.fine },
            { min = 0.30, max = 0.60, color = COLORS.physical1 },
            { min = 0.60, max = 0.80, color = COLORS.physical2 },
            { min = 0.80, max = 0.90, color = COLORS.physical3 },
            { min = 0.90, max = 1.00, color = COLORS.physical4 },
            { min = 1.00, max = nil, color = COLORS.physical5, includeMax = true },
        }),
    },
    ISTABASBodyGrimeStatusIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.45, major = false },
            { value = 0.60, major = true },
            { value = 0.75, major = false },
            { value = 0.90, major = true },
        },
        colorBands = physicalBands(0.45, 0.60, 0.75, 0.90),
    },
    ISWORCombinedStatusIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.50, major = true },
            { value = 0.60, major = false },
            { value = 0.75, major = false },
            { value = 0.90, major = true },
        },
        colorBands = physicalBands(0.50, 0.60, 0.75, 0.90),
    },
    ISExcStomachStatusIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        getThresholds = function(player, indicator)
            local data = indicator and indicator.getExcrementumStomachData and indicator:getExcrementumStomachData() or nil
            if not data or not data.stomachLimit or data.stomachLimit <= 0 then
                return {
                    { value = 0.31, major = false },
                    { value = 0.655, major = true },
                    { value = 1.00, major = true },
                }
            end

            return {
                { value = math.min(1, data.minimumThreshold / data.stomachLimit), major = false },
                { value = math.min(1, data.mediumThreshold / data.stomachLimit), major = true },
                { value = 1.00, major = true },
            }
        end,
        getColorBands = function(player, indicator)
            local data = indicator and indicator.getExcrementumStomachData and indicator:getExcrementumStomachData() or nil
            if not data or not data.stomachLimit or data.stomachLimit <= 0 then
                return buildBands({
                    { min = nil, max = 0.31, color = COLORS.fine },
                    { min = 0.31, max = 0.655, color = COLORS.physical1 },
                    { min = 0.655, max = nil, color = COLORS.physical2, includeMax = true },
                })
            end

            local minimumRatio = math.min(1, data.minimumThreshold / data.stomachLimit)
            local mediumRatio = math.min(1, data.mediumThreshold / data.stomachLimit)
            return buildBands({
                { min = nil, max = minimumRatio, color = COLORS.fine },
                { min = minimumRatio, max = mediumRatio, color = COLORS.physical1 },
                { min = mediumRatio, max = nil, color = COLORS.physical2, includeMax = true },
            })
        end,
    },
    ISExcColonStatusIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.25, major = false },
            { value = 0.50, major = false },
            { value = 0.75, major = true },
            { value = 1.00, major = true },
        },
        colorBands = buildBands({
            { min = nil, max = 0.25, color = COLORS.fine },
            { min = 0.25, max = 0.50, color = COLORS.physical1 },
            { min = 0.50, max = 0.75, color = COLORS.physical2 },
            { min = 0.75, max = 1.00, color = COLORS.physical3 },
            { min = 1.00, max = nil, color = COLORS.physical4, includeMax = true },
        }),
    },
    ISExcUrineStatusIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        getThresholds = function(player, indicator)
            local data = indicator and indicator.getExcrementumUrineData and indicator:getExcrementumUrineData() or nil
            local bad3 = data and tonumber(data.thresholdBad3) or 0.60
            local bad4 = data and tonumber(data.thresholdBad4) or 0.75
            return {
                { value = 0.20, major = false },
                { value = 0.30, major = true },
                { value = bad3, major = false },
                { value = bad4, major = true },
            }
        end,
        getColorBands = function(player, indicator)
            local data = indicator and indicator.getExcrementumUrineData and indicator:getExcrementumUrineData() or nil
            local bad3 = data and tonumber(data.thresholdBad3) or 0.60
            local bad4 = data and tonumber(data.thresholdBad4) or 0.75
            return buildBands({
                { min = nil, max = 0.20, color = COLORS.fine },
                { min = 0.20, max = 0.30, color = COLORS.physical1 },
                { min = 0.30, max = bad3, color = COLORS.physical2 },
                { min = bad3, max = bad4, color = COLORS.physical3 },
                { min = bad4, max = nil, color = COLORS.physical4, includeMax = true },
            })
        end,
    },
    ISExcSmellStatusIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.01, major = false },
            { value = 0.50, major = false },
            { value = 0.75, major = true },
            { value = 1.00, major = true },
        },
        colorBands = buildBands({
            { min = nil, max = 0.01, color = COLORS.fine },
            { min = 0.01, max = 0.50, color = COLORS.physical1 },
            { min = 0.50, max = 0.75, color = COLORS.physical2 },
            { min = 0.75, max = 1.00, color = COLORS.physical3 },
            { min = 1.00, max = nil, color = COLORS.physical4, includeMax = true },
        }),
    },
    ISExcShameStatusIndicator = {
        minValue = 0, maxValue = 1, valueProvider = getCurrentValue,
        thresholds = {
            { value = 0.01, major = false },
            { value = 0.50, major = false },
            { value = 0.75, major = true },
            { value = 1.00, major = true },
        },
        colorBands = buildBands({
            { min = nil, max = 0.01, color = COLORS.fine },
            { min = 0.01, max = 0.50, color = COLORS.mental1 },
            { min = 0.50, max = 0.75, color = COLORS.mental2 },
            { min = 0.75, max = 1.00, color = COLORS.mental3 },
            { min = 1.00, max = nil, color = COLORS.mental5, includeMax = true },
        }),
    },
    ISNCBladderStatusIndicator = {
        minValue = 0, maxValue = 120, valueProvider = getNCBladderFill,
        thresholds = {
            { value = 70, major = false },
            { value = 95, major = true },
            { value = 100, major = true },
        },
        colorBands = buildBands({
            { min = nil, max = 70, color = COLORS.fine },
            { min = 70, max = 95, color = COLORS.physical1 },
            { min = 95, max = 100, color = COLORS.physical3 },
            { min = 100, max = nil, color = COLORS.physical5, includeMax = true },
        }),
    },
    ISNCBowelStatusIndicator = {
        minValue = 0, maxValue = 120, valueProvider = getNCBowelFill,
        thresholds = {
            { value = 70, major = false },
            { value = 95, major = true },
            { value = 100, major = true },
        },
        colorBands = buildBands({
            { min = nil, max = 70, color = COLORS.fine },
            { min = 70, max = 95, color = COLORS.physical1 },
            { min = 95, max = 100, color = COLORS.physical3 },
            { min = 100, max = nil, color = COLORS.physical5, includeMax = true },
        }),
    },
    ISNCGutHealthStatusIndicator = {
        minValue = 1, maxValue = 10, valueProvider = getNCGutHealth,
        thresholds = {
            { value = 3, major = true },
            { value = 5, major = false },
            { value = 7, major = true },
        },
        colorBands = buildBands({
            { min = nil, max = 2, color = COLORS.physical5 },
            { min = 2, max = 4, color = COLORS.physical2 },
            { min = 4, max = 6, color = COLORS.full },
            { min = 6, max = 8, color = COLORS.timed2 },
            { min = 8, max = nil, color = COLORS.timed4, includeMax = true },
        }),
    },
    ISNCSoilageStatusIndicator = {
        minValue = 0, maxValue = 100, valueProvider = getNCSoilageValue,
        thresholds = {
            { value = 25, major = false },
            { value = 50, major = true },
            { value = 75, major = false },
            { value = 90, major = true },
        },
        colorBands = physicalBands(25, 50, 75, 90),
    },
    ISNCDigestiveEffectsStatusIndicator = {
        minValue = 0, maxValue = 100, valueProvider = getNCDigestiveEffectsValue,
        -- Composite Nature's Call effects keep original units in the value text,
        -- but the visual fill is normalized. Timed effects use 4 h as full.
        showThresholds = false,
        colorBands = buildBands({
            { min = nil, max = 0, color = COLORS.fine, includeMax = true },
            { min = 0, max = 25, color = COLORS.physical1, includeMax = false },
            { min = 25, max = 50, color = COLORS.physical2, includeMax = false },
            { min = 50, max = 75, color = COLORS.physical3, includeMax = false },
            { min = 75, max = nil, color = COLORS.physical4, includeMax = true },
        }),
    },
}

function CFG.getConfig(indicatorType)
    return CFG.Config[normalizeIndicatorType(indicatorType)]
end

function CFG.supportsThresholds(indicatorType)
    local config = CFG.getConfig(indicatorType)
    if not config or config.showThresholds == false then
        return false
    end
    return (config.thresholds and #config.thresholds > 0) or config.getThresholds ~= nil
end

function CFG.hasBandColors(indicatorType)
    local config = CFG.getConfig(indicatorType)
    if not config then return false end
    return (config.colorBands and #config.colorBands > 0) or config.getColorBands ~= nil
end

function CFG.getDomainValue(indicator, currentValue)
    local indicatorType = indicator and indicator:getType()
    local config = CFG.getConfig(indicatorType)
    if not config then
        return currentValue
    end

    local provider = config.valueProvider or getCurrentValue
    local ok, value = pcall(provider, indicator, indicator and indicator.player or nil, currentValue)
    if ok then
        return value
    end
    return currentValue
end

function CFG.getVisualRange(indicator, currentValue)
    local indicatorType = indicator and indicator:getType()
    local config = CFG.getConfig(indicatorType)
    if not config then
        return 0, 1
    end

    if config.getVisualRange then
        local ok, minValue, maxValue = pcall(config.getVisualRange, indicator and indicator.player or nil, indicator, currentValue)
        if ok and minValue ~= nil and maxValue ~= nil then
            return minValue, maxValue
        end
    end

    return config.minValue or 0, config.maxValue or 1
end

function CFG.getThresholds(indicator, currentValue)
    local indicatorType = indicator and indicator:getType()
    local config = CFG.getConfig(indicatorType)
    if not config or config.showThresholds == false then
        return nil
    end

    if config.getThresholds then
        local ok, thresholds = pcall(config.getThresholds, indicator and indicator.player or nil, indicator, currentValue)
        if ok then
            return cloneThresholds(thresholds)
        end
        return nil
    end

    return cloneThresholds(config.thresholds)
end

function CFG.getColorBands(indicator, currentValue)
    local indicatorType = indicator and indicator:getType()
    local config = CFG.getConfig(indicatorType)
    if not config then
        return nil
    end

    if config.getColorBands then
        local ok, bands = pcall(config.getColorBands, indicator and indicator.player or nil, indicator, currentValue)
        if ok then
            return bands
        end
        return nil
    end

    return config.colorBands
end

function CFG.getVisualRatio(indicator, currentValue)
    local displayValue = CFG.getDomainValue(indicator, currentValue)
    local minValue, maxValue = CFG.getVisualRange(indicator, currentValue)
    local span = (maxValue or 1) - (minValue or 0)
    if span <= 0 then return 0 end

    local ratio = ((displayValue or 0) - minValue) / span
    if ratio < 0 then ratio = 0 end
    if ratio > 1 then ratio = 1 end
    return ratio
end

function CFG.getBandColor(indicator, currentValue)
    local indicatorType = indicator and indicator:getType()
    if not CFG.hasBandColors(indicatorType) then
        return nil
    end

    local value = CFG.getDomainValue(indicator, currentValue)
    local bands = CFG.getColorBands(indicator, currentValue)
    return getStepColor(value, bands)
end
