require "MS/MS_NoxiousExposure"
require "MS/MS_NaturesCallCompat"
MS_IndicatorTooltipData = MS_IndicatorTooltipData or {}

local DATA = MS_IndicatorTooltipData

local RGB_G = " <RGB:0.3,1,0.3> "
local RGB_R = " <RGB:1,0.3,0.3> "
local RGB_W = " <RGB:1,1,1> "
local RGB_DETAIL_LABEL = " <RGB:0.39,0.58,0.93> "
local RGB_DETAIL_SUBLABEL = " <RGB:0.58,0.80,0.92> "
local DETAIL_STATE_OFF_COLOR = { r = 0.55, g = 0.62, b = 0.72 }
local DETAIL_STATE_ON_COLOR = { r = 0.72, g = 0.24, b = 0.10 }
local DETAIL_CONDITION_OK_COLOR = { r = 0.30, g = 0.90, b = 0.30 }
local DETAIL_CONDITION_BAD_COLOR = { r = 0.95, g = 0.25, b = 0.20 }

local function tr(key, fallback)
    local value = getText(key)
    if value == nil or value == key or value == "" then
        return fallback or key
    end
    return tostring(value)
end

local function getFoodHealingDataForIndicator(indicator)
    if indicator and indicator.getFoodHealingData then
        local ok, data = pcall(function()
            return indicator:getFoodHealingData()
        end)
        if ok and data then
            return data
        end
    end

    if indicator and indicator.player and MS_PlayerStatus and MS_PlayerStatus.Get and MS_PlayerStatus.Get.FoodHealingData then
        return MS_PlayerStatus.Get.FoodHealingData(indicator.player)
    end

    return nil
end

-- Normalize indicator type names so both branch-specific class names resolve
-- to the same tooltip mapping keys.
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",
}

-- Indicators that should keep their hover text as-is instead of appending a
-- percentage suffix in the tooltip value line.
local getExcrementumStomachAssociation
local getExcrementumColonAssociation
local getExcrementumUrineAssociation
local getExcrementumSmellAssociation
local getExcrementumShameAssociation
local formatPercentValue
local formatBooleanLabel
local joinThresholdParts
local formatUrgeText
local getThresholdLevel
local resolveTranslatedText

local RAW_VALUE_TYPES = {
    ["MS_TemperatureIndicator"] = true,
    ["MS_WindchillIndicator"] = true,
    ["MS_CatchAColdIndicator"] = true,
    ["MS_FoodHealingIndicator"] = true,
    ["MS_PoisonIndicator"] = true,
    ["MS_BleedingIndicator"] = true,
    ["MS_CalorieIndicator"] = true,
    ["MS_CarryWeightIndicator"] = true,
    ["MS_ProteinsIndicator"] = true,
    ["MS_CarbohydratesIndicator"] = true,
    ["MS_LipidsIndicator"] = true,
    ["MS_WeightIndicator"] = true,
    ["ISExcStomachStatusIndicator"] = true,
    ["ISExcColonStatusIndicator"] = true,
    ["ISExcUrineStatusIndicator"] = true,
    ["ISExcSmellStatusIndicator"] = true,
    ["ISExcShameStatusIndicator"] = true,
    ["MS_NoxiousExposureIndicator"] = true,
    ["ISNCSoilageStatusIndicator"] = true,
    ["ISNCGutHealthStatusIndicator"] = true,
    ["ISNCBowelStatusIndicator"] = true,
    ["ISNCBladderStatusIndicator"] = true,
    ["ISNCDigestiveEffectsStatusIndicator"] = true,
}

-- Resolve Nature's Call custom moodle associations when the mod is active.
-- These keys are provided by Nature's Call, so they resolve only while that mod
-- is loaded, without copying its tooltip text into Modern Status.
local function getNaturesCallSnapshotForMoodle(indicator)
    if not indicator or not indicator.player or indicator.player:isDead() then
        return nil
    end
    if not MS_NaturesCallCompat or not MS_NaturesCallCompat.getDetailedSnapshot then
        return nil
    end
    return MS_NaturesCallCompat.getDetailedSnapshot(indicator.player, 2500)
end

local function getNaturesCallBadAssociation(indicator, moodleName, labelKey, fallbackLabel, moodleField, fillField)
    local snapshot = getNaturesCallSnapshotForMoodle(indicator)
    if not snapshot then return nil end
    if fillField and (tonumber(snapshot[fillField]) or 0) < 0 then return nil end

    local moodles = snapshot.moodles or {}
    local level = tonumber(moodles[moodleField]) or 0
    level = math.floor(math.abs(level))
    if level <= 0 then return nil end
    if level > 4 then level = 4 end

    return {
        prefix = moodleName,
        label = tr(labelKey, fallbackLabel),
        level = level,
        titleKey = string.format("Moodles_%s_Bad_lvl%d", moodleName, level),
        descriptionKey = string.format("Moodles_%s_Bad_desc_lvl%d", moodleName, level),
    }
end

local function getNaturesCallGutAssociation(indicator)
    local snapshot = getNaturesCallSnapshotForMoodle(indicator)
    if not snapshot then return nil end

    local rawLevel = tonumber(snapshot.moodles and snapshot.moodles.gut) or 0
    if rawLevel == 0 then return nil end

    local alignment = rawLevel > 0 and "Good" or "Bad"
    local level = math.floor(math.abs(rawLevel))
    if level <= 0 then return nil end
    if level > 4 then level = 4 end

    return {
        prefix = "NCGut",
        label = tr("IGUI_ModernStatus_NCGutHealth", "NC Gut Health"),
        level = level,
        titleKey = string.format("Moodles_NCGut_%s_lvl%d", alignment, level),
        descriptionKey = string.format("Moodles_NCGut_%s_desc_lvl%d", alignment, level),
    }
end

local NC_DIGESTIVE_MOODLE_FIELDS = {
    cramps = { name = "NCCramps", field = "cramps", labelKey = "IGUI_ModernStatus_NCDigestiveCramps", fallback = "Cramps" },
    heartburn = { name = "NCHeartburn", field = "heartburn", labelKey = "IGUI_ModernStatus_NCDigestiveHeartburn", fallback = "Heartburn" },
}

local function getNaturesCallDigestiveAssociation(indicator)
    local snapshot = getNaturesCallSnapshotForMoodle(indicator)
    if not snapshot then return nil end

    local data = MS_NaturesCallCompat and MS_NaturesCallCompat.getDigestiveEffectsData and MS_NaturesCallCompat.getDigestiveEffectsData(indicator.player) or nil
    if not data or (tonumber(data.activeCount) or 0) <= 0 then return nil end

    local function buildForEffect(effect)
        local info = effect and NC_DIGESTIVE_MOODLE_FIELDS[effect.key] or nil
        if not info then return nil end
        local level = tonumber(snapshot.moodles and snapshot.moodles[info.field]) or 0
        level = math.floor(math.abs(level))
        if level <= 0 then return nil end
        if level > 4 then level = 4 end
        return {
            prefix = info.name,
            label = tr(info.labelKey, info.fallback),
            level = level,
            titleKey = string.format("Moodles_%s_Bad_lvl%d", info.name, level),
            descriptionKey = string.format("Moodles_%s_Bad_desc_lvl%d", info.name, level),
        }
    end

    local primaryAssociation = buildForEffect(data.primary)
    if primaryAssociation then return primaryAssociation end

    for _, effect in ipairs(data.effects or {}) do
        local association = buildForEffect(effect)
        if association then return association end
    end

    return nil
end

-- Mapping between indicators and their closest vanilla moodle progression.
-- The threshold values are expressed in the same normalized domain used by the
-- indicator value logic so the tooltip stays build-compatible.
local MOODLE_ASSOCIATIONS = {
    MS_EnduranceIndicator = { prefix = "Endurance", compare = "lte", thresholds = { 0.75, 0.50, 0.25, 0.10 } },
    MS_HealthIndicator = { prefix = "Injured", compare = "lte", thresholds = { 0.80, 0.60, 0.40, 0.25 } },
    MS_HungerIndicator = { prefix = "Hungry", compare = "lte", thresholds = { 0.85, 0.75, 0.55, 0.30 } },
    MS_ThirstIndicator = { prefix = "Thirst", compare = "lte", thresholds = { 0.88, 0.75, 0.30, 0.16 } },
    MS_FatigueIndicator = { prefix = "Tired", compare = "gte", thresholds = { 0.60, 0.70, 0.80, 0.90 } },
    MS_WindchillIndicator = {
        resolver = function(indicator, currentValue)
            local amount = tonumber(currentValue) or 0
            local level = getThresholdLevel(amount, "gte", { 5, 10, 15, 20 })
            if level <= 0 then return nil end

            return {
                title = resolveTranslatedText(string.format("Moodles_Windchill_lvl%d", level)) or resolveTranslatedText(string.format("Moodles_windchill_lvl%d", level)),
                description = resolveTranslatedText(string.format("Moodles_Windchill_desc_lvl%d", level)) or resolveTranslatedText(string.format("Moodles_windchill_desc_lvl%d", level)),
                prefix = "Windchill",
                label = tr("IGUI_ModernStatus_Windchill", "Windchill"),
                level = level,
            }
        end,
    },
    MS_TemperatureIndicator = {
        resolver = function(indicator)
            if not indicator or not indicator.player or indicator.player:isDead() then
                return nil
            end

            local actualTemp = indicator.player:getStats():get(CharacterStat.TEMPERATURE)
            if actualTemp == nil then
                return nil
            end

            if actualTemp > 41.0 then
                return { prefix = "Hyperthermia", level = 4 }
            elseif actualTemp > 40.0 then
                return { prefix = "Hyperthermia", level = 3 }
            elseif actualTemp > 39.0 then
                return { prefix = "Hyperthermia", level = 2 }
            elseif actualTemp > 37.5 then
                return { prefix = "Hyperthermia", level = 1 }
            elseif actualTemp < 25.0 then
                return { prefix = "Hypothermia", level = 4 }
            elseif actualTemp < 30.0 then
                return { prefix = "Hypothermia", level = 3 }
            elseif actualTemp < 35.0 then
                return { prefix = "Hypothermia", level = 2 }
            elseif actualTemp < 36.5 then
                return { prefix = "Hypothermia", level = 1 }
            end

            return nil
        end,
    },
    MS_StressIndicator = { prefix = "Stress", compare = "gte", thresholds = { 0.25, 0.50, 0.75, 0.90 } },
    MS_PanicIndicator = { prefix = "Panic", compare = "gte", thresholds = { 0.06, 0.30, 0.65, 0.80 } },
    MS_BoredomIndicator = { prefix = "Bored", compare = "gte", thresholds = { 0.25, 0.50, 0.75, 0.90 } },
    MS_DiscomfortIndicator = { prefix = "Uncomfortable", compare = "gte", thresholds = { 0.20, 0.40, 0.60, 0.80 } },
    MS_UnhappynessIndicator = { prefix = "Unhappy", compare = "gte", thresholds = { 0.20, 0.45, 0.60, 0.80 } },
    MS_AngerIndicator = { prefix = "Angry", compare = "gte", thresholds = { 0.10, 0.25, 0.50, 0.75 } },
    MS_DrunkennessIndicator = { prefix = "Drunk", compare = "gte", thresholds = { 0.10, 0.30, 0.50, 0.70 } },
    MS_SicknessIndicator = { prefix = "Sick", compare = "gte", thresholds = { 0.25, 0.50, 0.75, 0.90 } },
    MS_WetnessIndicator = { prefix = "Wet", compare = "gte", thresholds = { 0.15, 0.40, 0.70, 0.90 } },
    MS_CatchAColdIndicator = { prefix = "HasACold", compare = "gte", thresholds = { 0.20, 0.40, 0.60, 0.75 } },
    MS_FoodHealingIndicator = {
        resolver = function(indicator)
            if not indicator or not indicator.player or indicator.player:isDead() then return nil end
            local data = getFoodHealingDataForIndicator(indicator)
            local level = tonumber(data and data.level or 0) or 0
            if level < 0 then level = 0 end
            if level > 4 then level = 4 end

            local resolved = {
                prefix = "FoodEaten",
                label = tr("IGUI_ModernStatus_FoodHealing", "Food Healing"),
                level = level,
            }

            if level > 0 then
                resolved.titleKey = string.format("Moodles_FoodEaten_lvl%d", level)
                -- Vanilla FoodEaten descriptions are intentionally flavorful, but they
                -- can read like unrelated Hunger text in this indicator tooltip. Use the
                -- clearer Modern Status explanation for the moodle description row.
                resolved.description = tr("IGUI_ModernStatus_FoodHealingNote", "Shows the vanilla healing bonus timer while Satiated, Well Fed, Stuffed, or Full to Bursting.")
            end

            return resolved
        end,
    },
    MS_PainIndicator = { prefix = "Pain", compare = "gte", thresholds = { 0.10, 0.20, 0.50, 0.75 } },
    MS_BleedingIndicator = {
        prefix = "Bleeding",
        compare = "gte",
        thresholds = { 1, 2, 3, 4 },
        valueProvider = function(indicator)
            if not indicator or not indicator.getBleedingData then
                return 0
            end
            local data = indicator:getBleedingData()
            return tonumber(data and data.level or 0) or 0
        end,
    },
    MS_NoxiousExposureIndicator = {
        resolver = function(indicator)
            if not indicator or not indicator.getExposureData then
                return nil
            end

            local data = indicator:getExposureData()
            local level = tonumber(data and data.moodleLevel or 0) or 0
            if level > 0 then
                return { prefix = "NoxiousSmell", level = level }
            end

            return nil
        end,
        valueFormatter = function(indicator)
            if not indicator or not indicator.getExposureData then
                return nil
            end
            local data = indicator:getExposureData()
            return string.format("%.1f", tonumber(data and data.displayValue or 0) or 0)
        end,
    },
    ISNCBladderStatusIndicator = {
        resolver = function(indicator)
            return getNaturesCallBadAssociation(indicator, "NCBladder", "IGUI_ModernStatus_NCBladder", "NC Bladder", "bladder", "bladderFill")
        end,
    },
    ISNCBowelStatusIndicator = {
        resolver = function(indicator)
            return getNaturesCallBadAssociation(indicator, "NCBowel", "IGUI_ModernStatus_NCBowel", "NC Bowel", "bowel", "bowelFill")
        end,
    },
    ISNCGutHealthStatusIndicator = {
        resolver = getNaturesCallGutAssociation,
    },
    ISNCSoilageStatusIndicator = {
        resolver = function(indicator)
            return getNaturesCallBadAssociation(indicator, "NCSoilage", "IGUI_ModernStatus_NCSoilage", "NC Soilage", "soilage")
        end,
    },
    ISNCDigestiveEffectsStatusIndicator = {
        resolver = getNaturesCallDigestiveAssociation,
        valueFormatter = function(indicator)
            if not indicator or not indicator.player or indicator.player:isDead() then
                return nil
            end
            local data = MS_NaturesCallCompat and MS_NaturesCallCompat.getDigestiveEffectsData and MS_NaturesCallCompat.getDigestiveEffectsData(indicator.player) or nil
            if not data or (tonumber(data.activeCount) or 0) <= 0 or not data.primary then
                return nil
            end
            local primary = data.primary
            local label = tr(primary.labelKey, primary.fallback)
            local value = tonumber(primary.value) or 0
            local suffix = ""
            if primary.valueType == "hours" then
                suffix = string.format(" %.1f h", value)
            elseif primary.valueType == "strain" then
                suffix = string.format(" %.2f", value)
            elseif primary.valueType == "intensity" then
                suffix = string.format(" %.1f", value)
            end
            local text = tostring(label or "") .. suffix
            local extra = (tonumber(data.activeCount) or 1) - 1
            if extra > 0 then
                text = text .. " +" .. tostring(extra)
            end
            return text
        end,
    },
    ISBathroomNeedStatusIndicator = {
        resolver = function(indicator)
            local value = 0
            if indicator and indicator.getValue then
                value = tonumber(indicator:getValue()) or 0
            end
            local level = getThresholdLevel(value, "gte", { 0.30, 0.60, 0.80, 0.90 })
            if level <= 0 then return nil end
            return {
                prefix = "BladderNeed",
                label = tr("IGUI_ModernStatus_BathroomNeed", "Bladder Need"),
                level = level,
                titleKey = string.format("Moodles_BladderNeed_L%d", level),
                descriptionKey = string.format("Moodles_BladderNeed_L%d_desc", level),
            }
        end,
    },
    ISHygieneNeedStatusIndicator = {
        resolver = function(indicator)
            local value = 0
            if indicator and indicator.getValue then
                value = tonumber(indicator:getValue()) or 0
            end
            if value < 0.10 then
                return { prefix = "HygieneGood", label = tr("IGUI_ModernStatus_HygieneNeed", "Hygiene Need"), level = 4, titleKey = "Moodles_HygieneGood_L4", descriptionKey = "Moodles_HygieneGood_L4_desc" }
            elseif value < 0.20 then
                return { prefix = "HygieneGood", label = tr("IGUI_ModernStatus_HygieneNeed", "Hygiene Need"), level = 3, titleKey = "Moodles_HygieneGood_L3", descriptionKey = "Moodles_HygieneGood_L3_desc" }
            elseif value < 0.30 then
                return { prefix = "HygieneGood", label = tr("IGUI_ModernStatus_HygieneNeed", "Hygiene Need"), level = 2, titleKey = "Moodles_HygieneGood_L2", descriptionKey = "Moodles_HygieneGood_L2_desc" }
            elseif value < 0.40 then
                return { prefix = "HygieneGood", label = tr("IGUI_ModernStatus_HygieneNeed", "Hygiene Need"), level = 1, titleKey = "Moodles_HygieneGood_L1", descriptionKey = "Moodles_HygieneGood_L1_desc" }
            elseif value > 0.90 then
                return { prefix = "HygieneBad", label = tr("IGUI_ModernStatus_HygieneNeed", "Hygiene Need"), level = 4, titleKey = "Moodles_HygieneBad_L4", descriptionKey = "Moodles_HygieneBad_L4_desc" }
            elseif value > 0.80 then
                return { prefix = "HygieneBad", label = tr("IGUI_ModernStatus_HygieneNeed", "Hygiene Need"), level = 3, titleKey = "Moodles_HygieneBad_L3", descriptionKey = "Moodles_HygieneBad_L3_desc" }
            elseif value > 0.70 then
                return { prefix = "HygieneBad", label = tr("IGUI_ModernStatus_HygieneNeed", "Hygiene Need"), level = 2, titleKey = "Moodles_HygieneBad_L2", descriptionKey = "Moodles_HygieneBad_L2_desc" }
            elseif value > 0.60 then
                return { prefix = "HygieneBad", label = tr("IGUI_ModernStatus_HygieneNeed", "Hygiene Need"), level = 1, titleKey = "Moodles_HygieneBad_L1", descriptionKey = "Moodles_HygieneBad_L1_desc" }
            end
            return nil
        end,
    },
    ISTABASBodyGrimeStatusIndicator = {
        resolver = function(indicator)
            local value = 0
            if indicator and indicator.getValue then
                value = tonumber(indicator:getValue()) or 0
            end
            local level = getThresholdLevel(value, "gte", { 0.45, 0.60, 0.75, 0.90 })
            if level <= 0 then return nil end
            return {
                prefix = "BodyGrime",
                label = tr("IGUI_ModernStatus_BodyGrime", "Body Grime"),
                level = level,
                titleKey = string.format("Moodles_BodyGrime_Bad_lvl%d", level),
                descriptionKey = string.format("Moodles_BodyGrime_Bad_desc_lvl%d", level),
            }
        end,
    },
    ISWORCombinedStatusIndicator = {
        resolver = function(indicator)
            local value = 0
            if indicator and indicator.getValue then
                value = tonumber(indicator:getValue()) or 0
            end
            local level = getThresholdLevel(value, "gte", { 0.50, 0.60, 0.75, 0.90 })
            if level <= 0 then return nil end
            return {
                prefix = "worgrimed_Bad",
                label = tr("IGUI_ModernStatus_WORGrime", "Grime"),
                level = level,
                titleKey = string.format("Moodles_worgrimed_Bad_lvl%d", level),
                descriptionKey = string.format("Moodles_worgrimed_Bad_desc_lvl%d", level),
            }
        end,
    },
    MS_CarryWeightIndicator = {
        prefix = "HeavyLoad",
        compare = "gte",
        thresholds = { 1.00, 1.25, 1.50, 1.75 },
        valueProvider = function(indicator)
            if not indicator or not indicator.getCarryWeightData then
                return 0
            end
            local data = indicator:getCarryWeightData()
            return tonumber(data and data.loadRatio or 0) or 0
        end,
        valueFormatter = function(indicator)
            if not indicator or not indicator.getCarryWeightData then
                return nil
            end
            local data = indicator:getCarryWeightData()
            local weight = tonumber(data and data.weight or 0) or 0
            local capacity = tonumber(data and data.capacity or 0) or 0
            local loadRatio = tonumber(data and data.loadRatio or 0) or 0
            return string.format("%.1f / %.1f (%s)", weight, capacity, formatPercentValue(loadRatio))
        end,
    },
    ISExcStomachStatusIndicator = {
        resolver = function(indicator)
            local resolved = getExcrementumStomachAssociation(indicator)
            if not resolved then return nil end
            if (tonumber(resolved.level) or 0) > 0 and resolved.branch then
                resolved.titleKey = string.format("Moodles_%s_%s_lvl%d", resolved.prefix, resolved.branch, resolved.level)
                resolved.descriptionKey = string.format("Moodles_%s_%s_desc_lvl%d", resolved.prefix, resolved.branch, resolved.level)
            end
            return resolved
        end,
    },
    ISExcColonStatusIndicator = {
        resolver = function(indicator)
            local resolved = getExcrementumColonAssociation(indicator)
            if not resolved then return nil end
            if (tonumber(resolved.level) or 0) > 0 and resolved.branch then
                resolved.titleKey = string.format("Moodles_%s_%s_lvl%d", resolved.prefix, resolved.branch, resolved.level)
                resolved.descriptionKey = string.format("Moodles_%s_%s_desc_lvl%d", resolved.prefix, resolved.branch, resolved.level)
            end
            return resolved
        end,
    },
    ISExcUrineStatusIndicator = {
        resolver = function(indicator)
            local resolved = getExcrementumUrineAssociation(indicator)
            if not resolved then return nil end
            if (tonumber(resolved.level) or 0) > 0 and resolved.branch then
                resolved.titleKey = string.format("Moodles_%s_%s_lvl%d", resolved.prefix, resolved.branch, resolved.level)
                resolved.descriptionKey = string.format("Moodles_%s_%s_desc_lvl%d", resolved.prefix, resolved.branch, resolved.level)
            end
            return resolved
        end,
    },
    ISExcSmellStatusIndicator = {
        resolver = function(indicator)
            local resolved = getExcrementumSmellAssociation(indicator)
            if not resolved then return nil end
            if (tonumber(resolved.level) or 0) > 0 and resolved.branch then
                resolved.titleKey = string.format("Moodles_%s_%s_lvl%d", resolved.prefix, resolved.branch, resolved.level)
                resolved.descriptionKey = string.format("Moodles_%s_%s_desc_lvl%d", resolved.prefix, resolved.branch, resolved.level)
            end
            return resolved
        end,
    },
    ISExcShameStatusIndicator = {
        resolver = function(indicator)
            local resolved = getExcrementumShameAssociation(indicator)
            if not resolved then return nil end
            if (tonumber(resolved.level) or 0) > 0 and resolved.branch then
                resolved.titleKey = string.format("Moodles_%s_%s_lvl%d", resolved.prefix, resolved.branch, resolved.level)
                resolved.descriptionKey = string.format("Moodles_%s_%s_desc_lvl%d", resolved.prefix, resolved.branch, resolved.level)
            end
            return resolved
        end,
    },
}

-- Action hints mirror the quick double-click helpers that already exist for supported indicators.
local ACTION_HINTS = {
    MS_ThirstIndicator = "IGUI_ModernStatus_DoubleClickDrink",
    MS_HungerIndicator = "IGUI_ModernStatus_DoubleClickEat",
    MS_EnduranceIndicator = "IGUI_ModernStatus_DoubleClickSitOnGround",
    MS_FatigueIndicator = "IGUI_ModernStatus_DoubleClickTakeVitaminsTeaCoffee",
    MS_PanicIndicator = "IGUI_ModernStatus_DoubleClickTakeBetaBlockersAlcohol",
    MS_HealthIndicator = "IGUI_ModernStatus_DoubleClickApplyBandage",
    MS_BleedingIndicator = "IGUI_ModernStatus_DoubleClickApplyBandage",
    MS_SicknessIndicator = "IGUI_ModernStatus_DoubleClickTakeAntibiotics",
    MS_PainIndicator = "IGUI_ModernStatus_DoubleClickTakePainkillersAlcohol",
    MS_CalorieIndicator = "IGUI_ModernStatus_DoubleClickEat",
    MS_ProteinsIndicator = "IGUI_ModernStatus_DoubleClickEat",
    MS_LipidsIndicator = "IGUI_ModernStatus_DoubleClickEat",
    MS_UnhappynessIndicator = "IGUI_ModernStatus_DoubleClickTakeAntidepressants",
}

-- These entries drive the per-medicine detail block shown in tooltips for the rotating medicine indicator.
local MEDICINE_DETAIL_ENTRIES = {
    { titleKey = "IGUI_ModernStatus_Painkillers", getter = function(player) return player and player.getPainEffect and player:getPainEffect() or 0 end, maxValue = 5400, showApproxDuration = true },
    { titleKey = "IGUI_ModernStatus_Antibiotics", getter = function(player) return player and player.getReduceInfectionPower and player:getReduceInfectionPower() or 0 end, maxValue = 50, showApproxDuration = false },
    { titleKey = "IGUI_ModernStatus_BetaBlockers", getter = function(player) return player and player.getBetaEffect and player:getBetaEffect() or 0 end, maxValue = 6600, showApproxDuration = true },
    { titleKey = "IGUI_ModernStatus_Antidepressants", getter = function(player) return player and player.getDepressEffect and player:getDepressEffect() or 0 end, maxValue = 6600, showApproxDuration = true },
    { titleKey = "IGUI_ModernStatus_SleepingTablets", getter = function(player) return player and player.getSleepingTabletEffect and player:getSleepingTabletEffect() or 0 end, maxValue = 6600, showApproxDuration = true },
}

function DATA.normalizeIndicatorType(indicatorType)
    return TYPE_ALIASES[indicatorType] or indicatorType
end

getThresholdLevel = function(value, compare, thresholds)
    local numericValue = tonumber(value)
    if not numericValue then
        return 0
    end

    for level = 4, 1, -1 do
        local threshold = thresholds and thresholds[level]
        if threshold ~= nil then
            if compare == "lte" and numericValue <= threshold then
                return level
            end
            if compare == "gte" and numericValue >= threshold then
                return level
            end
        end
    end

    return 0
end

local function formatTooltipText(text)
    if not text or text == "" then
        return nil
    end

    local formatted = tostring(text)
    formatted = formatted:gsub("<br%s*/?>", " <LINE> ")
    formatted = formatted:gsub("\r\n", " <LINE> ")
    formatted = formatted:gsub("\n", " <LINE> ")
    formatted = formatted:gsub("\r", " <LINE> ")
    formatted = formatted:gsub("%s*<LINE>%s*", " <LINE> ")

    return formatted
end

resolveTranslatedText = function(key)
    if not key then
        return nil
    end

    local text = getText(key)
    if not text or text == key or text == "" then
        return nil
    end

    return formatTooltipText(text)
end

function DATA.isRawValueType(indicatorType)
    return RAW_VALUE_TYPES[DATA.normalizeIndicatorType(indicatorType)] == true
end


local function formatThresholdNumber(value)
    local numericValue = tonumber(value)
    if numericValue == nil then
        return tostring(value)
    end

    if math.abs(numericValue - math.floor(numericValue + 0.000001)) < 0.000001 then
        return tostring(math.floor(numericValue + 0.000001))
    end

    local formatted = string.format("%.2f", numericValue)
    formatted = formatted:gsub("(%..-)0+$", "%1")
    formatted = formatted:gsub("%.$", "")
    return formatted
end

function DATA.getTooltipThresholdLine(indicator, currentValue)
    if not indicator or not MS_IndicatorVisualConfig or not MS_IndicatorVisualConfig.getThresholds then
        return nil
    end

    local normalizedType = DATA.normalizeIndicatorType(indicator:getType())

    if normalizedType == "ISExcStomachStatusIndicator" and indicator.getExcrementumStomachData then
        local data = indicator:getExcrementumStomachData()
        if data and data.stomachLimit and data.stomachLimit > 0 then
            return joinThresholdParts({
                tr("IGUI_ModernStatus_FullShort", "Full") .. " " .. formatPercentValue((tonumber(data.minimumThreshold) or 0) / data.stomachLimit),
                tr("IGUI_ModernStatus_StrongFullShort", "Strong full") .. " " .. formatPercentValue((tonumber(data.mediumThreshold) or 0) / data.stomachLimit),
                tr("IGUI_ModernStatus_OverfilledShort", "Overfilled") .. " 100%",
            })
        end
    elseif normalizedType == "ISExcColonStatusIndicator" and indicator.getExcrementumColonData then
        local data = indicator:getExcrementumColonData()
        if data then
            -- In normal Excrementum conditions, defecation becomes available around 80%.
            -- The dynamic runtime threshold can move later, so the tooltip keeps the normal reference clear.
            return joinThresholdParts({
                tr("IGUI_ModernStatus_CanDefecateShort", "Can defecate") .. " 80%",
                tr("IGUI_ModernStatus_PanicHordeShort", "Panic+horde") .. " " .. formatPercentValue(tonumber(data.panicHordeThreshold) or 0),
                tr("IGUI_ModernStatus_PainShort", "Pain") .. " 4 h " .. tr("IGUI_ModernStatus_AfterUrgeShort", "after urge"),
            })
        end
    elseif normalizedType == "ISExcUrineStatusIndicator" and indicator.getExcrementumUrineData then
        local data = indicator:getExcrementumUrineData()
        if data then
            return joinThresholdParts({
                tr("IGUI_ModernStatus_CanUrinateShort", "Can urinate") .. " " .. formatPercentValue(tonumber(data.thresholdGood1) or 0),
                tr("IGUI_ModernStatus_DiscomfortShort", "Discomfort") .. " " .. formatPercentValue(tonumber(data.thresholdBad1) or 0),
                tr("IGUI_ModernStatus_PainShort", "Pain") .. " " .. formatPercentValue(tonumber(data.thresholdBad3) or 0),
                tr("IGUI_ModernStatus_HighAccidentShort", "High accident") .. " " .. formatPercentValue(tonumber(data.thresholdBad4) or 0),
            })
        end
    elseif normalizedType == "MS_CarryWeightIndicator" then
        -- HeavyLoad moodle thresholds are based on current load / max carry.
        return joinThresholdParts({ "50%", "100%", "125%", "150%", "175%" })
    elseif normalizedType == "ISNCBladderStatusIndicator" or normalizedType == "ISNCBowelStatusIndicator" then
        return joinThresholdParts({
            tr("IGUI_ModernStatus_NCWarningShort", "Warning") .. " 70",
            tr("IGUI_ModernStatus_NCCriticalShort", "Critical") .. " 95",
            tr("IGUI_ModernStatus_NCOverflowShort", "Overflow") .. " 100+",
        })
    elseif normalizedType == "ISNCGutHealthStatusIndicator" then
        return tr("IGUI_ModernStatus_KeyThresholds", "Thresholds") .. ": " .. table.concat({ "3", "5 (" .. tr("IGUI_ModernStatus_Normal", "Normal") .. ")", "7" }, ", ")
    elseif normalizedType == "ISNCSoilageStatusIndicator" then
        return tr("IGUI_ModernStatus_KeyThresholds", "Thresholds") .. ": " .. table.concat({ "25", "50", "75", "90" }, ", ")
    elseif normalizedType == "ISNCDigestiveEffectsStatusIndicator" then
        -- NC Effects is a composite active-effects indicator. It has no meaningful
        -- fixed thresholds because each effect can use a different source unit.
        return nil
    elseif normalizedType == "ISBathroomNeedStatusIndicator" then
        return tr("IGUI_ModernStatus_KeyThresholds", "Thresholds") .. ": " .. table.concat({ "30", "60", "80", "90" }, ", ")
    elseif normalizedType == "ISHygieneNeedStatusIndicator" then
        return tr("IGUI_ModernStatus_KeyThresholds", "Thresholds") .. ": " .. table.concat({
            "10",
            "20",
            "30",
            "40",
            "50 (" .. tr("IGUI_ModernStatus_Normal", "Normal") .. ")",
            "60",
            "70",
            "80",
            "90",
        }, ", ")
    elseif normalizedType == "ISTABASBodyGrimeStatusIndicator" then
        return tr("IGUI_ModernStatus_KeyThresholds", "Thresholds") .. ": " .. table.concat({ "45", "60", "75", "90" }, ", ")
    elseif normalizedType == "ISWORCombinedStatusIndicator" then
		return tr("IGUI_ModernStatus_KeyThresholds", "Thresholds") .. ": " .. table.concat({ "50", "60", "75", "90" }, ", ")
    end

    local thresholds = MS_IndicatorVisualConfig.getThresholds(indicator, currentValue)
    if not thresholds or #thresholds == 0 then
        return nil
    end

    local minValue, maxValue = MS_IndicatorVisualConfig.getVisualRange(indicator, currentValue)
    local usesPercentDomain = (tonumber(minValue) or 0) >= 0 and (tonumber(maxValue) or 0) <= 1.0001

    local values = {}
    for _, threshold in ipairs(thresholds) do
        local thresholdValue = threshold and threshold.value
        if normalizedType == "MS_CarryWeightIndicator" then
            values[#values + 1] = formatThresholdNumber((tonumber(thresholdValue) or 0) * 100)
        elseif usesPercentDomain then
            values[#values + 1] = formatThresholdNumber((tonumber(thresholdValue) or 0) * 100)
        else
            values[#values + 1] = formatThresholdNumber(thresholdValue)
        end
    end

    if #values == 0 then
        return nil
    end

    return tr("IGUI_ModernStatus_ThresholdsLabel", "Thresholds") .. ": " .. table.concat(values, ", ")
end

function DATA.getTooltipValueText(indicator, currentValue)
    if not indicator then
        return nil
    end

    local normalizedType = DATA.normalizeIndicatorType(indicator:getType())
    local association = MOODLE_ASSOCIATIONS[normalizedType]
    if association and association.valueFormatter then
        local formatted = association.valueFormatter(indicator, currentValue)
        if formatted and formatted ~= "" then
            return formatted
        end
    end

    local hoverText = indicator.getHoverText and indicator:getHoverText(currentValue) or nil
    if hoverText == nil then
        return nil
    end

    hoverText = tostring(hoverText)
    if hoverText == "" then
        return nil
    end

    if DATA.isRawValueType(normalizedType) then
        if normalizedType == "ISExcStomachStatusIndicator" or normalizedType == "ISExcColonStatusIndicator" or normalizedType == "ISExcUrineStatusIndicator" then
            return hoverText .. "%"
        end
        return hoverText
    end

    return hoverText .. "%"
end

function DATA.getTooltipValueLine(indicator, currentValue)
    local valueText = DATA.getTooltipValueText(indicator, currentValue)
    if not valueText or valueText == "" then
        return nil
    end

    return tr("IGUI_ModernStatus_ValueLabel", "Value") .. ": " .. valueText
end

local function getTooltipBaseName(indicator)
    if not indicator then
        return nil
    end

    local normalizedType = DATA.normalizeIndicatorType(indicator:getType())
    local displayName = indicator.getIndicatorDisplayName and indicator:getIndicatorDisplayName() or indicator:getType()
    displayName = tostring(displayName or indicator:getType() or "")
    displayName = displayName:gsub("%*$", "")

    if normalizedType == "MS_StressIndicator" then
        displayName = tr("IGUI_ModernStatus_TooltipEffectiveStress", "Effective Stress")
    elseif normalizedType == "MS_SicknessIndicator" then
        displayName = tr("IGUI_ModernStatus_TooltipEffectiveSickness", "Effective Sickness")
    end

    if indicator.isDisplayDirectionInverted and indicator:isDisplayDirectionInverted() then
        displayName = displayName .. "*"
    end

    return displayName
end

function DATA.getTooltipTitle(indicator, currentValue, settings)
    local showName = (not settings) or settings.showName ~= false
    local showValue = (not settings) or settings.showValue ~= false

    local nameText = showName and getTooltipBaseName(indicator) or nil
    local valueText = showValue and DATA.getTooltipValueText(indicator, currentValue) or nil

    if nameText and valueText and nameText ~= "" and valueText ~= "" then
        return nameText .. ": " .. valueText
    end

    if nameText and nameText ~= "" then
        return nameText
    end

    if valueText and valueText ~= "" then
        return valueText
    end

    return nil
end

function DATA.getAssociatedMoodle(indicator, currentValue)
    if not indicator then
        return nil
    end

    local normalizedType = DATA.normalizeIndicatorType(indicator:getType())
    local association = MOODLE_ASSOCIATIONS[normalizedType]
    if not association then
        return nil
    end

    local titleKey = nil
    local descriptionKey = nil

    if association.resolver then
        local resolved = association.resolver(indicator, currentValue)
        if not resolved then
            return nil
        end

        if resolved.title or resolved.description then
            return {
                title = resolved.title,
                description = resolved.description,
            }
        elseif resolved.titleKey or resolved.descriptionKey then
            titleKey = resolved.titleKey
            descriptionKey = resolved.descriptionKey
        elseif resolved.prefix and resolved.level and resolved.level > 0 then
            titleKey = string.format("Moodles_%s_lvl%d", resolved.prefix, resolved.level)
            descriptionKey = string.format("Moodles_%s_desc_lvl%d", resolved.prefix, resolved.level)
        else
            return nil
        end
    else
        local moodleValue = currentValue
        if association.valueProvider then
            moodleValue = association.valueProvider(indicator, currentValue)
        end

        local level = getThresholdLevel(moodleValue, association.compare, association.thresholds)
        if level <= 0 then
            return nil
        end

        titleKey = string.format("Moodles_%s_lvl%d", association.prefix, level)
        descriptionKey = string.format("Moodles_%s_desc_lvl%d", association.prefix, level)
    end

    local title = resolveTranslatedText(titleKey)
    local description = resolveTranslatedText(descriptionKey)

    if not title and not description then
        return nil
    end

    return {
        title = title,
        description = description,
    }
end

formatPercentValue = function(value)
    return formatThresholdNumber((tonumber(value) or 0) * 100) .. "%"
end
formatBooleanLabel = function(value)
    if value then
        return tr("IGUI_ModernStatus_Yes", "Yes")
    end
    return tr("IGUI_ModernStatus_No", "No")
end

joinThresholdParts = function(parts)
    local filtered = {}
    for _, part in ipairs(parts or {}) do
        if part and part ~= "" then
            filtered[#filtered + 1] = part
        end
    end
    if #filtered == 0 then
        return nil
    end
    return tr("IGUI_ModernStatus_KeyThresholds", "Thresholds") .. ": " .. table.concat(filtered, " - ")
end

formatUrgeText = function(hasUrge, urgeMinutes)
    if not hasUrge then
        return tr("IGUI_ModernStatus_Off", "Off")
    end

    local minutes = math.max(0, math.floor((tonumber(urgeMinutes) or 0) + 0.5))
    local template = tr("IGUI_ModernStatus_OnMinutes", "On (%d min)")
    if template and string.find(template, "%%d", 1, true) then
        return (string.gsub(template, "%%d", tostring(minutes), 1))
    end
    return (template or "On") .. " (" .. tostring(minutes) .. " min)"
end

local function formatApproxDurationFromTicks(ticks)
    local seconds = math.max(0, math.floor(((tonumber(ticks) or 0) / 30) + 0.5))
    local minutes = math.floor(seconds / 60)
    local remainingSeconds = seconds % 60

    local function zeroPad(value)
        value = math.max(0, math.floor(tonumber(value) or 0))
        if value < 10 then
            return "0" .. tostring(value)
        end
        return tostring(value)
    end

    -- Build the approximate duration manually instead of relying on translated
    -- printf-style placeholders. Kahlua and MP can surface malformed
    -- placeholder variants like %1m or %02$s that are not safe to format.
    local minuteUnit = tr("IGUI_ModernStatus_MinuteShort", "m")
    local secondUnit = tr("IGUI_ModernStatus_SecondShort", "s")
    if minutes > 0 then
        return "~" .. tostring(minutes) .. minuteUnit .. " " .. zeroPad(remainingSeconds) .. secondUnit
    end

    return "~" .. tostring(remainingSeconds) .. secondUnit
end

local function formatApproxDurationFromSeconds(secondsValue)
    local seconds = math.max(0, math.floor((tonumber(secondsValue) or 0) + 0.5))
    local minutes = math.floor(seconds / 60)
    local remainingSeconds = seconds % 60
    local minuteUnit = tr("IGUI_ModernStatus_MinuteShort", "m")
    local secondUnit = tr("IGUI_ModernStatus_SecondShort", "s")
    if minutes > 0 then
        local rem = remainingSeconds < 10 and ("0" .. tostring(remainingSeconds)) or tostring(remainingSeconds)
        return "~" .. tostring(minutes) .. minuteUnit .. " " .. rem .. secondUnit
    end
    return "~" .. tostring(remainingSeconds) .. secondUnit
end

local function getAssociationState(indicator, currentValue)
    if not indicator then
        return nil
    end

    local normalizedType = DATA.normalizeIndicatorType(indicator:getType())
    local association = MOODLE_ASSOCIATIONS[normalizedType]
    if not association then
        return nil
    end

    if association.resolver then
        local resolved = association.resolver(indicator, currentValue)
        if not resolved then
            return nil
        end

        return {
            prefix = resolved.prefix,
            label = resolved.label,
            level = tonumber(resolved.level) or 0,
            titleKey = resolved.titleKey,
            descriptionKey = resolved.descriptionKey,
        }
    end

    local moodleValue = currentValue
    if association.valueProvider then
        moodleValue = association.valueProvider(indicator, currentValue)
    end

    return {
        prefix = association.prefix,
        level = getThresholdLevel(moodleValue, association.compare, association.thresholds),
    }
end

local function getTemperatureMoodleLevels(indicator)
    if not indicator or not indicator.player or indicator.player:isDead() then
        return nil
    end

    local stats = indicator.player.getStats and indicator.player:getStats() or nil
    local actualTemp = stats and stats.get and stats:get(CharacterStat.TEMPERATURE) or nil
    if actualTemp == nil and indicator.player.getBodyDamage and indicator.player:getBodyDamage() and indicator.player:getBodyDamage().getTemperature then
        actualTemp = indicator.player:getBodyDamage():getTemperature()
    end
    if actualTemp == nil then
        return nil
    end

    local hotLevel = getThresholdLevel(actualTemp, "gte", { 37.5, 39.0, 40.0, 41.0 })
    local coldLevel = getThresholdLevel(actualTemp, "lte", { 36.5, 35.0, 30.0, 25.0 })

    return {
        actualTemp = actualTemp,
        hotLevel = hotLevel,
        coldLevel = coldLevel,
    }
end

getExcrementumStomachAssociation = function(indicator)
    if not indicator or not indicator.getExcrementumStomachData then
        return nil
    end

    local data = indicator:getExcrementumStomachData()
    if not data then
        return nil
    end

    local liquids = tonumber(data.liquids) or 0
    local solids = tonumber(data.solids) or 0
    local useWaterBranch = liquids > 0 and (solids / liquids) < 0.29
    local prefix = useWaterBranch and "exc-stomach-w" or "exc-stomach"
    local green = tonumber(data.greenStomach) or 0

    if green > 0 then
        return {
            prefix = prefix,
            label = prefix .. " Good",
            branch = "Good",
            level = math.max(1, math.min(4, math.floor(green + 0.0001))),
        }
    elseif green < 0 then
        local pain = math.abs(green)
        local level = 1
        if pain >= 90 then
            level = 4
        elseif pain >= 60 then
            level = 3
        elseif pain >= 30 then
            level = 2
        end
        return {
            prefix = prefix,
            label = prefix .. " Bad",
            branch = "Bad",
            level = level,
        }
    end

    return {
        prefix = prefix,
        label = prefix,
        level = 0,
    }
end

getExcrementumColonAssociation = function(indicator)
    if not indicator or not indicator.getExcrementumColonData then
        return nil
    end

    local data = indicator:getExcrementumColonData()
    if not data then
        return nil
    end

    local level = tonumber(data.moodleLevel) or 0
    local branch = data.moodleBranch
    if level <= 0 or not branch then
        return { prefix = "exc-colon", label = "exc-colon", level = 0 }
    end

    return {
        prefix = "exc-colon",
        label = "exc-colon " .. branch,
        branch = branch,
        level = level,
    }
end

getExcrementumUrineAssociation = function(indicator)
    if not indicator or not indicator.getExcrementumUrineData then
        return nil
    end

    local data = indicator:getExcrementumUrineData()
    if not data then
        return nil
    end

    local level = tonumber(data.moodleLevel) or 0
    local branch = data.moodleBranch
    if level <= 0 or not branch then
        return { prefix = "exc-urine", label = "exc-urine", level = 0 }
    end

    return {
        prefix = "exc-urine",
        label = "exc-urine " .. branch,
        branch = branch,
        level = level,
    }
end

getExcrementumSmellAssociation = function(indicator)
    if not indicator or not indicator.getExcrementumSmellData then
        return nil
    end

    local data = indicator:getExcrementumSmellData()
    if not data then
        return nil
    end

    local level = tonumber(data.moodleLevel) or 0
    if level <= 0 then
        return { prefix = "exc-smell", label = "exc-smell", level = 0 }
    end

    return {
        prefix = "exc-smell",
        label = "exc-smell Bad",
        branch = "Bad",
        level = level,
    }
end

getExcrementumShameAssociation = function(indicator)
    if not indicator or not indicator.getExcrementumShameData then
        return nil
    end

    local data = indicator:getExcrementumShameData()
    if not data then
        return nil
    end

    local level = tonumber(data.moodleLevel) or 0
    local branch = data.moodleBranch
    if level <= 0 or not branch then
        return { prefix = "exc-shame", label = "exc-shame", level = 0 }
    end

    return {
        prefix = "exc-shame",
        label = "exc-shame " .. branch,
        branch = branch,
        level = level,
    }
end

local function colorizeCondition(ok, label)
    if ok then
        return RGB_G .. label .. RGB_W
    end

    return RGB_R .. label .. RGB_W
end

local function makeConditionDetailRow(ok, label, value)
    return {
        label = tostring(label or "") .. ":",
        value = tostring(value or ""),
        valueColor = ok and DETAIL_CONDITION_OK_COLOR or DETAIL_CONDITION_BAD_COLOR,
    }
end

local function escapeLuaPattern(text)
    return tostring(text or ""):gsub("([^%w])", "%%%1")
end

local function makeConditionLabel(key, fallback, conditionText)
    local label = tr(key, fallback)
    local condition = tostring(conditionText or "")
    if condition == "" then
        return label
    end

    local pattern = "%s*" .. escapeLuaPattern(condition) .. "%s*$"
    local replaced, count = label:gsub(pattern, " (" .. condition .. ")", 1)
    if count and count > 0 then
        return replaced
    end

    return label .. " (" .. condition .. ")"
end

local function colorizeDetailedLinePrefix(line)
    if not line or line == "" then
        return line
    end

    local text = tostring(line)
    if string.find(text, "<RGB:", 1, true) then
        return text
    end

    local bullet, subLabel, subRest = string.match(text, "^(-%s*)([^:]+)(:.*)$")
    if bullet and subLabel and subRest then
        return bullet .. RGB_DETAIL_SUBLABEL .. subLabel .. RGB_W .. subRest
    end

    local label, rest = string.match(text, "^([^:]+)(:.*)$")
    if label and rest then
        return RGB_DETAIL_LABEL .. label .. RGB_W .. rest
    end

    return text
end

local function colorizeDetailedLines(lines)
    if not lines then
        return nil
    end

    local colored = {}
    for _, line in ipairs(lines) do
        if type(line) == "table" then
            colored[#colored + 1] = line
        else
            colored[#colored + 1] = colorizeDetailedLinePrefix(line)
        end
    end

    return colored
end


local function trimText(value)
    local text = tostring(value or "")
    text = text:gsub("^%s+", "")
    text = text:gsub("%s+$", "")
    return text
end

local function normalizeDetailedInfoPercentText(value)
    -- Some translated detail strings can arrive with escaped percent signs; display them as literal percent signs.
    return tostring(value or ""):gsub("%%%%", "%%")
end

local function makePlainDetailRow(text)
    -- Narrative detail notes widen the tooltip if needed, but opt out of the
    -- aligned label/value columns used by numeric rows.
    return {
        label = normalizeDetailedInfoPercentText(text),
        value = nil,
        leftOnly = true,
        note = true,
    }
end

local function stripTooltipRichTags(value)
    local text = tostring(value or "")
    text = text:gsub("%s*<RGB:[^>]+>%s*", " ")
    text = text:gsub("%s*<PUSHRGB:[^>]+>%s*", " ")
    text = text:gsub("%s*<POPRGB>%s*", " ")
    text = text:gsub("%s+", " ")
    return trimText(text)
end

local function findLastColonPosition(text)
    local lastColon = nil
    local startIndex = 1
    while true do
        local position = string.find(text, ":", startIndex, true)
        if not position then
            break
        end
        lastColon = position
        startIndex = position + 1
    end
    return lastColon
end

local function buildDetailedInfoRows(lines)
    if not lines then
        return nil
    end

    local rows = {}
    local doubleClickPrefix = tr("IGUI_ModernStatus_DoubleClickPrefix", "Double-click")

    for _, line in ipairs(lines) do
        if type(line) == "table" then
            -- Structured rows are used when a label contains inline color spans
            -- or when the row must opt out of right-aligned value rendering.
            if line.label or line.value or line.labelSegments or line.leftOnly then
                rows[#rows + 1] = line
            end
        else
            local rawLine = normalizeDetailedInfoPercentText(line)
            local plainLine = stripTooltipRichTags(rawLine)
            if plainLine ~= "" then
                local colonPosition = findLastColonPosition(plainLine)
                local isSubLine = string.find(plainLine, "^%-%s*") ~= nil
                local isDanger = string.find(rawLine, RGB_R, 1, true) ~= nil
                if colonPosition and colonPosition < #plainLine then
                    local label = trimText(string.sub(plainLine, 1, colonPosition))
                    local value = trimText(string.sub(plainLine, colonPosition + 1))
                    if label == doubleClickPrefix .. ":" then
                        rows[#rows + 1] = {
                            label = label,
                            value = value,
                            sub = isSubLine,
                            danger = isDanger,
                            leftOnly = true,
                        }
                    else
                        rows[#rows + 1] = {
                            label = label,
                            value = value,
                            sub = isSubLine,
                            danger = isDanger,
                        }
                    end
                else
                    rows[#rows + 1] = {
                        label = plainLine,
                        value = nil,
                        sub = isSubLine,
                        danger = isDanger,
                    }
                end
            end
        end
    end

    return #rows > 0 and rows or nil
end

local function formatPercentValue100(value)
    return formatThresholdNumber(tonumber(value) or 0) .. "%"
end

local function getStressDetailedLines(indicator)
    if not indicator or not indicator.player or indicator.player:isDead() or not indicator.player.getStats then
        return nil
    end

    local stats = indicator.player:getStats()
    if not stats or not stats.get then
        return nil
    end

    local pureStress = tonumber(stats:get(CharacterStat.STRESS)) or 0
    local nicotineWithdrawal = tonumber(stats:get(CharacterStat.NICOTINE_WITHDRAWAL)) or 0

    return {
        tr("IGUI_ModernStatus_Stress", "Stress") .. ": " .. formatPercentValue(pureStress),
        tr("IGUI_ModernStatus_StressFromNicotineWithdrawal", "Stress from Nicotine Withdrawal") .. ": " .. formatPercentValue(nicotineWithdrawal),
    }
end

local function getBoredomDetailedLines(indicator)
    if not indicator or not indicator.player or indicator.player:isDead() or not indicator.player.getStats then
        return nil
    end

    local stats = indicator.player:getStats()
    if not stats or not stats.get then
        return nil
    end

    local idleness = tonumber(stats:get(CharacterStat.IDLENESS)) or 0
    return {
        tr("IGUI_ModernStatus_Idleness", "Idleness") .. ": " .. formatPercentValue(idleness),
    }
end

local function getSicknessDetailedLines(indicator)
    if not indicator or not indicator.player or indicator.player:isDead() or not indicator.player.getStats then
        return nil
    end

    local stats = indicator.player:getStats()
    if not stats or not stats.get then
        return nil
    end

    local sickness = tonumber(stats:get(CharacterStat.SICKNESS)) or 0
    local foodSickness = tonumber(stats:get(CharacterStat.FOOD_SICKNESS)) or 0

    return {
        tr("IGUI_ModernStatus_Sickness", "Sickness") .. ": " .. formatPercentValue(sickness),
        tr("IGUI_ModernStatus_FoodSickness", "Food Sickness") .. ": " .. formatPercentValue100(foodSickness),
    }
end

local function getBleedingDetailedLines(indicator)
    if not indicator or not indicator.getBleedingData then
        return nil
    end

    local data = indicator:getBleedingData()
    if not data then
        return nil
    end

    return {
        tr("IGUI_ModernStatus_BleedingLevel", "Bleeding level") .. ": " .. tostring(tonumber(data.level) or 0) .. " / 4",
        tr("IGUI_ModernStatus_BleedingBodyParts", "Bleeding body parts") .. ": " .. tostring(tonumber(data.bleedingParts) or 0),
        tr("IGUI_ModernStatus_NeckBleeding", "Neck bleeding") .. ": " .. formatBooleanLabel(data.neckBleeding == true),
    }
end

local function getExcrementumStomachDetailedLines(indicator)
    if not indicator or not indicator.getExcrementumStomachData then
        return nil
    end

    local data = indicator:getExcrementumStomachData()
    if not data or not data.stomachLimit or data.stomachLimit <= 0 then
        return nil
    end

    local foodRatio = (tonumber(data.solids) or 0) / data.stomachLimit
    local waterRatio = (tonumber(data.liquids) or 0) / data.stomachLimit
    local pain = tonumber(data.stomachPain) or 0
    local liquids = tonumber(data.liquids) or 0
    local solids = tonumber(data.solids) or 0
    local isWaterBranch = liquids > 0 and (solids / liquids) < 0.29

    local lines = {
        tr("IGUI_ModernStatus_StomachFood", "Stomach Food") .. ": " .. formatPercentValue(foodRatio),
        tr("IGUI_ModernStatus_StomachWater", "Stomach Water") .. ": " .. formatPercentValue(waterRatio),
    }

    if isWaterBranch then
        table.insert(lines, 1, tr("IGUI_ModernStatus_StomachBranch", "Stomach branch") .. ": " .. tr("IGUI_ModernStatus_WaterBranch", "Water branch"))
    end

    if pain > 0 then
        lines[#lines + 1] = tr("IGUI_ModernStatus_StomachPain", "Stomach Pain") .. ": " .. formatThresholdNumber(pain)
    end

    return lines
end

local function getExcrementumColonDetailedLines(indicator)
    if not indicator or not indicator.getExcrementumColonData then
        return nil
    end

    local data = indicator:getExcrementumColonData()
    if not data then
        return nil
    end

    return {
        tr("IGUI_ModernStatus_Fill", "Fill") .. ": " .. formatPercentValue(tonumber(data.fill) or 0),
        tr("IGUI_ModernStatus_DynamicThreshold", "Dynamic threshold") .. ": " .. formatPercentValue(tonumber(data.threshold) or 0),
        tr("IGUI_ModernStatus_Viscosity", "Viscosity") .. ": " .. formatThresholdNumber(tonumber(data.viscosity) or 0),
        tr("IGUI_ModernStatus_DefecationUrge", "Defecation urge") .. ": " .. formatUrgeText(data.hasUrge, data.urgeMinutes),
        tr("IGUI_ModernStatus_ColonPain", "Colon Pain") .. ": " .. formatThresholdNumber(tonumber(data.colonPain) or 0),
        tr("IGUI_ModernStatus_SmallIntestineContent", "Small intestine content") .. ": " .. formatBooleanLabel((tonumber(data.intestineCount) or 0) > 0),
        tr("IGUI_ModernStatus_DiarrheaRisk", "Diarrhea risk") .. ": " .. tr("IGUI_ModernStatus_DiarrheaRisk" .. tostring(data.diarrheaRisk or "Off"), tostring(data.diarrheaRisk or "Off")),
    }
end

local function getExcrementumUrineDetailedLines(indicator)
    if not indicator or not indicator.getExcrementumUrineData then
        return nil
    end

    local data = indicator:getExcrementumUrineData()
    if not data then
        return nil
    end

    return {
        tr("IGUI_ModernStatus_CanUrinateLabel", "Can urinate") .. ": " .. formatBooleanLabel(data.canUrinate),
        tr("IGUI_ModernStatus_ThresholdOffsetFromStressPanic", "Threshold offset from stress/panic") .. ": " .. formatThresholdNumber(tonumber(data.ms) or 0),
        tr("IGUI_ModernStatus_UrinePain", "Urine Pain") .. ": " .. formatThresholdNumber(tonumber(data.urinePain) or 0),
    }
end

local function getExcrementumSmellDetailedLines(indicator)
    if not indicator or not indicator.getExcrementumSmellData then
        return nil
    end

    local data = indicator:getExcrementumSmellData()
    if not data then
        return nil
    end

    return {
        tr("IGUI_ModernStatus_SmellTotal", "Smell total") .. ": " .. formatThresholdNumber(tonumber(data.smell) or 0),
        tr("IGUI_ModernStatus_RoomSmell", "Room smell") .. ": " .. formatThresholdNumber(tonumber(data.roomSmell) or 0),
        tr("IGUI_ModernStatus_BodySmell", "Body smell") .. ": " .. formatThresholdNumber(tonumber(data.bodySmell) or 0),
        tr("IGUI_ModernStatus_SoiledClothing", "Soiled clothing") .. ": " .. tostring(tonumber(data.soiledClothing) or 0),
        tr("IGUI_ModernStatus_SoiledWipingItems", "Soiled wiping items") .. ": " .. tostring(tonumber(data.soiledWipingItems) or 0),
        tr("IGUI_ModernStatus_FecesCarried", "Feces carried") .. ": " .. tostring(tonumber(data.fecesCarried) or 0),
        tr("IGUI_ModernStatus_DirtySeats", "Dirty seats") .. ": " .. tostring(tonumber(data.dirtySeats) or 0),
    }
end

local function getExcrementumShameDetailedLines(indicator)
    if not indicator or not indicator.getExcrementumShameData then
        return nil
    end

    local data = indicator:getExcrementumShameData()
    if not data then
        return nil
    end

    local rawShame = tonumber(data.rawShame) or 0
    local lines = {
        tr("IGUI_ModernStatus_RawShame", "Raw shame") .. ": " .. formatThresholdNumber(rawShame),
    }

    if rawShame < 0 then
        lines[#lines + 1] = tr("IGUI_ModernStatus_PositiveCuriosityBranch", "Positive curiosity branch") .. ": " .. tr("IGUI_ModernStatus_on", "(On)")
    elseif rawShame > 0 then
        lines[#lines + 1] = tr("IGUI_ModernStatus_PositiveCuriosityBranch", "Positive curiosity branch") .. ": " .. tr("IGUI_ModernStatus_Off", "Off")
    end

    return lines
end

local function hasCharacterTrait(player, traitName)
    -- Safely checks Build 42 CharacterTrait enum entries without breaking older branches.
    local trait = CharacterTrait and CharacterTrait[traitName]
    if not player or not player.hasTrait or not trait then
        return false
    end
    return player:hasTrait(trait)
end

local function getLifestyleModData(indicator)
    -- Returns Lifestyle Hobbies player modData only when the hovered indicator is valid.
    if not indicator or not indicator.player or indicator.player:isDead() then
        return nil
    end
    if not indicator.player.getModData then
        return nil
    end
    return indicator.player:getModData()
end

local function getLifestyleMoodleValue(modData, moodleName)
    -- Lifestyle Hobbies stores custom moodles in player:getModData().LSMoodles.
    local lsMoodles = modData and modData.LSMoodles
    local moodle = lsMoodles and lsMoodles[moodleName]
    return tonumber(moodle and moodle.Value or 0) or 0
end

local function formatSignedRate(value)
    -- Formats stat deltas with an explicit sign so buffs/debuffs are unambiguous.
    local numericValue = tonumber(value) or 0
    local sign = numericValue > 0 and "+" or ""
    return sign .. formatThresholdNumber(numericValue)
end

local function addCurrentEffectRateLines(lines, stressRate, unhappinessRate)
    -- Adds current per-minute stress and unhappiness deltas produced by LS effects.
    if stressRate and math.abs(stressRate) > 0.000001 then
        lines[#lines + 1] = tr("IGUI_ModernStatus_Stress", "Stress") .. "/min: " .. formatSignedRate(stressRate)
    end
    if unhappinessRate and math.abs(unhappinessRate) > 0.000001 then
        lines[#lines + 1] = tr("IGUI_ModernStatus_Unhappyness", "Unhappiness") .. "/min: " .. formatSignedRate(unhappinessRate)
    end
end

local function getPanicMoodleLevel(player)
    -- Reads the vanilla Panic moodle level defensively for panic-driven bladder accidents.
    if not player or not player.getMoodles or not MoodleType or not MoodleType.PANIC then
        return 0
    end
    local moodles = player:getMoodles()
    if not moodles or not moodles.getMoodleLevel then
        return 0
    end
    local ok, level = pcall(function() return moodles:getMoodleLevel(MoodleType.PANIC) end)
    if not ok then
        return 0
    end
    return tonumber(level) or 0
end

local function getHygieneEffectRates(player, modData, hygieneNeed)
    -- Mirrors Lifestyle Hobbies hygiene moodle math closely enough for tooltip display.
    local stressBase, unhappinessBase = 0.005, 0.015
    local isAsleep = player and player.isAsleep and player:isAsleep()
    if isAsleep then
        return 0, 0, tr("IGUI_ModernStatus_PausedWhileSleeping", "paused while sleeping")
    end

    if hygieneNeed < 40 then
        local multiplierValue = 1
        if hygieneNeed < 10 then
            multiplierValue = 3
        elseif hygieneNeed < 20 then
            multiplierValue = 2
        end

        local traitMultiplier = 1
        if hasCharacterTrait(player, "SLOPPY") then
            traitMultiplier = 0.5
        elseif hasCharacterTrait(player, "CLEANFREAK") then
            traitMultiplier = 1.5
        end
        if hygieneNeed >= 30 and traitMultiplier > 1 then
            traitMultiplier = 1
        end

        return -(stressBase * multiplierValue * traitMultiplier), -(unhappinessBase * multiplierValue * traitMultiplier), "HygieneGood"
    elseif hygieneNeed > 60 then
        local hygieneBadValue, multiplierValue = 0.2, 0.5
        if hygieneNeed > 90 then
            hygieneBadValue, multiplierValue = 0.8, 3
        elseif hygieneNeed > 80 then
            hygieneBadValue, multiplierValue = 0.6, 2
        elseif hygieneNeed > 70 then
            hygieneBadValue, multiplierValue = 0.4, 1
        end

        if getLifestyleMoodleValue(modData, "SmellGood") > 0 then
            if hygieneBadValue == 0.8 then
                hygieneBadValue = 0.4
            elseif hygieneBadValue == 0.6 then
                hygieneBadValue = 0.2
            else
                hygieneBadValue = 0
            end
        end

        if hygieneBadValue <= 0 then
            return 0, 0, tr("IGUI_ModernStatus_SuppressedBySmellGood", "suppressed by SmellGood")
        end

        local traitMultiplierNeg = 1
        if hasCharacterTrait(player, "SLOPPY") then
            traitMultiplierNeg = 0.5
        elseif hasCharacterTrait(player, "CLEANFREAK") then
            traitMultiplierNeg = 2
        end
        if hygieneNeed <= 70 and traitMultiplierNeg > 1 then
            traitMultiplierNeg = 1
        end

        return stressBase * multiplierValue * traitMultiplierNeg, unhappinessBase * multiplierValue * traitMultiplierNeg, "HygieneBad"
    end

    return 0, 0, tr("IGUI_ModernStatus_NoActiveLifestyleEffect", "none now")
end


local function getLifestyleMoodleLevel(modData, moodleName)
    -- Mirrors Lifestyle Hobbies custom moodle level thresholds for display-only rows.
    local value = getLifestyleMoodleValue(modData, moodleName)
    if value >= 0.70 then
        return 4
    elseif value >= 0.50 then
        return 3
    elseif value >= 0.30 then
        return 2
    elseif value >= 0.15 then
        return 1
    end
    return 0
end

local function getLifestyleMoodleDisplayName(modData, moodleName)
    -- Prefer Lifestyle Hobbies translated moodle titles when the source is active.
    local level = getLifestyleMoodleLevel(modData, moodleName)
    local candidates = { level, 1 }
    for _, candidateLevel in ipairs(candidates) do
        if candidateLevel and candidateLevel > 0 then
            local key = "Moodles_" .. tostring(moodleName) .. "_L" .. tostring(candidateLevel)
            local label = tr(key, nil)
            if label and label ~= key and label ~= "" then
                return stripTooltipRichTags(label)
            end
        end
    end
    return tostring(moodleName or "?")
end

local function getPlayerPerkLevelSafe(player, perk)
    -- Reads a perk level defensively because compatibility files can be loaded across B42 branches.
    if not player or not player.getPerkLevel or not perk then
        return 0
    end
    local ok, level = pcall(function() return player:getPerkLevel(perk) end)
    if not ok then
        return 0
    end
    return tonumber(level) or 0
end

local function getLifestyleMoodTraitMultipliers(player)
    -- Mirrors Lifestyle Hobbies multipliers for comfort, music/DJ, party and Eureka mood effects.
    local multipliers = { comfort = 1, music = 1, party = 1, eureka = 1 }
    local traitEffects = {
        DISCIPLINED = { comfort = -0.5, music = -0.2, party = -0.2 },
        COUCHPOTATO = { comfort = 1, party = -0.5 },
        VIRTUOSO = { music = 1 },
        TONEDEAF = { music = 2 },
        HARD_OF_HEARING = { music = -0.3 },
        PARTYANIMAL = { party = 1 },
        KILLJOY = { party = 1 },
        INVENTIVE = { eureka = 1 },
    }

    for traitName, effects in pairs(traitEffects) do
        if hasCharacterTrait(player, traitName) then
            for key, delta in pairs(effects) do
                multipliers[key] = math.max(0.1, (multipliers[key] or 1) + delta)
            end
        end
    end

    return multipliers
end

local function getLifestyleGeneralMoodleEffectValues(player, modData, moodleName)
    -- Mirrors Lifestyle Hobbies LSEffects/LSPerMinute moodle contribution table.
    local moodleValue = getLifestyleMoodleValue(modData, moodleName)
    if moodleValue <= 0 then
        return 0, 0, 0, 0
    end

    local mult = getLifestyleMoodTraitMultipliers(player)
    local comfortMult = mult.comfort or 1
    local musicMult = mult.music or 1
    local partyMult = mult.party or 1
    local eurekaMult = mult.eureka or 1
    local stress, unhappiness, boredom, discomfort = 0, 0, 0, 0

    if moodleName == "Comfort" then
        if moodleValue >= 0.80 then
            stress, unhappiness = -0.03 * comfortMult, -1.6 * comfortMult
        elseif moodleValue >= 0.60 then
            stress, unhappiness = -0.02 * comfortMult, -1.0 * comfortMult
        elseif moodleValue >= 0.40 then
            stress, unhappiness = -0.01 * comfortMult, -0.7 * comfortMult
        elseif moodleValue >= 0.20 then
            stress, unhappiness = -0.005 * comfortMult, -0.4 * comfortMult
        end
    elseif moodleName == "Uncomfortable" then
        if moodleValue >= 0.20 then
            stress, unhappiness = 0.001 * comfortMult, 0.01 * comfortMult
        end
    elseif moodleName == "PartyGood" then
        if moodleValue >= 0.60 then
            stress, unhappiness, boredom = -0.03 * partyMult, -2 * partyMult, -2 * partyMult
        elseif moodleValue >= 0.40 then
            stress, unhappiness, boredom = -0.01 * partyMult, -1 * partyMult, -1 * partyMult
        elseif moodleValue >= 0.20 then
            stress, unhappiness, boredom = -0.003 * partyMult, -0.5 * partyMult, -1 * partyMult
        end
    elseif moodleName == "PartyBad" then
        if moodleValue >= 0.60 then
            stress, unhappiness, boredom = 0.05 * partyMult, 3 * partyMult, 3 * partyMult
        elseif moodleValue >= 0.40 then
            stress, unhappiness, boredom = 0.03 * partyMult, 1.5 * partyMult, 2 * partyMult
        elseif moodleValue >= 0.20 then
            stress, unhappiness, boredom = 0.01 * partyMult, 0.7 * partyMult, 1 * partyMult
        end
    elseif moodleName == "MusicGood" then
        if hasCharacterTrait(player, "DEAF") then
            return 0, 0, 0, 0
        end
        if moodleValue >= 0.40 then
            stress, unhappiness, boredom = -0.02 * musicMult, -1 * musicMult, -1 * musicMult
        elseif moodleValue >= 0.20 then
            stress, unhappiness, boredom = -0.002 * musicMult, -0.5 * musicMult, -0.5 * musicMult
        end
    elseif moodleName == "MusicBad" then
        if hasCharacterTrait(player, "DEAF") then
            return 0, 0, 0, 0
        end
        if moodleValue >= 0.40 then
            stress, unhappiness, boredom = 0.05 * musicMult, 2 * musicMult, 2 * musicMult
        elseif moodleValue >= 0.20 then
            stress, unhappiness, boredom = 0.005 * musicMult, 0.7 * musicMult, 1 * musicMult
        end
    elseif moodleName == "DJAudience" then
        if moodleValue >= 0.60 then
            stress, unhappiness, boredom = -0.03 * musicMult, -2 * musicMult, -2 * musicMult
        elseif moodleValue >= 0.40 then
            stress, unhappiness, boredom = -0.01 * musicMult, -1 * musicMult, -1 * musicMult
        elseif moodleValue >= 0.20 then
            stress, unhappiness, boredom = -0.003 * musicMult, -0.5 * musicMult, -1 * musicMult
        end
    elseif moodleName == "MintFresh" or moodleName == "MintCurio" then
        if moodleValue >= 0.20 then
            stress, unhappiness = -0.003, -0.2
        end
    elseif moodleName == "BathHot" then
        if moodleValue >= 0.20 then
            stress, unhappiness = -0.005, -0.5
        end
    elseif moodleName == "BathCold" then
        if moodleValue >= 0.20 then
            stress, unhappiness = 0.007, 0.6
        end
    elseif moodleName == "Gloomy" then
        if moodleValue >= 0.80 then
            unhappiness = 3
        elseif moodleValue >= 0.60 then
            unhappiness = 1.5
        elseif moodleValue >= 0.40 then
            unhappiness = 1
        elseif moodleValue >= 0.20 then
            unhappiness = 0.5
        end
    elseif moodleName == "Eureka" then
        if moodleValue >= 0.80 then
            unhappiness = -3 * eurekaMult
        elseif moodleValue >= 0.60 then
            unhappiness = -2 * eurekaMult
        elseif moodleValue >= 0.40 then
            unhappiness = -1 * eurekaMult
        elseif moodleValue >= 0.20 then
            unhappiness = -0.5 * eurekaMult
        end
    end

    local comfortNoImpact = SandboxVars and SandboxVars.LSComfort and SandboxVars.LSComfort.ComfortNoImpact
    if (moodleName == "Comfort" or moodleName == "Uncomfortable") and not comfortNoImpact then
        discomfort = unhappiness
    end

    return stress, unhappiness, boredom, discomfort
end

local function getLifestyleBathroomEffectValues(player, modData)
    -- Mirrors the current BladderNeed impact used by Lifestyle Hobbies for tooltip display.
    local bathroomNeed = tonumber(modData and modData.bathroomNeed or 0) or 0
    local traitMultiplier, traitMultiplierPain = 1, 1
    if hasCharacterTrait(player, "IRON_GUT") then
        traitMultiplierPain = 0.5
    elseif hasCharacterTrait(player, "WEAK_STOMACH") then
        traitMultiplier = 1.5
        traitMultiplierPain = 1.5
    end

    if bathroomNeed >= 90 then
        return 0.015 * traitMultiplier, 0.4 * traitMultiplier, 20 * traitMultiplierPain, 90
    elseif bathroomNeed >= 80 then
        return 0.005 * traitMultiplier, 0.2 * traitMultiplier, 10 * traitMultiplierPain, 60
    elseif bathroomNeed >= 60 then
        return 0.002 * traitMultiplier, 0, 5 * traitMultiplierPain, 25
    end

    return 0, 0, nil, nil
end

local function getLifestyleBeautyEffectValues(player, modData)
    -- Estimates the active BeautyGood/BeautyNeg per-minute mood contribution from the current LS moodle value.
    local function getBeautyBaseValues()
        local strength = SandboxVars and SandboxVars.LSArt and SandboxVars.LSArt.BeautyNeedStrength or 3
        local tableByStrength = {
            [1] = { stress = 0.00015, unhappiness = 0.0005 },
            [2] = { stress = 0.0005, unhappiness = 0.0015 },
            [3] = { stress = 0.0015, unhappiness = 0.005 },
            [4] = { stress = 0.003, unhappiness = 0.01 },
            [5] = { stress = 0.006, unhappiness = 0.02 },
        }
        return tableByStrength[tonumber(strength) or 3] or tableByStrength[3]
    end

    local function getMultiplier(value)
        if value >= 0.70 then
            return 3
        elseif value >= 0.50 then
            return 2
        elseif value >= 0.30 then
            return 1
        elseif value >= 0.15 then
            return 0.5
        end
        return 0
    end

    local base = getBeautyBaseValues()
    local goodMultiplier = getMultiplier(getLifestyleMoodleValue(modData, "BeautyGood"))
    if goodMultiplier > 0 then
        return "BeautyGood", -(base.stress * goodMultiplier), -(base.unhappiness * goodMultiplier)
    end

    local badMultiplier = getMultiplier(getLifestyleMoodleValue(modData, "BeautyNeg"))
    if badMultiplier > 0 then
        return "BeautyNeg", base.stress * badMultiplier, base.unhappiness * badMultiplier
    end

    return nil, 0, 0
end

local function getLifestyleMindfulnessEffectValues(player, modData)
    -- Mirrors Lifestyle Hobbies mindfulness reductions for stress and pain as closely as possible.
    local mindfulValue = getLifestyleMoodleValue(modData, "MindfulState")
    if mindfulValue <= 0 then
        return 0, 0
    end

    local effectMultiplier = 1
    local effectSO = SandboxVars and SandboxVars.Meditation and SandboxVars.Meditation.EffectMultiplier or 2
    if effectSO == 1 then
        effectMultiplier = 0.5
    elseif effectSO == 3 then
        effectMultiplier = 2
    end

    local level = getPlayerPerkLevelSafe(player, Perks and Perks.Meditation)
    local disciplined = hasCharacterTrait(player, "DISCIPLINED")
    local baseEffect = 1
    local tableByLevel = {
        [10] = disciplined and 3.5 or 3,
        [9] = disciplined and 3.2 or 2.8,
        [8] = disciplined and 3.0 or 2.4,
        [7] = disciplined and 2.6 or 2,
        [6] = disciplined and 2.2 or 1.8,
        [5] = disciplined and 2.0 or 1.6,
        [4] = disciplined and 1.8 or 1.4,
        [3] = disciplined and 1.2 or 1,
    }
    if tableByLevel[level] then
        baseEffect = tableByLevel[level]
    end

    local strength = baseEffect * effectMultiplier
    local stress = mindfulValue >= 0.20 and (-0.05 * strength) or 0
    local pain = mindfulValue >= 0.40 and (-5 * strength) or 0
    return stress, pain
end

local function makeLifestyleRateRow(modData, moodleName, value, unit)
    local numericValue = tonumber(value) or 0
    return {
        label = getLifestyleMoodleDisplayName(modData, moodleName) .. ":",
        value = formatSignedRate(numericValue) .. tostring(unit or "/min"),
        valueColor = numericValue <= 0 and DETAIL_CONDITION_OK_COLOR or DETAIL_CONDITION_BAD_COLOR,
    }
end

local function addLifestyleRateRow(rows, modData, moodleName, value, unit)
    if value and math.abs(value) > 0.000001 then
        rows[#rows + 1] = makeLifestyleRateRow(modData, moodleName, value, unit)
    end
end

local function addLifestyleInstantRow(rows, modData, moodleName, value)
    if value and math.abs(value) > 0.000001 and getLifestyleMoodleValue(modData, moodleName) > 0 then
        rows[#rows + 1] = {
            label = getLifestyleMoodleDisplayName(modData, moodleName) .. ":",
            value = formatSignedRate(value) .. " " .. tr("IGUI_ModernStatus_Instant", "instant"),
            valueColor = value <= 0 and DETAIL_CONDITION_OK_COLOR or DETAIL_CONDITION_BAD_COLOR,
        }
    end
end

local function appendLifestyleActiveEffectsHeader(lines, rows)
    if rows and #rows > 0 then
        lines[#lines + 1] = makePlainDetailRow(tr("IGUI_ModernStatus_LifestyleActiveEffects", "Lifestyle active effects"))
        for _, row in ipairs(rows) do
            lines[#lines + 1] = row
        end
    end
end

local function getLifestyleStatEffectRows(indicator, statKey)
    local modData = getLifestyleModData(indicator)
    if not modData or type(modData.LSMoodles) ~= "table" then
        return nil
    end

    local player = indicator.player
    local rows = {}

    if statKey == "Stress" or statKey == "Unhappiness" then
        local hygieneNeed = tonumber(modData.hygieneNeed or 0) or 0
        local stressRate, unhappinessRate, source = getHygieneEffectRates(player, modData, hygieneNeed)
        local hygieneSource = (source == "HygieneGood" or source == "HygieneBad") and source or nil
        if hygieneSource then
            addLifestyleRateRow(rows, modData, hygieneSource, statKey == "Stress" and stressRate or unhappinessRate, "/min")
        end

        local bathroomStress, bathroomUnhappiness = getLifestyleBathroomEffectValues(player, modData)
        addLifestyleRateRow(rows, modData, "BladderNeed", statKey == "Stress" and bathroomStress or bathroomUnhappiness, "/min")

        local sources = { "BathHot", "BathCold", "MusicGood", "MusicBad", "DJAudience", "PartyGood", "PartyBad", "Comfort", "Uncomfortable" }
        if statKey == "Unhappiness" then
            sources[#sources + 1] = "Eureka"
            sources[#sources + 1] = "Gloomy"
            sources[#sources + 1] = "MintFresh"
            sources[#sources + 1] = "MintCurio"
        end
        for _, moodleName in ipairs(sources) do
            local stress, unhappiness = getLifestyleGeneralMoodleEffectValues(player, modData, moodleName)
            addLifestyleRateRow(rows, modData, moodleName, statKey == "Stress" and stress or unhappiness, "/min")
        end

        if statKey == "Stress" then
            local mindfulStress = getLifestyleMindfulnessEffectValues(player, modData)
            addLifestyleRateRow(rows, modData, "MindfulState", mindfulStress, "/min")

            local homeSickLevel = getLifestyleMoodleLevel(modData, "HomeSick")
            if homeSickLevel > 0 then
                addLifestyleRateRow(rows, modData, "HomeSick", 0.04 * homeSickLevel, "/10min")
            end
        end

        local beautySource, beautyStress, beautyUnhappiness = getLifestyleBeautyEffectValues(player, modData)
        if beautySource then
            addLifestyleRateRow(rows, modData, beautySource, statKey == "Stress" and beautyStress or beautyUnhappiness, "/min")
        end

        if statKey == "Unhappiness" then
            addLifestyleInstantRow(rows, modData, "FTGood", -80)
            addLifestyleInstantRow(rows, modData, "FTBad", 80)
        end
    elseif statKey == "Boredom" then
        local sources = { "MusicGood", "MusicBad", "DJAudience", "PartyGood", "PartyBad" }
        for _, moodleName in ipairs(sources) do
            local _, _, boredom = getLifestyleGeneralMoodleEffectValues(player, modData, moodleName)
            addLifestyleRateRow(rows, modData, moodleName, boredom, "/min")
        end

        if getLifestyleMoodleLevel(modData, "AtHouse") > 0 then
            addLifestyleRateRow(rows, modData, "AtHouse", -0.6, "/10min")
        end
    elseif statKey == "Discomfort" then
        for _, moodleName in ipairs({ "Comfort", "Uncomfortable" }) do
            local _, _, _, discomfort = getLifestyleGeneralMoodleEffectValues(player, modData, moodleName)
            addLifestyleRateRow(rows, modData, moodleName, discomfort, "/min")
        end

        local _, _, painChange, painCap = getLifestyleBathroomEffectValues(player, modData)
        if painChange and painCap then
            rows[#rows + 1] = {
                label = getLifestyleMoodleDisplayName(modData, "BladderNeed") .. " " .. tr("IGUI_ModernStatus_Pain", "Pain") .. ":",
                value = "+" .. formatThresholdNumber(painChange) .. " (" .. tr("IGUI_ModernStatus_Max", "max.") .. " " .. formatThresholdNumber(painCap) .. ")",
                valueColor = DETAIL_CONDITION_BAD_COLOR,
            }
        end
    elseif statKey == "Pain" then
        local _, _, painChange, painCap = getLifestyleBathroomEffectValues(player, modData)
        if painChange and painCap then
            rows[#rows + 1] = {
                label = getLifestyleMoodleDisplayName(modData, "BladderNeed") .. " " .. tr("IGUI_ModernStatus_LowerTorsoPain", "Lower torso pain") .. ":",
                value = "+" .. formatThresholdNumber(painChange) .. " (" .. tr("IGUI_ModernStatus_Max", "max.") .. " " .. formatThresholdNumber(painCap) .. ")",
                valueColor = DETAIL_CONDITION_BAD_COLOR,
            }
        end

        local _, mindfulPain = getLifestyleMindfulnessEffectValues(player, modData)
        addLifestyleRateRow(rows, modData, "MindfulState", mindfulPain, "/min")
    elseif statKey == "Cold" then
        local hygieneNeed = tonumber(modData.hygieneNeed or 0) or 0
        if hygieneNeed > 80 or getLifestyleMoodleValue(modData, "HygieneBad") >= 0.50 then
            rows[#rows + 1] = {
                label = getLifestyleMoodleDisplayName(modData, "HygieneBad") .. ":",
                value = tr("IGUI_ModernStatus_LifestyleColdRollsIfEnabled", "cold rolls if enabled"),
                valueColor = DETAIL_CONDITION_BAD_COLOR,
            }
        end
    elseif statKey == "Sickness" then
        if getLifestyleMoodleValue(modData, "MintCurio") >= 0.40 then
            rows[#rows + 1] = {
                label = getLifestyleMoodleDisplayName(modData, "MintCurio") .. ":",
                value = tr("IGUI_ModernStatus_LifestylePossibleIntoxication", "possible Intoxication roll"),
                valueColor = DETAIL_CONDITION_BAD_COLOR,
            }
        end
    end

    return #rows > 0 and rows or nil
end

local function appendLifestyleStatEffectLines(lines, indicator, statKey)
    appendLifestyleActiveEffectsHeader(lines, getLifestyleStatEffectRows(indicator, statKey))
end

local function getLifestyleBathroomDetailedLines(indicator)
    local modData = getLifestyleModData(indicator)
    if not modData then
        return nil
    end

    local player = indicator.player
    local bathroomNeed = tonumber(modData.bathroomNeed or 0) or 0
    -- The main tooltip already shows the indicator name, current value and associated moodle level.
    local lines = {}

    local traitMultiplier, traitMultiplierPain = 1, 1
    if hasCharacterTrait(player, "IRON_GUT") then
        traitMultiplierPain = 0.5
    elseif hasCharacterTrait(player, "WEAK_STOMACH") then
        traitMultiplier = 1.5
        traitMultiplierPain = 1.5
    end

    local stressRate, unhappinessRate = 0, 0
    local painChange, painCap = nil, nil
    if bathroomNeed >= 90 then
        stressRate = 0.015 * traitMultiplier
        unhappinessRate = 0.4 * traitMultiplier
        painChange = 20 * traitMultiplierPain
        painCap = 90
    elseif bathroomNeed >= 80 then
        stressRate = 0.005 * traitMultiplier
        unhappinessRate = 0.2 * traitMultiplier
        painChange = 10 * traitMultiplierPain
        painCap = 60
    elseif bathroomNeed >= 60 then
        stressRate = 0.002 * traitMultiplier
        painChange = 5 * traitMultiplierPain
        painCap = 25
    end

    addCurrentEffectRateLines(lines, stressRate, unhappinessRate)
    if painChange and painCap then
        lines[#lines + 1] = {
            label = tr("IGUI_ModernStatus_LowerTorsoPain", "Lower torso pain") .. " (" .. tr("IGUI_ModernStatus_Max", "max.") .. " " .. formatThresholdNumber(painCap) .. "):",
            value = "+" .. formatThresholdNumber(painChange),
        }
    end
    if stressRate == 0 and unhappinessRate == 0 and not painChange then
        lines[#lines + 1] = tr("IGUI_ModernStatus_CurrentEffects", "Current effects") .. ": " .. tr("IGUI_ModernStatus_NoActiveLifestyleEffect", "none now")
    end

    local panicLevel = getPanicMoodleLevel(player)
    local braveOrDesensitized = hasCharacterTrait(player, "BRAVE") or hasCharacterTrait(player, "DESENSITIZED")
    local currentAccidentRisk = nil
    if bathroomNeed >= 100 then
        currentAccidentRisk = "10%/min"
    elseif bathroomNeed >= 80 and panicLevel > 2 and not braveOrDesensitized then
        currentAccidentRisk = "30%/min"
    elseif bathroomNeed >= 60 and panicLevel > 2 and hasCharacterTrait(player, "COWARDLY") then
        currentAccidentRisk = "40%/min"
    end
    if currentAccidentRisk then
        lines[#lines + 1] = tr("IGUI_ModernStatus_CurrentAccidentRisk", "Current accident risk") .. ": " .. currentAccidentRisk
    end

    lines[#lines + 1] = makePlainDetailRow(tr("IGUI_ModernStatus_AccidentRisk", "Accident risk") .. ": " .. tr("IGUI_ModernStatus_BladderAccidentRule", "10%/min at 100%; panic may trigger from 80% (60% if Cowardly); Brave/Desensitized block panic accidents"))
    lines[#lines + 1] = makePlainDetailRow(tr("IGUI_ModernStatus_Sleep", "Sleep") .. ": " .. tr("IGUI_ModernStatus_BladderWakeRule", "maximum level can wake the character"))

    return lines
end

local function getLifestyleHygieneDetailedLines(indicator)
    local modData = getLifestyleModData(indicator)
    if not modData then
        return nil
    end

    local player = indicator.player
    local hygieneNeed = tonumber(modData.hygieneNeed or 0) or 0
    -- The main tooltip already shows the indicator name, current value and associated moodle level.
    -- Detailed Info stays focused on mechanics and active effects.
    local lines = {}

    local stressRate, unhappinessRate, effectState = getHygieneEffectRates(player, modData, hygieneNeed)
    addCurrentEffectRateLines(lines, stressRate, unhappinessRate)
    if math.abs(stressRate) <= 0.000001 and math.abs(unhappinessRate) <= 0.000001 then
        lines[#lines + 1] = tr("IGUI_ModernStatus_CurrentEffects", "Current effects") .. ": " .. tostring(effectState or tr("IGUI_ModernStatus_NoActiveLifestyleEffect", "none now"))
    end

    lines[#lines + 1] = makePlainDetailRow(tr("IGUI_ModernStatus_HygieneGoodSummary", "Good Hygiene (<40%): reduces Stress and Unhappiness"))
    lines[#lines + 1] = makePlainDetailRow(tr("IGUI_ModernStatus_HygieneBadSummary", "Bad Hygiene (>60%): increases Stress and Unhappiness"))
    lines[#lines + 1] = makePlainDetailRow(tr("IGUI_ModernStatus_HygieneRiskSummary", "Strong Bad Hygiene (>80%): cold rolls may occur if enabled; may give nearby players a bad-smell reaction"))
    lines[#lines + 1] = makePlainDetailRow(tr("IGUI_ModernStatus_HygieneWashingNote", "Showers/baths reduce this directly; vanilla Wash > Yourself only removes dirt/blood, letting bad hygiene recover toward 50"))
    lines[#lines + 1] = makePlainDetailRow(tr("IGUI_ModernStatus_SmellGoodNote", "Good Smell can lower or suppress visible Bad Hygiene, but penalties depend on underlying hygiene"))

    return lines
end

local function getTABASBodyGrimeDetailedLines(indicator)
    -- The main tooltip value already shows the TABAS body grime percentage.
    -- Associated moodle data is handled by the standard moodle section.
    return nil
end

local function getWORCombinedDetailedLines(indicator)
    if not indicator or not indicator.player or indicator.player:isDead() then
        return nil
    end

    local modData = indicator.player.getModData and indicator.player:getModData() or nil
    local dirtiness = tonumber(modData and modData.WORbodyDirtiness or 0) or 0
    local bloodiness = tonumber(modData and modData.WORbodyBloodiness or 0) or 0
    local peak = math.max(dirtiness, bloodiness)

    return {
        tr("IGUI_ModernStatus_Dirtiness", "Dirtiness") .. ": " .. formatPercentValue100(dirtiness),
        tr("IGUI_ModernStatus_Bloodiness", "Bloodiness") .. ": " .. formatPercentValue100(bloodiness),
        tr("IGUI_ModernStatus_WORGrime", "Grime") .. ": " .. formatPercentValue100(peak),
    }
end

local function getCatchAColdDetailedLines(indicator, currentValue)
    if not indicator or not indicator.player or indicator.player:isDead() then
        return nil
    end

    local associationState = getAssociationState(indicator, currentValue)
    if not associationState or (tonumber(associationState.level) or 0) <= 0 then
        return nil
    end

    local player = indicator.player
    local stats = player.getStats and player:getStats() or nil
    local bodyDamage = player.getBodyDamage and player:getBodyDamage() or nil
    local lines = {}

    local isInterior = player.getCurrentBuilding and player:getCurrentBuilding() ~= nil
    lines[#lines + 1] = makeConditionDetailRow(isInterior, tr("IGUI_ModernStatus_Interior", "Interior"), formatBooleanLabel(isInterior))

    local hungerValue = MS_PlayerStatus and MS_PlayerStatus.Get and MS_PlayerStatus.Get.Hunger and MS_PlayerStatus.Get.Hunger(player) or 0
    local hungerLevel = getThresholdLevel(hungerValue, "lte", { 0.85, 0.75, 0.55, 0.30 })
    lines[#lines + 1] = makeConditionDetailRow(
        hungerLevel < 2,
        makeConditionLabel("IGUI_ModernStatus_HungerMoodleLevelLt2", "Hunger moodle level < 2", "< 2"),
        tostring(hungerLevel)
    )

    local thirstValue = MS_PlayerStatus and MS_PlayerStatus.Get and MS_PlayerStatus.Get.Thirst and MS_PlayerStatus.Get.Thirst and MS_PlayerStatus.Get.Thirst(player) or 0
    local thirstLevel = getThresholdLevel(thirstValue, "lte", { 0.88, 0.75, 0.30, 0.16 })
    lines[#lines + 1] = makeConditionDetailRow(
        thirstLevel < 2,
        makeConditionLabel("IGUI_ModernStatus_ThirstMoodleLevelLt2", "Thirst moodle level < 2", "< 2"),
        tostring(thirstLevel)
    )

    local fatigue = stats and stats.get and (tonumber(stats:get(CharacterStat.FATIGUE)) or 0) or 0
    lines[#lines + 1] = makeConditionDetailRow(
        fatigue <= 0.5,
        makeConditionLabel("IGUI_ModernStatus_FatigueLe50", "Fatigue <= 50%", "<= 50%"),
        formatPercentValue(fatigue)
    )

    -- Show moodle-style checks in the same order as the core survival indicators.
    local wetness = bodyDamage and bodyDamage.getWetness and (tonumber(bodyDamage:getWetness()) or 0) or 0
    local wetnessLevel = getThresholdLevel(wetness / 100, "gte", { 0.15, 0.40, 0.70, 0.90 })
    lines[#lines + 1] = makeConditionDetailRow(
        wetnessLevel < 1,
        makeConditionLabel("IGUI_ModernStatus_WetnessMoodleLevelLt1", "Wetness moodle level < 1", "< 1"),
        tostring(wetnessLevel)
    )

    local tempInfo = getTemperatureMoodleLevels(indicator)
    local hypothermiaLevel = tempInfo and tonumber(tempInfo.coldLevel) or 0
    lines[#lines + 1] = makeConditionDetailRow(
        hypothermiaLevel < 1,
        makeConditionLabel("IGUI_ModernStatus_HypothermiaMoodleLevelLt1", "Hypothermia moodle level < 1", "< 1"),
        tostring(hypothermiaLevel)
    )

    return lines
end


local function getCarryWeightDetailedLines(indicator)
    if not indicator or not indicator.getCarryWeightData then
        return nil
    end

    local data
    if indicator.getCarryWeightDetailedData then
        data = indicator:getCarryWeightDetailedData()
    else
        data = indicator:getCarryWeightData(true)
    end
    if not data then
        return nil
    end

    local lines = {}

    local containerGross = tonumber(data.equippedContainersGross) or 0
    local containerReduced = tonumber(data.equippedContainersReduced) or 0
    if containerGross > 0 or containerReduced > 0 then
        lines[#lines + 1] = string.format("%s: %s %.1f, %s %.1f", tr("IGUI_ModernStatus_EquippedContainersWeight", "Equipped containers"), tr("IGUI_ModernStatus_GrossWeight", "gross"), containerGross, tr("IGUI_ModernStatus_ReducedWeight", "reduced"), containerReduced)
        for _, container in ipairs(data.equippedContainers or {}) do
            -- Put the reduction label before the numbers so the important weights stay aligned on the right.
            lines[#lines + 1] = string.format("- %s: (%s %s) %.1f -> %.1f", tostring(container.name or "?"), tr("IGUI_ModernStatus_WeightReduction", "reduction"), formatPercentValue((tonumber(container.reduction) or 0) / 100), tonumber(container.gross) or 0, tonumber(container.reduced) or 0)
        end
    else
        lines[#lines + 1] = tr("IGUI_ModernStatus_EquippedContainersWeight", "Equipped containers") .. ": " .. tr("IGUI_ModernStatus_None", "None")
    end

    lines[#lines + 1] = tr("IGUI_ModernStatus_EquippedItemsWeight", "Other equipped items") .. ": " .. formatThresholdNumber(tonumber(data.equippedItemsWeight) or 0)
    lines[#lines + 1] = tr("IGUI_ModernStatus_OtherInventoryWeight", "Other inventory") .. ": " .. formatThresholdNumber(tonumber(data.otherInventoryWeight) or 0)

    local traitMultiplier = tonumber(data.traitMultiplier) or 1
    if math.abs(traitMultiplier - 1) > 0.001 then
        lines[#lines + 1] = string.format("%s: x%.2f", tr("IGUI_ModernStatus_TraitMultiplier", "Trait multiplier"), traitMultiplier)
    end

    local penaltyTotal = tonumber(data.capacityPenaltyTotal) or 0
    if penaltyTotal > 0 then
        lines[#lines + 1] = tr("IGUI_ModernStatus_CapacityEffects", "Capacity effects") .. ": " .. tr("IGUI_ModernStatus_MaxCarryReducers", "Max carry reducers") .. " -" .. formatThresholdNumber(penaltyTotal)
        for _, effect in ipairs(data.capacityEffects or {}) do
            local label = tr(effect.key, effect.fallback or tostring(effect.key or "?"))
            lines[#lines + 1] = string.format("- %s %s %d: -%s", label, tr("IGUI_ModernStatus_LevelWord", "level"), tonumber(effect.level) or 0, formatThresholdNumber(tonumber(effect.penalty) or 0))
        end
    else
        lines[#lines + 1] = tr("IGUI_ModernStatus_CapacityEffects", "Capacity effects") .. ": " .. tr("IGUI_ModernStatus_None", "None")
    end

    return lines
end

local function getNoxiousExposureDetailedLines(indicator)
    if not indicator or not indicator.getExposureData then
        return nil
    end

    local data = indicator:getExposureData()
    if not data then
        return nil
    end

    local lines = {}
    local corpseCount = tonumber(data.corpses or 0) or 0
    local countedZombies = tonumber(data.countedZombies or 0) or 0
    local cappedSources = tonumber(data.cappedTotal or 0) or 0
    local protectionMultiplier = tonumber(data.protection and data.protection.multiplier or 1) or 1
    local protectionSource = data.protection and data.protection.source or nil
    local traitMultiplier = tonumber(data.trait and data.trait.multiplier or 1) or 1
    local traitSource = data.trait and data.trait.source or "none"
    local weightedSources = tonumber(data.weightedSources or 0) or 0
    local weightedExcess = tonumber(data.weightedExcess or 0) or 0
    local impactIncrement = tonumber(data.impactIncrement or 0) or 0
    local gainPerMinuteIRL = tonumber(data.gainPerMinuteIRL or 0) or 0

    local function stripParens(text)
        text = tostring(text or "")
        text = text:gsub("^%(", "")
        text = text:gsub("%)$", "")
        return text
    end

    lines[#lines + 1] = tr("IGUI_ModernStatus_Corpses", "Corpses") .. ": " .. tostring(corpseCount)

    -- Render On/Off directly in the left label with fixed colors to avoid
    -- re-querying color tables while keeping the numeric zombie count aligned.
    local stateText = data.zombieHealthImpactEnabled and stripParens(tr("IGUI_ModernStatus_on", "On")) or stripParens(tr("IGUI_ModernStatus_Off", "Off"))
    local stateColor = data.zombieHealthImpactEnabled and DETAIL_STATE_ON_COLOR or DETAIL_STATE_OFF_COLOR
    local zombiesPrefix = tr("IGUI_ModernStatus_Zombies", "Zombies") .. " (" .. tr("IGUI_ModernStatus_Impact", "Impact") .. " "
    local zombiesSuffix = "):"
    lines[#lines + 1] = {
        label = zombiesPrefix .. stateText .. zombiesSuffix,
        value = tostring(countedZombies),
        labelSegments = {
            { text = zombiesPrefix },
            { text = stateText, color = stateColor },
            { text = zombiesSuffix },
        },
    }

    lines[#lines + 1] = tr("IGUI_ModernStatus_Sources", "Sources") .. ": " .. tostring(cappedSources) .. "/25"

    if protectionSource and protectionSource ~= "" then
        lines[#lines + 1] = string.format("%s (%s): x%.2f", tr("IGUI_ModernStatus_Protection", "Protection"), tostring(protectionSource), protectionMultiplier)
    else
        lines[#lines + 1] = string.format("%s: x%.2f", tr("IGUI_ModernStatus_Protection", "Protection"), protectionMultiplier)
    end

    if traitSource == "Resilient" then
        lines[#lines + 1] = string.format("%s (%s): x%.2f", tr("IGUI_ModernStatus_TraitModifier", "Trait modifier"), tr("IGUI_ModernStatus_Resilient", "Resilient"), traitMultiplier)
    elseif traitSource == "ProneToIllness" then
        lines[#lines + 1] = string.format("%s (%s): x%.2f", tr("IGUI_ModernStatus_TraitModifier", "Trait modifier"), tr("IGUI_ModernStatus_ProneToIllness", "Prone to Illness"), traitMultiplier)
    else
        lines[#lines + 1] = string.format("%s: x%.2f", tr("IGUI_ModernStatus_TraitModifier", "Trait modifier"), traitMultiplier)
    end

    if data.deadlyFumesActive then
        lines[#lines + 1] = RGB_R .. tr("IGUI_ModernStatus_DeadlyFumesActive", "Deadly Fumes active: toxic air source detected") .. RGB_W
    end

    lines[#lines + 1] = string.format("%s: %d x %.2f x %.2f -> %.1f", tr("IGUI_ModernStatus_NoxiousSmellLabel", "Noxious smell"), cappedSources, protectionMultiplier, traitMultiplier, weightedSources)
    lines[#lines + 1] = string.format("%s: (%d - 5) x %.2f x %.2f -> %.3f", tr("IGUI_ModernStatus_Excess", "Excess"), cappedSources, protectionMultiplier, traitMultiplier, weightedExcess)
    lines[#lines + 1] = string.format("%s (%s): %.6f", tr("IGUI_ModernStatus_ImpactPerSource", "Impact/source"), tostring(data.impactLabel or tr("IGUI_ModernStatus_Normal", "Normal")), impactIncrement)
    lines[#lines + 1] = string.format("%s (1x) %s/min IRL: +%.3f", tr("IGUI_ModernStatus_Gain", "Gain"), tr("IGUI_ModernStatus_Sickness", "Sickness"), gainPerMinuteIRL)

    return lines
end

local function getMedicineDetailedLines(indicator)
    if not indicator or not indicator.player or indicator.player:isDead() then
        return nil
    end

    local lines = {}
    for _, entry in ipairs(MEDICINE_DETAIL_ENTRIES) do
        local label = getText(entry.titleKey)
        if not label or label == entry.titleKey or label == "" then
            label = entry.titleKey
        end

        local rawValue = tonumber(entry.getter(indicator.player)) or 0
        local maxValue = tonumber(entry.maxValue) or 0
        local ratio = 0
        if maxValue > 0 then
            ratio = rawValue / maxValue
            if ratio < 0 then
                ratio = 0
            elseif ratio > 1 then
                ratio = 1
            end
        end

        local line = label .. ": " .. formatPercentValue(ratio)
        if entry.showApproxDuration and rawValue > 0 then
            line = line .. " (" .. formatApproxDurationFromTicks(rawValue) .. ")"
        end

        lines[#lines + 1] = line
    end

    return #lines > 0 and lines or nil
end

function DATA.getAssociatedMoodleLines(indicator, currentValue)
    if not indicator then
        return nil
    end

    local normalizedType = DATA.normalizeIndicatorType(indicator:getType())
    local lines = {}

    if normalizedType == "MS_WindchillIndicator" then
        local amount = tonumber(currentValue) or 0
        local level = getThresholdLevel(amount, "gte", { 5, 10, 15, 20 })
        lines[#lines + 1] = tr("IGUI_ModernStatus_MoodleWord", "Moodle") .. " " .. tr("IGUI_ModernStatus_Windchill", "Windchill") .. " " .. tr("IGUI_ModernStatus_LevelWord", "level") .. ": " .. tostring(level or 0)
    elseif normalizedType == "MS_TemperatureIndicator" then
        local tempInfo = getTemperatureMoodleLevels(indicator)
        if tempInfo then
            lines[#lines + 1] = tr("IGUI_ModernStatus_MoodleWord", "Moodle") .. " " .. tr("IGUI_ModernStatus_HyperthermiaWord", "Hyperthermia") .. " " .. tr("IGUI_ModernStatus_LevelWord", "level") .. ": " .. tostring(tempInfo.hotLevel or 0)
            lines[#lines + 1] = tr("IGUI_ModernStatus_MoodleWord", "Moodle") .. " " .. tr("IGUI_ModernStatus_HypothermiaWord", "Hypothermia") .. " " .. tr("IGUI_ModernStatus_LevelWord", "level") .. ": " .. tostring(tempInfo.coldLevel or 0)
        end
    else
        local associationState = getAssociationState(indicator, currentValue)
        if associationState and associationState.prefix then
            local label = associationState.label or associationState.prefix
            lines[#lines + 1] = tr("IGUI_ModernStatus_MoodleWord", "Moodle") .. " " .. tostring(label) .. " " .. tr("IGUI_ModernStatus_LevelWord", "level") .. ": " .. tostring(associationState.level or 0)
        end
    end

    if #lines == 0 then
        return nil
    end

    return lines
end

-- Build optional detail lines only when there is meaningful extra data for the hovered indicator.

local function getNaturesCallFillDetailedLines(indicator, kind)
    if not indicator or not indicator.player or indicator.player:isDead() or not MS_NaturesCallCompat then
        return nil
    end
    local snapshot = MS_NaturesCallCompat.getDetailedSnapshot(indicator.player, 2500)
    if not snapshot then return nil end

    local fillValue = kind == "bladder" and (tonumber(snapshot.bladderFill) or 0) or (tonumber(snapshot.bowelFill) or 0)
    local digesting = false
    local rate = 0
    if snapshot.digestion then
        if kind == "bladder" then
            rate = tonumber(snapshot.digestion.bladderPerHour) or 0
            digesting = snapshot.digestion.bladderDigesting == true
        else
            rate = tonumber(snapshot.digestion.bowelPerHour) or 0
            digesting = snapshot.digestion.bowelDigesting == true
        end
    end

    local lines = {}
    if fillValue < 0 then
        lines[#lines + 1] = makePlainDetailRow(tr("IGUI_ModernStatus_NCMeterHidden", "hidden by trait"))
    end
    lines[#lines + 1] = tr("IGUI_ModernStatus_NCFillRate", "Fill rate") .. ": " .. string.format("%+.2f/h", rate)
    lines[#lines + 1] = tr("IGUI_ModernStatus_NCDigesting", "Digesting") .. ": " .. formatBooleanLabel(digesting)
    lines[#lines + 1] = tr("IGUI_ModernStatus_NCControl", "Control") .. ": " .. formatPercentValue(tonumber(snapshot.control) or 0)
    return lines
end

local function getNaturesCallGutDetailedLines(indicator)
    if not indicator or not indicator.player or indicator.player:isDead() or not MS_NaturesCallCompat then
        return nil
    end
    local snapshot = MS_NaturesCallCompat.getDetailedSnapshot(indicator.player, 3500)
    if not snapshot then return nil end

    local trend = tonumber(snapshot.gutTrendPerHour) or 0
    return {
        tr("IGUI_ModernStatus_NCGutTrend", "Trend") .. ": " .. string.format("%+.2f/day", trend * 24),
        makePlainDetailRow(tr("IGUI_ModernStatus_NCGutNote", "Good gut helps control and endurance; bad gut worsens fatigue, bowel rate and cramps")),
    }
end

local function formatNaturesCallDigestiveEffectValue(effect)
    if not effect then return "" end

    -- Keep the original source unit visible. NC Effects uses a binary
    -- active/inactive bar, so percentages would look like arbitrary thresholds.
    local value = tonumber(effect.value) or 0
    if effect.valueType == "hours" then
        return string.format("%.1f h", value)
    elseif effect.valueType == "strain" then
        return string.format("%.2f", value)
    elseif effect.valueType == "intensity" then
        return string.format("%.1f", value)
    end
    return ""
end

local function getNaturesCallDigestiveDetailedLines(indicator)
    if not indicator or not indicator.player or indicator.player:isDead() or not MS_NaturesCallCompat then
        return nil
    end
    local data = MS_NaturesCallCompat.getDigestiveEffectsData(indicator.player)
    if not data or (tonumber(data.activeCount) or 0) <= 0 then
        return nil
    end

    local lines = {}
    for _, effect in ipairs(data.effects or {}) do
        local label = tr(effect.labelKey, effect.fallback)
        lines[#lines + 1] = label .. ": " .. formatNaturesCallDigestiveEffectValue(effect)
    end
    return lines
end

local function getNaturesCallSoilageDetailedLines(indicator)
    if not indicator or not indicator.player or indicator.player:isDead() or not MS_NaturesCallCompat then
        return nil
    end
    local data = MS_NaturesCallCompat.getSoilageData(indicator.player)
    if not data then return nil end

    local lines = {
        tr("IGUI_ModernStatus_NCClothingUrine", "Clothing urine") .. ": " .. formatPercentValue100(data.clothingUrine),
        tr("IGUI_ModernStatus_NCClothingFeces", "Clothing feces") .. ": " .. formatPercentValue100(data.clothingFeces),
        tr("IGUI_ModernStatus_NCBodyUrine", "Body urine") .. ": " .. formatPercentValue100(data.bodyUrine),
        tr("IGUI_ModernStatus_NCBodyFeces", "Body feces") .. ": " .. formatPercentValue100(data.bodyFeces),
    }

    if data.driedUrine and data.driedUrine > 0 then
        lines[#lines + 1] = tr("IGUI_ModernStatus_NCDriedUrine", "Dried urine") .. ": " .. formatPercentValue100(data.driedUrine)
    end
    if data.driedFeces then
        lines[#lines + 1] = tr("IGUI_ModernStatus_NCDriedFeces", "Dried feces") .. ": " .. formatBooleanLabel(true)
    end
    if data.wipeRequired then
        lines[#lines + 1] = tr("IGUI_ModernStatus_NCWipeNeeded", "Wipe needed") .. ": " .. formatPercentValue100(data.wipeNeed)
    end

    local effectParts = {}
    if data.urineRash then effectParts[#effectParts + 1] = tr("IGUI_ModernStatus_NCUrineRash", "urine rash") end
    if data.fecesRash then effectParts[#effectParts + 1] = tr("IGUI_ModernStatus_NCFecesRash", "feces rash") end
    if data.urineInfection then effectParts[#effectParts + 1] = tr("IGUI_ModernStatus_NCUrineInfection", "urine infection") end
    if data.fecesInfection then effectParts[#effectParts + 1] = tr("IGUI_ModernStatus_NCFecesInfection", "feces infection") end
    if #effectParts > 0 then
        lines[#lines + 1] = makePlainDetailRow(tr("IGUI_ModernStatus_CurrentEffects", "Current effects") .. ": " .. table.concat(effectParts, ", "))
    end
    if data.unknownSoilage then
        lines[#lines + 1] = makePlainDetailRow(tr("IGUI_ModernStatus_NCUnknownSoilage", "some clothing soilage is unknown until checked"))
    end

    return lines
end

local function getFoodHealingDetailedLines(indicator)
    if not indicator or not indicator.player or indicator.player:isDead() then
        return nil
    end

    local data = getFoodHealingDataForIndicator(indicator)
    if not data then return nil end

    local level = tonumber(data.level) or 0
    local status = tr("IGUI_ModernStatus_Off", "Off")
    if level > 0 then
        status = resolveTranslatedText(string.format("Moodles_FoodEaten_lvl%d", level)) or status
    end

    local lines = {
        tr("IGUI_ModernStatus_StatusLabel", "Status") .. ": " .. status,
        tr("IGUI_ModernStatus_Timer", "Timer") .. ": " .. string.format("%.0f / %.0f", tonumber(data.timer) or 0, tonumber(data.maxTimer) or 11000),
        tr("IGUI_ModernStatus_RemainingGame", "Remaining game time") .. ": " .. string.format("%.1f " .. tr("IGUI_ModernStatus_MinuteShort", "m"), tonumber(data.gameMinutes) or 0),
    }

    return lines
end

local function collectDetailedInfoLines(indicator, currentValue)
    if not indicator then
        return nil
    end

    local normalizedType = DATA.normalizeIndicatorType(indicator:getType())
    local lines = {}

    local actionHint = ACTION_HINTS[normalizedType]
    if actionHint then
        lines[#lines + 1] = tr("IGUI_ModernStatus_DoubleClickPrefix", "Double-click") .. ": " .. tr(actionHint, actionHint)
    end

    if normalizedType == "MS_StressIndicator" then
        local stressLines = getStressDetailedLines(indicator)
        if stressLines then
            for _, line in ipairs(stressLines) do
                lines[#lines + 1] = line
            end
        end
        appendLifestyleStatEffectLines(lines, indicator, "Stress")
    elseif normalizedType == "MS_BoredomIndicator" then
        local boredomLines = getBoredomDetailedLines(indicator)
        if boredomLines then
            for _, line in ipairs(boredomLines) do
                lines[#lines + 1] = line
            end
        end
        appendLifestyleStatEffectLines(lines, indicator, "Boredom")
    elseif normalizedType == "MS_UnhappynessIndicator" then
        appendLifestyleStatEffectLines(lines, indicator, "Unhappiness")
    elseif normalizedType == "MS_DiscomfortIndicator" then
        appendLifestyleStatEffectLines(lines, indicator, "Discomfort")
    elseif normalizedType == "MS_PainIndicator" then
        appendLifestyleStatEffectLines(lines, indicator, "Pain")
    elseif normalizedType == "MS_SicknessIndicator" then
        local sicknessLines = getSicknessDetailedLines(indicator)
        if sicknessLines then
            for _, line in ipairs(sicknessLines) do
                lines[#lines + 1] = line
            end
        end
        appendLifestyleStatEffectLines(lines, indicator, "Sickness")
    elseif normalizedType == "MS_BleedingIndicator" then
        local bleedingLines = getBleedingDetailedLines(indicator)
        if bleedingLines then
            for _, line in ipairs(bleedingLines) do
                lines[#lines + 1] = line
            end
        end
    elseif normalizedType == "MS_CatchAColdIndicator" then
        local coldLines = getCatchAColdDetailedLines(indicator, currentValue)
        if coldLines then
            for _, line in ipairs(coldLines) do
                lines[#lines + 1] = line
            end
        end
        appendLifestyleStatEffectLines(lines, indicator, "Cold")
    elseif normalizedType == "MS_FoodHealingIndicator" then
        local foodLines = getFoodHealingDetailedLines(indicator)
        if foodLines then
            for _, line in ipairs(foodLines) do
                lines[#lines + 1] = line
            end
        end
    elseif normalizedType == "MS_NoxiousExposureIndicator" then
        local noxiousLines = getNoxiousExposureDetailedLines(indicator)
        if noxiousLines then
            for _, line in ipairs(noxiousLines) do
                lines[#lines + 1] = line
            end
        end
    elseif normalizedType == "MS_CarryWeightIndicator" then
        local carryLines = getCarryWeightDetailedLines(indicator)
        if carryLines then
            for _, line in ipairs(carryLines) do
                lines[#lines + 1] = line
            end
        end
    elseif normalizedType == "ISExcStomachStatusIndicator" then
        local stomachLines = getExcrementumStomachDetailedLines(indicator)
        if stomachLines then
            for _, line in ipairs(stomachLines) do
                lines[#lines + 1] = line
            end
        end
    elseif normalizedType == "ISExcColonStatusIndicator" then
        local colonLines = getExcrementumColonDetailedLines(indicator)
        if colonLines then
            for _, line in ipairs(colonLines) do
                lines[#lines + 1] = line
            end
        end
    elseif normalizedType == "ISExcUrineStatusIndicator" then
        local urineLines = getExcrementumUrineDetailedLines(indicator)
        if urineLines then
            for _, line in ipairs(urineLines) do
                lines[#lines + 1] = line
            end
        end
    elseif normalizedType == "ISExcSmellStatusIndicator" then
        local smellLines = getExcrementumSmellDetailedLines(indicator)
        if smellLines then
            for _, line in ipairs(smellLines) do
                lines[#lines + 1] = line
            end
        end
    elseif normalizedType == "ISExcShameStatusIndicator" then
        local shameLines = getExcrementumShameDetailedLines(indicator)
        if shameLines then
            for _, line in ipairs(shameLines) do
                lines[#lines + 1] = line
            end
        end
    elseif normalizedType == "ISNCBladderStatusIndicator" then
        local ncLines = getNaturesCallFillDetailedLines(indicator, "bladder")
        if ncLines then
            for _, line in ipairs(ncLines) do lines[#lines + 1] = line end
        end
    elseif normalizedType == "ISNCBowelStatusIndicator" then
        local ncLines = getNaturesCallFillDetailedLines(indicator, "bowel")
        if ncLines then
            for _, line in ipairs(ncLines) do lines[#lines + 1] = line end
        end
    elseif normalizedType == "ISNCGutHealthStatusIndicator" then
        local ncLines = getNaturesCallGutDetailedLines(indicator)
        if ncLines then
            for _, line in ipairs(ncLines) do lines[#lines + 1] = line end
        end
    elseif normalizedType == "ISNCSoilageStatusIndicator" then
        local ncLines = getNaturesCallSoilageDetailedLines(indicator)
        if ncLines then
            for _, line in ipairs(ncLines) do lines[#lines + 1] = line end
        end
    elseif normalizedType == "ISNCDigestiveEffectsStatusIndicator" then
        local ncLines = getNaturesCallDigestiveDetailedLines(indicator)
        if ncLines then
            for _, line in ipairs(ncLines) do lines[#lines + 1] = line end
        end
    elseif normalizedType == "MS_MedicineIndicator" then
        local medicineLines = getMedicineDetailedLines(indicator)
        if medicineLines then
            for _, line in ipairs(medicineLines) do
                lines[#lines + 1] = line
            end
        end
    elseif normalizedType == "ISBathroomNeedStatusIndicator" then
        local bathroomLines = getLifestyleBathroomDetailedLines(indicator)
        if bathroomLines then
            for _, line in ipairs(bathroomLines) do
                lines[#lines + 1] = line
            end
        end
    elseif normalizedType == "ISHygieneNeedStatusIndicator" then
        local hygieneLines = getLifestyleHygieneDetailedLines(indicator)
        if hygieneLines then
            for _, line in ipairs(hygieneLines) do
                lines[#lines + 1] = line
            end
        end
    elseif normalizedType == "ISTABASBodyGrimeStatusIndicator" then
        local tabasLines = getTABASBodyGrimeDetailedLines(indicator)
        if tabasLines then
            for _, line in ipairs(tabasLines) do
                lines[#lines + 1] = line
            end
        end
    elseif normalizedType == "ISWORCombinedStatusIndicator" then
        local worLines = getWORCombinedDetailedLines(indicator)
        if worLines then
            for _, line in ipairs(worLines) do
                lines[#lines + 1] = line
            end
        end
    end

    if #lines == 0 then
        return nil
    end

    return lines
end

function DATA.getDetailedInfoLines(indicator, currentValue)
    return colorizeDetailedLines(collectDetailedInfoLines(indicator, currentValue))
end

function DATA.getDetailedInfoRows(indicator, currentValue)
    return buildDetailedInfoRows(collectDetailedInfoLines(indicator, currentValue))
end
