package theorycrafter.ui.fiteditor

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.*
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import compose.utils.EasyTooltipPlacement
import compose.utils.VerticallyCenteredRow
import compose.utils.setText
import compose.widgets.FlatButtonWithText
import compose.widgets.IconButton
import compose.widgets.SingleLineText
import eve.data.*
import eve.data.typeid.*
import kotlinx.coroutines.*
import theorycrafter.TheorycrafterContext
import theorycrafter.fitting.EveItem
import theorycrafter.fitting.Fit
import theorycrafter.fitting.Module
import theorycrafter.fitting.maxLoadedChargeAmount
import theorycrafter.fitting.utils.mapEach
import theorycrafter.tournaments.TournamentRules
import theorycrafter.ui.Icons
import theorycrafter.ui.TheorycrafterTheme
import theorycrafter.ui.tooltip
import theorycrafter.ui.widgets.InfoDialog
import theorycrafter.ui.widgets.MenuButton
import theorycrafter.ui.widgets.MenuItemHeading
import theorycrafter.utils.AttractAttentionOnValueChange
import theorycrafter.utils.DpOffsetX
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds


/**
 * The progress and result of fit analysis.
 */
private sealed interface FitAnalysis {


    /**
     * The fit analysis is in progress.
     */
    data object InProgress: FitAnalysis


    /**
     * The fit analysis completed with the given result.
     */
    data class Result(val issues: List<FitIssue>): FitAnalysis


}


/**
 * Returns a state of the current analysis of the given fit.
 */
@Composable
private fun analyzeFit(fit: Fit): State<FitAnalysis> {
    val tournamentRules = TheorycrafterContext.tournaments.activeRules?.fittingRules
    return produceState<FitAnalysis>(
        initialValue = FitAnalysis.InProgress,
        key1 = fit.changeKey,
        key2 = tournamentRules
    ) {
        launch(Dispatchers.Default) {
            // It's too fast at the moment; we can just keep displaying the previous state until the result is ready
//            value = FitAnalysis.InProgress
            value = FitAnalysis.Result(
                issues = fit.issues(tournamentRules)
            )
        }
    }
}


/**
 * An icon indicating the given fit's analysis and includes the ability to show quick fixes for the issues.
 */
@Composable
fun InteractiveFitAnalysisIcon(
    fit: Fit,
    modifier: Modifier = Modifier
) {
    val undoRedoQueue = LocalFitEditorUndoRedoQueue.current
    var emittedUi: (@Composable (remove: () -> Unit) -> Unit)? by remember { mutableStateOf(null) }

    Box(
        contentAlignment = Alignment.CenterEnd,
        modifier = modifier
    ) {
        val analysis = analyzeFit(fit).value
        if (analysis is FitAnalysis.Result) {
            val issues = analysis.issues
            val tooltipPlacement = EasyTooltipPlacement.Element(
                anchor = Alignment.TopStart,
                alignment = Alignment.BottomStart,
                offset = DpOffsetX(x = -TheorycrafterTheme.spacing.xsmall)
            )

            AnimatedVisibility(
                visible = issues.isEmpty(),
                enter = fadeIn(),
                exit = fadeOut()
            ) {
                Icons.OkCheckmark(Modifier.tooltip("Fit looks good", placement = tooltipPlacement))
            }

            AnimatedVisibility(
                visible = issues.isNotEmpty(),
                enter = fadeIn(),
                exit = fadeOut()
            ) {
                MenuButton(
                    content = { onClick ->
                        IconButton(onClick = onClick) {
                            VerticallyCenteredRow(
                                horizontalArrangement = Arrangement.spacedBy(TheorycrafterTheme.spacing.xxxsmall),
                                modifier = Modifier.padding(end = TheorycrafterTheme.spacing.xxsmall),
                            ) {
                                Icons.Warning(
                                    modifier = Modifier.padding(TheorycrafterTheme.spacing.xxsmall),
                                )
                                var displayedIssuesCount by remember { mutableStateOf(issues.size) }
                                LaunchedEffect(issues.size) {
                                    if (issues.isNotEmpty()) {
                                        displayedIssuesCount = issues.size
                                    }
                                }
                                AttractAttentionOnValueChange(
                                    value = displayedIssuesCount,
                                    maxScale = 1.3f
                                )  {
                                    SingleLineText(text = displayedIssuesCount.toString(), textAlign = TextAlign.End)
                                }
                            }
                        }
                    },
                    menuContent = { onCloseMenu ->
                        if (issues.all { it.fixes.isEmpty() }) {
                            theorycrafter.ui.widgets.MenuItem(
                                text = "No quick fixes available",
                                enabled = false,
                                onCloseMenu = onCloseMenu,
                                action = {}
                            )
                            return@MenuButton
                        }

                        MenuItemHeading("Quick Fixes", extraTopPadding = false)

                        for (issue in issues) {
                            for (fix in issue.fixes) {
                                theorycrafter.ui.widgets.MenuItem(
                                    text = fix.text,
                                    onCloseMenu = onCloseMenu,
                                    action = {
                                        when (val action = fix.action) {
                                            is FitIssue.Fix.EditFit -> {
                                                action.editingActionProvider()?.let {
                                                    undoRedoQueue.performAndAppend(it)
                                                }
                                            }
                                            is FitIssue.Fix.EmitUi -> {
                                                emittedUi = action.ui
                                            }
                                            null -> {}
                                        }
                                    }
                                )
                            }
                        }
                    },
                    modifier = Modifier.issuesTooltip(issues, tooltipPlacement)
                )
            }
        } else {
            var visible by remember { mutableStateOf(false) }
            LaunchedEffect(Unit) {
                delay(500.milliseconds)
                visible = true
            }
            if (visible) {
                CircularProgressIndicator(Modifier.size(24.dp))
            }
        }
    }

    emittedUi?.let {
        it { emittedUi = null }
    }
}


/**
 * A simple icon indicating the given fit's analysis.
 *
 * This variant only displays the icon if there are issues. The icon is not interactive.
 */
@Composable
fun SimpleAnalysisIconOrNone(
    fit: Fit,
    tooltipPlacement: EasyTooltipPlacement = EasyTooltipPlacement.ElementBottomCenter,
    modifier: Modifier = Modifier,
) {
    Box(
        contentAlignment = Alignment.CenterEnd,
        modifier = modifier
    ) {
        val analysis = analyzeFit(fit).value
        if (analysis is FitAnalysis.Result) {
            val issues = analysis.issues
            AnimatedVisibility(
                visible = issues.isNotEmpty(),
                enter = fadeIn(),
                exit = fadeOut()
            ) {
                Icons.Warning(
                    modifier = Modifier
                        .issuesTooltip(issues, tooltipPlacement)
                        .padding(TheorycrafterTheme.spacing.xxsmall)
                )
            }
        } else {
            var visible by remember { mutableStateOf(false) }
            LaunchedEffect(Unit) {
                delay(500.milliseconds)
                visible = true
            }
            if (visible) {
                CircularProgressIndicator(Modifier.size(24.dp))
            }
        }
    }
}


/**
 * The tooltip to display for the given list of issues.
 */
private fun Modifier.issuesTooltip(
    issues: List<FitIssue>,
    tooltipPlacement: EasyTooltipPlacement
) = tooltip(
    text = {
        if (issues.size == 1)
            issues.first().warningMessage
        else
            issues.joinToString(
                prefix = "• ",
                separator = "\n• ",
                transform = { it.warningMessage }
            )
    },
    placement = tooltipPlacement,
)


/**
 * A problem with the fit.
 */
private class FitIssue(
    val warningMessage: String,
    val fixes: List<Fix>
) {


    /**
     * A constructor for an issue with no fixes.
     */
    constructor(warningMessage: String, vararg fixes: Fix): this(warningMessage, fixes = fixes.toList())


    /**
     * A fix to the issue.
     */
    class Fix(
        val text: String,
        val action: Action?
    ) {

        sealed interface Action

        class EditFit(val editingActionProvider: () -> FitEditingAction?): Action

        class EmitUi(val ui: @Composable (remove: () -> Unit) -> Unit): Action

    }


}


/**
 * Returns a non-null issue with the given message only if the condition holds.
 */
private fun fitIssueIf(condition: Boolean, warningMessage: String) =
    if (condition) FitIssue(warningMessage) else null


/**
 * Returns the list of warnings for the given fit.
 */
context(CoroutineScope)
private fun Fit.issues(tournamentRules: TournamentRules.FittingRules?) = with(TheorycrafterContext.eveData) {
    buildList {

        fun ensureActiveAndAddIfNotNull(issue: FitIssue?) {
            ensureActive()
            if (issue != null)
                add(issue)
        }

        ensureActiveAndAddIfNotNull(missingRequiredSkills())
        ensureActiveAndAddIfNotNull(requiresMoreCpuThanAvailable())
        ensureActiveAndAddIfNotNull(
            requiresMorePowerThanAvailable() ?: requiresMorePowerThanAvailableForUndocking()
        )
        ensureActiveAndAddIfNotNull(requiresMoreCalibrationThanAvailable())
        ensureActiveAndAddIfNotNull(requiresMoreDroneBayVolumeThanAvailable())
        ensureActiveAndAddIfNotNull(requiresMoreDroneBandwidthThanAvailable())
        ensureActiveAndAddIfNotNull(requiresMoreCargoSpaceThanAvailable())
        ensureActiveAndAddIfNotNull(hasMixedDefenseModules())
        ensureActiveAndAddIfNotNull(hasDuplicateCommandBurstCharges())
        ensureActiveAndAddIfNotNull(hasUnusedCommandProcessorRigs())
        ensureActiveAndAddIfNotNull(hasUndersizedPropulsionModule())
        ensureActiveAndAddIfNotNull(hasUndersizedArmorRepairer())
        ensureActiveAndAddIfNotNull(hasUndersizedShieldBooster())
        ensureActiveAndAddIfNotNull(isMindlinkCommandBurstMismatch())
        ensureActiveAndAddIfNotNull(hasModulesInCargoWithoutEnoughCargoSpaceForRefit())

        if (isFitReadyForCargoholdChecks()) {
            ensureActiveAndAddIfNotNull(isMissingScriptsInCargo(tournamentRules))
            ensureActiveAndAddIfNotNull(isMissingCommandBurstChargesInCargo())
            ensureActiveAndAddIfNotNull(isMissingAncillaryShieldBoosterChargesInCargo())
            ensureActiveAndAddIfNotNull(isMissingAncillaryArmorRepairerChargesInCargo())
            ensureActiveAndAddIfNotNull(isMissingCapBoostersInCargo())
        }

        ensureActiveAndAddIfNotNull(hasIllegalItemsFitted())
    }
}


/**
 * Checks whether the given fit is missing required skills.
 */
context(EveData)
private fun Fit.missingRequiredSkills(): FitIssue? {
    val skillSet = character.skillSet
    val unfulfilledSkillRequirements = skillSet.unfullfilledRequirements(
        listOf(ship.type),
        modules.all.mapEach { it.type },
        drones.all.mapEach { it.type },
        implants.fitted.mapEach { it.type },
    )

    return if (unfulfilledSkillRequirements.isEmpty())
        null
    else
        FitIssue(
            warningMessage = "Missing ${unfulfilledSkillRequirements.size} required skills",
            FitIssue.Fix(
                text = "Show missing skills",
                action = FitIssue.Fix.EmitUi { removeUi ->
                    val skillsAndLevelsText = remember {
                        unfulfilledSkillRequirements.joinToString(separator = "\n") { requirement ->
                            val skill = skillType(requirement.skillId)
                            "${skill.name} ${requirement.level}"
                        }
                    }
                    val clipboard = LocalClipboard.current
                    var showSuccessIcon by remember { mutableStateOf(false) }
                    InfoDialog(
                        title = "Missing required skills",
                        text = skillsAndLevelsText,
                        onDismiss = removeUi,
                        modifier = Modifier.widthIn(min = 300.dp),
                        extraButtons = {
                            VerticallyCenteredRow {
                                AnimatedVisibility(visible = showSuccessIcon) {
                                    Icons.OkCheckmark()
                                    LaunchedEffect(Unit) {
                                        delay(1.5.seconds)
                                        showSuccessIcon = false
                                    }
                                }
                                FlatButtonWithText(
                                    text = "Copy to Clipboard",
                                    onClick = {
                                        clipboard.setText(skillsAndLevelsText)
                                        showSuccessIcon = true
                                    }
                                )
                            }
                        }
                    )
                }
            )
        )
}


/**
 * Checks whether the given fit has both armor and shield modules.
 */
context(EveData, CoroutineScope)
private fun Fit.hasMixedDefenseModules(): FitIssue? {
    fun ModuleType.isShieldModule(): Boolean {
        return isShieldExtender() || isShieldHardener() || isShieldResistanceAmplifier() ||
                isShieldRecharger() || isShieldBooster(includingAncillary = true) ||
                isShieldFluxCoil() || isShieldPowerRelay() || isShieldRig()
    }
    fun ModuleType.isArmorModule(): Boolean {
        return isArmorPlate() || isArmorHardener() || isEnergizedArmorResistanceMembrane() ||
                isArmorResistanceCoating() || isArmorRepairer(includingAncillary = true) ||
                isReactiveArmorHardener() || isArmorRig()
    }

    val modules = modules.all
    val hasShieldModules = modules.any {
        ensureActive()
        it.type.isShieldModule()
    }
    val hasArmorModules = modules.any {
        ensureActive()
        it.type.isArmorModule()
    }

    return fitIssueIf(
        condition = hasShieldModules && hasArmorModules,
        warningMessage = "Both shield and armor modules are fitted"
    )
}


/**
 * Checks whether the given fit has command bursts with identical charges.
 */
context(EveData)
private fun Fit.hasDuplicateCommandBurstCharges(): FitIssue? {
    val commandBurstCharges = mutableSetOf<ChargeType>()
    var hasDuplicates = false
    for (module in modules.high) {
        if (!module.type.isCommandBurst())
            continue
        val charge = module.loadedCharge ?: continue
        if (!commandBurstCharges.add(charge.type)) {
            hasDuplicates = true
            break
        }
    }

    return fitIssueIf(
        condition = hasDuplicates,
        warningMessage = "Duplicate command burst charges are loaded"
    )
}


/**
 * Checks whether the given fit has unused command processor rigs.
 */
context(EveData)
private fun Fit.hasUnusedCommandProcessorRigs(): FitIssue? {
    if (modules.rigs.none { it.type.isCommandProcessor() })
        return null

    val commandBurstModules = modules.high.filter { it.type.isCommandBurst() }
    val maxCommandBurstModules = commandBurstModules.firstOrNull()?.maxGroupOnline?.value ?: 0
    return fitIssueIf(
        condition = maxCommandBurstModules > commandBurstModules.size,
        warningMessage = "Unused command processor rigs are fitted"
    )
}


/**
 * Checks and returns an issue of not enough fit resources.
 *
 * This is used for PG and CPU over max. issues.
 */
context(EveData)
private fun Fit.requiresMoreShipResourceThanAvailableWithImplantFix(
    resource: Fit.Resource<Double>,
    warningMessagePrefix: String,
    implantIdentifier: ImplantType.() -> Boolean,
    bonusPct: ImplantType.() -> Double?,
    fixTextImplantName: String,
): FitIssue? {
    val availableResource = resource.available
    if (availableResource >= 0.0)
        return null

    val fixImplants = implantTypes
        .filter { it.implantIdentifier() && (it.bonusPct() != null) }
        .sortedBy { it.bonusPct()!! }
    val slotIndex = fixImplants.firstNotNullOf { it.slotIndex }
    val currentImplantInSlot = implants.inSlot(slotIndex)
    val currentResourceBonus = fixImplants.find { it == currentImplantInSlot?.type }?.bonusPct() ?: 0.0

    val overResourcePct = (-availableResource / resource.total) * 100.0
    val overResourceWithoutImplantPct = overResourcePct + currentResourceBonus
    val fixImplant = fixImplants.firstOrNull { it.bonusPct()!! >= overResourceWithoutImplantPct }
    return FitIssue(
        warningMessage = warningMessagePrefix + "(" + overResourcePct.asPercentage(precision = 2, withSign = true) + ")",
        fixes = listOfNotNull(
            if (fixImplant == null)
                null
            else
                FitIssue.Fix(
                    text = "${if (currentResourceBonus > 0) "Upgrade to" else "Fit"} " +
                            "${fixImplant.bonusPct()!!.asPercentageWithPrecisionAtMost(precision = 1, withSign = true)} " +
                            fixTextImplantName,
                    action = FitIssue.Fix.EditFit {
                        replaceImplantAction(this, fixImplant, preserveEnabledState = false)
                    }
                )
        )
    )
}


/**
 * Checks whether the given fit requires more CPU than it produces.
 */
context(EveData)
private fun Fit.requiresMoreCpuThanAvailable(): FitIssue? = requiresMoreShipResourceThanAvailableWithImplantFix(
    resource = fitting.cpu,
    warningMessagePrefix = "Not enough CPU",
    implantIdentifier = { isCpuManagementImplant() },
    bonusPct = { cpuOutputPercentageBonus },
    fixTextImplantName = "CPU implant",
)


/**
 * Checks whether the given fit requires more power than it produces.
 */
context(EveData)
private fun Fit.requiresMorePowerThanAvailable() = requiresMoreShipResourceThanAvailableWithImplantFix(
    resource = fitting.power,
    warningMessagePrefix = "Not enough powergrid",
    implantIdentifier = { isPowerGridManagementImplant() },
    bonusPct = { powerOutputPercentageBonus },
    fixTextImplantName = "Powergrid implant",
)


/**
 * Checks whether the given fit, including offline modules, is over the limit for undocking.
 */
context(EveData)
private fun Fit.requiresMorePowerThanAvailableForUndocking() = fitIssueIf(
    condition = modules.all.sumOf { it.powerNeed?.doubleValue ?: 0.0 } > 2 * fitting.power.total,
    warningMessage = "Not enough powergrid (incl. offline modules) to undock"
)


/**
 * Checks whether the given fit requires more calibration than is available.
 */
context(EveData)
private fun Fit.requiresMoreCalibrationThanAvailable() = fitIssueIf(
    condition =  fitting.calibration.available < 0.0,
    warningMessage = "Not enough calibration"
)


/**
 * Checks whether the given fit requires more drone bay volume than is available.
 */
context(EveData)
private fun Fit.requiresMoreDroneBayVolumeThanAvailable() = fitIssueIf(
    condition =  drones.capacity.available < 0.0,
    warningMessage = "Not enough drone bay capacity"
)


/**
 * Checks whether the given fit requires more drone bandwidth than is available.
 */
context(EveData)
private fun Fit.requiresMoreDroneBandwidthThanAvailable() = fitIssueIf(
    condition =  drones.bandwidth.available < 0.0,
    warningMessage = "Not enough drone bandwidth"
)


/**
 * Checks whether the given fit requires more cargo space than is available.
 */
context(EveData, CoroutineScope)
private fun Fit.requiresMoreCargoSpaceThanAvailable() = fitIssueIf(
    condition =  cargohold.capacity.available < 0.0,
    warningMessage = "Not enough cargo space"
)


/**
 * Checks whether the fit has an undersized propulsion module.
 */
context(EveData)
private fun Fit.hasUndersizedPropulsionModule(): FitIssue? {
    val shipMass = ship.type.propulsion.mass
    return fitIssueIf(
        condition = modules.all.any { module ->
            val moduleType = module.type
            if (!moduleType.isPropulsionModule())
                return@any false

            val massAddition = moduleType.attributeValueOrNull(attributes.massAddition) ?: return@any false
            massAddition * 10 < shipMass
        },
        warningMessage = "Undersized propulsion module is fitted"
    )
}


/**
 * Checks whether the fit has an undersized armor repaired.
 */
context(EveData)
private fun Fit.hasUndersizedArmorRepairer(): FitIssue? {
    val shipPower = ship.type.fitting.power
    return fitIssueIf(
        condition = modules.all.any { module ->
            val moduleType = module.type
            if (!moduleType.isArmorRepairer(includingAncillary = true))
                return@any false

            val power = moduleType.powerNeed ?: return@any false
            power * 20 < shipPower
        },
        warningMessage = "Undersized armor repairer is fitted"
    )
}


/**
 * Checks whether the fit has an undersized shield booster.
 */
context(EveData)
private fun Fit.hasUndersizedShieldBooster(): FitIssue? {
    val shipPower = ship.type.fitting.power
    return fitIssueIf(
        condition = modules.all.any { module ->
            val moduleType = module.type
            if (!moduleType.isShieldBooster(includingAncillary = true))
                return@any false

            val power = moduleType.powerNeed ?: return@any false
            power * 80 < shipPower
        },
        warningMessage = "Undersized shield booster is fitted"
    )
}


/**
 * Checks whether the fit has a mindlink that doesn't give any bonuses to the command burst modules fitted.
 */
context(EveData)
private fun Fit.isMindlinkCommandBurstMismatch(): FitIssue? {
    val mindlink = implants.fitted.findLast { it.type.isMindlinkImplant() }
    return fitIssueIf(
        condition = (mindlink != null) && mindlink.appliedEffects.isEmpty(),
        warningMessage = "Fitted mindlink does not provide bonuses to any command bursts"
    )
}


/**
 * Checks whether the fit has modules (for refitting) in cargo, but there is not enough available cargo
 * space to put some fitted modules into the cargohold.
 */
context(EveData)
private fun Fit.hasModulesInCargoWithoutEnoughCargoSpaceForRefit(): FitIssue? {
    val cargoModulesSlotTypes = cargohold.contents.mapNotNullTo(mutableSetOf()) { item ->
        (item.type as? ModuleType)?.slotType.takeIf { it != ModuleSlotType.RIG }
    }
    if (cargoModulesSlotTypes.isEmpty())
        return null

    val availableCargoVolume = cargohold.capacity.available
    var hasModulesThatDontFitInCargohold = false
    outerLoop@ for (slotType in cargoModulesSlotTypes) {
        for (module in modules.inRack(slotType)) {
            val moduleVolume = module.type.volume
            val chargesVolume = module.loadedCharge?.let { charge ->
                charge.type.volume * (maxLoadedChargeAmount(module.type, charge.type) ?: 0)
            } ?: 0.0
            if (moduleVolume + chargesVolume > availableCargoVolume) {
                hasModulesThatDontFitInCargohold = true
                break@outerLoop
            }
        }
    }

    return fitIssueIf(
        condition = hasModulesThatDontFitInCargohold,
        warningMessage = "May be unable to refit modules due to limited cargo space"
    )
}


/**
 * Returns whether the fit is complete enough to run cargohold checks on it.
 */
private fun Fit.isFitReadyForCargoholdChecks(): Boolean {
    return (modules.rigs.size == fitting.slots.rig.value) &&
            (modules.low.size == fitting.slots.low.value) &&
            (modules.medium.size == fitting.slots.medium.value)
    // Not all fits require high-slots to be filled
}


/**
 * Checks whether the fit has scripted modules fitted, but not enough scripts for them.
 */
context(EveData)
private fun Fit.isMissingScriptsInCargo(tournamentRules: TournamentRules.FittingRules?): FitIssue? {
    // Compute for each script, the amount of it needed in cargo
    val neededScriptCountInCargo = mutableMapOf<ChargeType, Int>()
    for (module in modules.all) {
        for (charge in chargesForModule(module.type)) {
            if (charge.isScript() && (tournamentRules?.isChargeLegal(charge, module.type) != false)) {
                neededScriptCountInCargo[charge] = neededScriptCountInCargo.getOrDefault(charge, 0) + 1
            }
        }
    }

    // Remove the charges in cargo from neededScriptCountInCargo
    for (item in cargohold.contents) {
        val chargeType = (item.type as? ChargeType) ?: continue
        val neededCount = neededScriptCountInCargo[chargeType] ?: continue
        neededScriptCountInCargo[chargeType] = (neededCount - item.amount).coerceAtLeast(0)
    }

    val missingScriptsCount = neededScriptCountInCargo.values.sum()
    return if (missingScriptsCount == 0)
        null
    else {
        FitIssue(
            warningMessage = "Possibly missing scripts in cargo hold",
            FitIssue.Fix(
                text = "Add $missingScriptsCount scripts to cargo hold",
                action = FitIssue.Fix.EditFit {
                    bulkAddCargoAction(this, neededScriptCountInCargo.toList())
                }
            )
        )
    }
}


/**
 * Returns the fitted modules whose loaded charge is not present in the cargo hold.
 */
private fun Fit.modulesWithoutChargeInCargo(
    filter: (ModuleType) -> Boolean,
): List<Module> {
    val chargeTypesInCargo = cargohold.contents.mapNotNullTo(mutableSetOf()) { it.type as? ChargeType }
    return modules.all.filter { module ->
        val moduleType = module.type
        if (!filter(moduleType))
            return@filter false
        val charge = module.loadedCharge ?: return@filter false
        charge.type !in chargeTypesInCargo
    }
}


/**
 * Checks whether the fit has command burst modules fitted, but no charges for them in the cargo hold.
 */
context(EveData)
private fun Fit.isMissingChargesInCargo(
    chargesName: String,
    moduleFilter: (ModuleType) -> Boolean,
): FitIssue? {
    val modulesWithoutChargesInCargo = modulesWithoutChargeInCargo(moduleFilter)

    val chargeTypeToNeededToAmount = mutableMapOf<ChargeType, Int>()
    for (module in modulesWithoutChargesInCargo) {
        val chargeType = module.loadedCharge!!.type
        val amount = maxLoadedChargeAmount(module.type, chargeType) ?: continue
        chargeTypeToNeededToAmount[chargeType] = chargeTypeToNeededToAmount.getOrDefault(chargeType, 0) + amount
    }

    val totalNeededCharges = chargeTypeToNeededToAmount.values.sum()
    return if (totalNeededCharges == 0)
        null
    else {
        FitIssue(
            warningMessage = "Possibly missing $chargesName in cargo hold",
            FitIssue.Fix(
                text =
                    if (chargeTypeToNeededToAmount.size == 1)
                        "Add ${totalNeededCharges}x ${chargeTypeToNeededToAmount.keys.first().name} to cargo hold"
                    else
                        "Add $totalNeededCharges $chargesName to cargo hold",
                action = FitIssue.Fix.EditFit {
                    bulkAddCargoAction(this, chargeTypeToNeededToAmount.toList())
                }
            )
        )
    }
}


/**
 * Checks whether the fit has command burst modules fitted, but no charges for them in the cargo hold.
 */
context(EveData)
private fun Fit.isMissingCommandBurstChargesInCargo() = isMissingChargesInCargo(
    chargesName = "command burst charges",
    moduleFilter = { it.isCommandBurst() }
)


/**
 * Checks whether the fit has ancillary shield booster modules fitted, but no charges for them in the cargo hold.
 */
context(EveData)
private fun Fit.isMissingAncillaryShieldBoosterChargesInCargo() = isMissingChargesInCargo(
    chargesName = "ancillary shield booster charges",
    moduleFilter = { it.isAncillaryShieldBooster() }
)


/**
 * Checks whether the fit has ancillary armor repairers fitted, but no nanite paste in the cargo hold.
 */
context(EveData)
private fun Fit.isMissingAncillaryArmorRepairerChargesInCargo() = isMissingChargesInCargo(
    chargesName = "nanite paste for ancillary armor repairer",
    moduleFilter = { it.isAncillaryArmorRepairer() }
)


/**
 * Checks whether the fit has capacitor boosters fitted, but no cap boosters in the cargo hold.
 */
context(EveData)
private fun Fit.isMissingCapBoostersInCargo() = isMissingChargesInCargo(
    chargesName = "cap boosters",
    moduleFilter = { it.isCapacitorBooster() }
)


/**
 * Checks whether the fit has any illegal items fitted.
 */
private fun Fit.hasIllegalItemsFitted(): FitIssue? {
    val allItems: List<Collection<EveItem<*>?>> = listOf(
        modules.all,
        drones.all,
        implants.fitted,
        boosters.fitted,
        cargohold.contents,
        listOf(tacticalMode),
        subsystemByKind?.values ?: emptyList(),
        environments
    )

    return fitIssueIf(
        condition = allItems.any { items -> items.any { it?.illegalFittingReason != null } },
        warningMessage = "Fit contains illegal items"
    )
}
