/**
 * The parts of the fit editor that relate to modules and charges.
 */

package theorycrafter.ui.fiteditor

import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.ProvideTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import compose.input.*
import compose.utils.*
import compose.widgets.FlatButtonWithText
import compose.widgets.GridScope
import compose.widgets.IconButton
import compose.widgets.SingleLineText
import eve.data.*
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.jetbrains.skiko.OS
import org.jetbrains.skiko.hostOs
import theorycrafter.*
import theorycrafter.fitting.*
import theorycrafter.ui.Icons
import theorycrafter.ui.LocalSnackbarHost
import theorycrafter.ui.OutlinedTextField
import theorycrafter.ui.TheorycrafterTheme
import theorycrafter.ui.fiteditor.effectcolumn.displayedModuleEffect
import theorycrafter.ui.tooltip
import theorycrafter.ui.widgets.InnerDialog
import theorycrafter.ui.widgets.SLOT_ROW_PADDING
import theorycrafter.ui.widgets.SlotRow
import theorycrafter.utils.*
import kotlin.math.max
import kotlin.math.min


/**
 * The information regarding a module that we remember in order to fit it.
 */
private class ModuleInfo(
    private val slotIndex: Int,
    private val moduleType: ModuleType,
    private val chargeType: ChargeType?,
    private val moduleState: Module.State?,
    private val spoolupCycles: SpoolupCycles?,
    private val adaptationCycles: AdaptationCycles?
) {


    /**
     * Creates a [ModuleInfo] from the given live module.
     */
    constructor(slotIndex: Int, module: Module): this(
        slotIndex = slotIndex,
        moduleType = module.type,
        chargeType = module.loadedCharge?.type,
        moduleState = module.state,
        spoolupCycles = SpoolupCycles.of(module),
        adaptationCycles = AdaptationCycles.of(module)
    )


    /**
     * Removes the module specified by this [ModuleInfo] from the given fit.
     */
    fun removeFrom(scope: FittingEngine.ModificationScope, fit: Fit) = with(scope) {
        fit.removeModule(moduleType.slotType, slotIndex)
    }


    /**
     * Fits the module specified by this [ModuleInfo] to the given fit.
     */
    fun fitTo(scope: FittingEngine.ModificationScope, fit: Fit) = with(scope) {
        val module = fit.fitModule(moduleType, slotIndex)
        if (moduleState != null)
            module.setState(moduleState)
        if (chargeType != null)
            module.setCharge(chargeType)
        if ((module.spoolupCycles != null) && (spoolupCycles != null)) {
            recomputePropertyValues()  // To first apply any bonuses to max spools
            module.setSpoolupCycles(spoolupCycles.valueFor(module))
        }
        if ((module.adaptationCycles != null) && (adaptationCycles != null))
            module.setAdaptationCycles(adaptationCycles.cycleCount)
    }


}


/**
 * A [FitEditingAction] that replaces a module with another one (both actions are optional)
 */
private class ModuleReplacement(
    private val fit: Fit,
    private val removedModuleInfo: ModuleInfo?,
    private val fitModuleInfo: ModuleInfo?
): FitEditingAction() {

    context(FitEditorUndoRedoContext)
    override fun FittingEngine.ModificationScope.performEdit() {
        removedModuleInfo?.removeFrom(this, fit)
        fitModuleInfo?.fitTo(this, fit)
    }

    context(FitEditorUndoRedoContext)
    override fun FittingEngine.ModificationScope.revertEdit() {
        fitModuleInfo?.removeFrom(this, fit)
        removedModuleInfo?.fitTo(this, fit)
    }

}


/**
 * Returns a [FitEditingAction] for fitting a module into the given slot (replacing the current module in it).
 */
private fun moduleReplacement(
    fit: Fit,
    type: ModuleType?,
    state: Module.State?,
    slotType: ModuleSlotType,
    slotIndex: Int,
    chargeType: ChargeType?,
    spoolupCycles: SpoolupCycles?,
    adaptationCycles: AdaptationCycles?,
): FitEditingAction? {
    val currentModule = fit.modules.inSlot(slotType, slotIndex)
    if (currentModule?.type == type)
        return null

    val removedModuleInfo = currentModule?.let {
        ModuleInfo(slotIndex, it)
    }

    val fitModuleInfo = type?.let {
        ModuleInfo(
            slotIndex = slotIndex,
            moduleType = it,
            chargeType = chargeType,
            moduleState = state,
            spoolupCycles = spoolupCycles,
            adaptationCycles = adaptationCycles
        )
    }

    return ModuleReplacement(fit = fit, removedModuleInfo = removedModuleInfo, fitModuleInfo = fitModuleInfo)
}


/**
 * Returns a [FitEditingAction] that fits a copy of the given module into the given empty slot. This is used when adding
 * a module to an existing [MultiModuleSlot].
 */
private fun fitModuleAction(
    fit: Fit,
    module: Module,
    slotIndex: Int,
): FitEditingAction? {
    return moduleReplacement(
        fit = fit,
        type = module.type,
        state = module.state,
        slotType = module.type.slotType,
        slotIndex = slotIndex,
        chargeType = module.loadedCharge?.type,
        spoolupCycles = SpoolupCycles.of(module),
        adaptationCycles = AdaptationCycles.of(module),
    )
}


/**
 * Returns the initial charge to load into the given module type, also taking into account the replaced module.
 */
fun preloadedChargeWhenReplacingModule(
    fit: Fit,
    moduleType: ModuleType,
    replacedModule: Module?
): ChargeType? {

    fun ModuleType.canLoad(chargeType: ChargeType?) = (chargeType == null) || canLoadCharge(chargeType)

    // If the new module is a variant of the current one, try to use the same charge.
    val currentChargeType = replacedModule?.loadedCharge?.type
    return if ((replacedModule != null) &&
        moduleType.isVariant(replacedModule.type) &&
        moduleType.canLoad(currentChargeType) &&
        replacedModule.canLoadCharges  // For the case of Shield Booster -> Ancillary Shield Booster
    ) {
        currentChargeType
    } else {
        // Otherwise, load the preferred initial charge
        preloadedCharge(fit, moduleType)
    }
}


/**
 * Enumerates the algorithms for setting the state of a newly fit module.
 */
sealed interface NewModuleState {


    /**
     * Use the state of the module being replaced.
     */
    data object PreserveReplaced: NewModuleState


    /**
     * Use the given state.
     */
    class Specified(val state: Module.State): NewModuleState


    /**
     * Use the default initial module state.
     */
    data object DefaultInitial: NewModuleState


}


/**
 * Returns a [FitEditingAction] that fits the given module type into the given group of slots.
 */
private fun fitModulesToSlotGroupAction(
    fit: Fit,
    moduleType: ModuleType,
    newModuleState: NewModuleState,
    slotGroup: ModuleSlotGroup,
    chargeType: ChargeType? = preloadedChargeWhenReplacingModule(fit, moduleType, slotGroup.repModule),
): FitEditingAction? {
    val currentModule = slotGroup.repModule
    if (moduleType == currentModule?.type)  // The same module type is already fitted
        return null

    val moduleState = when {
        (newModuleState == NewModuleState.PreserveReplaced) && (currentModule != null) -> currentModule.state
        newModuleState is NewModuleState.Specified -> newModuleState.state
        else -> moduleType.defaultInitialState()
    }

    val spoolupCycles = if (currentModule != null) SpoolupCycles.of(currentModule) else SpoolupCycles.Maximum
    val adaptationCycles = if (currentModule != null) AdaptationCycles.of(currentModule) else AdaptationCycles.Maximum

    val moduleReplacements = slotGroup.slotIndices
        .map { slotIndex ->
            moduleReplacement(
                fit = fit,
                type = moduleType,
                state = moduleState,
                slotType = slotGroup.slotType,
                slotIndex = slotIndex,
                chargeType = chargeType,
                spoolupCycles = spoolupCycles,
                adaptationCycles = adaptationCycles,
            )
        }

    return object: FitEditingAction() {

        context(FitEditorUndoRedoContext)
        override fun FittingEngine.ModificationScope.performEdit() {
            for (replacement in moduleReplacements)
                replacement?.performEditIn(this)
        }

        context(FitEditorUndoRedoContext)
        override fun FittingEngine.ModificationScope.revertEdit() {
            for (replacement in moduleReplacements)
                replacement?.revertEditIn(this)
        }

    }
}


/**
 * Returns a [FitEditingAction] that removes all modules in the given slot group.
 */
private fun removeSlotGroupAction(
    fit: Fit,
    slotGroup: ModuleSlotGroup,
): FitEditingAction? {
    val slotType = slotGroup.slotType
    val indicesToRemove = slotGroup.slotIndices.filter { fit.modules.inSlot(slotType, it) != null }
    if (indicesToRemove.isEmpty())
        return null

    val removedModuleInfos = indicesToRemove.map { slotIndex ->
        val module = fit.modules.inSlot(slotType, slotIndex)!!
        ModuleInfo(slotIndex, module)
    }

    return object: FitEditingAction() {

        context(FitEditorUndoRedoContext)
        override fun FittingEngine.ModificationScope.performEdit() {
            for (moduleInfo in removedModuleInfos)
                moduleInfo.removeFrom(this, fit)
        }

        context(FitEditorUndoRedoContext)
        override fun FittingEngine.ModificationScope.revertEdit() {
            for (removedModule in removedModuleInfos)
                removedModule.fitTo(this, fit)
        }

    }
}


/**
 * Returns a [FitEditingAction] that removes the module fitted at the given slot.
 */
private fun removeModuleAction(fit: Fit, slotType: ModuleSlotType, slotIndex: Int): FitEditingAction? {
    val module = fit.modules.inSlot(slotType, slotIndex) ?: return null
    val moduleInfo = ModuleInfo(slotIndex, module)

    return object: FitEditingAction() {

        context(FitEditorUndoRedoContext)
        override fun FittingEngine.ModificationScope.performEdit() {
            moduleInfo.removeFrom(this, fit)
        }

        context(FitEditorUndoRedoContext)
        override fun FittingEngine.ModificationScope.revertEdit() {
           moduleInfo.fitTo(this, fit)
        }

    }
}


/**
 * Returns a [FitEditingAction] that rearranges the modules according to the given function.
 */
private fun rearrangeModulesAction(fit: Fit, slotType: ModuleSlotType, newOrder: (Int) -> Int): FitEditingAction {
    val slotCount = fit.modules.slotsInRackCount(slotType)
    val inverse = (0 until slotCount).associateBy { newOrder(it) }

    return object: FitEditingAction() {

        context(FitEditorUndoRedoContext)
        override fun FittingEngine.ModificationScope.performEdit() {
            fit.reorderModules(slotType, newOrder)
        }

        context(FitEditorUndoRedoContext)
        override fun FittingEngine.ModificationScope.revertEdit() {
            fit.reorderModules(slotType, inverse::getValue)
        }

    }
}


/**
 * Returns a [FitEditingAction] that moves the given module slot to the given slot.
 */
private fun moveSlotAction(
    fit: Fit,
    slotGroup: SingleModuleSlot,
    targetSlot: Int,
): FitEditingAction? {
    val slotType = slotGroup.slotType
    val currentSlot = slotGroup.slotIndex
    if (targetSlot == currentSlot)
        return null
    val rearrangeModulesAction = rearrangeModulesAction(
        fit = fit,
        slotType = slotType,
        newOrder = { index ->
            when (index) {
                currentSlot -> targetSlot
                in min(currentSlot, targetSlot) .. max(currentSlot, targetSlot) -> {
                    if (targetSlot > currentSlot)
                        index - 1
                    else
                        index + 1
                }
                else -> index
            }
        }
    )

    return object: FitEditingAction() {

        context(FitEditorUndoRedoContext)
        override fun FittingEngine.ModificationScope.performEdit() {
            rearrangeModulesAction.performEditIn(this)
            selectionModel?.selectModuleSlot(slotType, targetSlot)
        }

        context(FitEditorUndoRedoContext)
        override fun FittingEngine.ModificationScope.revertEdit() {
            rearrangeModulesAction.revertEditIn(this)
            selectionModel?.selectModuleSlot(slotType, currentSlot)
        }

    }
}


/**
 * A [FitEditingAction] for modifying a module's enabled state.
 */
private class ModuleEnabledChange(
    private val fit: Fit,
    private val slotType: ModuleSlotType,
    private val slotIndices: List<Int>,
    private val newState: Boolean
): FitEditingAction() {

    context(FitEditorUndoRedoContext)
    override fun FittingEngine.ModificationScope.performEdit() {
        for (slotIndex in slotIndices) {
            val module = fit.modules.inSlot(slotType, slotIndex)
            module?.setEnabled(newState)
        }
    }

    context(FitEditorUndoRedoContext)
    override fun FittingEngine.ModificationScope.revertEdit() {
        for (slotIndex in slotIndices) {
            val module = fit.modules.inSlot(slotType, slotIndex)
            module?.setEnabled(!newState)
        }
    }

}


/**
 * Returns a [FitEditingAction] that toggles the module group's enabled state.
 */
private fun toggleEnabledStateAction(fit: Fit, slotGroup: ModuleSlotGroup): FitEditingAction {
    val newState = !slotGroup.requireRepModule().enabledState.value
    return ModuleEnabledChange(
        fit = fit,
        slotType = slotGroup.slotType,
        slotIndices = slotGroup.slotIndices,
        newState = newState
    )
}


/**
 * A [FitEditingAction] for modifying a module's state change.
 */
private class ModuleStateChange(
    private val fit: Fit,
    private val slotType: ModuleSlotType,
    private val slotIndices: List<Int>,
    private val prevState: Module.State,
    private val newState: Module.State
): FitEditingAction() {

    context(FitEditorUndoRedoContext)
    override fun FittingEngine.ModificationScope.performEdit() {
        for (slotIndex in slotIndices) {
            val module = fit.modules.inSlot(slotType, slotIndex)
            module?.setState(newState)
        }
    }

    context(FitEditorUndoRedoContext)
    override fun FittingEngine.ModificationScope.revertEdit() {
        for (slotIndex in slotIndices) {
            val module = fit.modules.inSlot(slotType, slotIndex)
            module?.setState(prevState)
        }
    }

}


/**
 * Returns a [FitEditingAction] that toggles the primary state (online <-> active if activeable;
 * offline <-> online if not activeable) of the modules in the group.
 */
fun toggleModulePrimaryStateAction(fit: Fit, slotGroup: ModuleSlotGroup): FitEditingAction {
    val repModule = slotGroup.requireRepModule()
    if (repModule.type.slotType == ModuleSlotType.RIG)
        return toggleEnabledStateAction(fit, slotGroup)

    val prevState = repModule.state
    val newState = if (repModule.type.isActivable) {
        if (prevState != Module.State.ACTIVE) Module.State.ACTIVE else Module.State.ONLINE
    } else {
        if (prevState != Module.State.ONLINE) Module.State.ONLINE else Module.State.OFFLINE
    }

    return ModuleStateChange(
        fit = fit,
        slotType = slotGroup.slotType,
        slotIndices = slotGroup.slotIndices,
        prevState = prevState,
        newState = newState
    )
}


/**
 * Returns a [FitEditingAction] that toggles the online/offline state of the modules in the group.
 */
fun toggleModuleOnlineStateAction(fit: Fit, slotGroup: ModuleSlotGroup): FitEditingAction {
    val repModule = slotGroup.requireRepModule()
    if (repModule.type.slotType == ModuleSlotType.RIG)
        return toggleEnabledStateAction(fit, slotGroup)

    val prevState = repModule.state
    val newState = when {
        prevState != Module.State.OFFLINE -> Module.State.OFFLINE
        repModule.type.isActivable -> Module.State.ACTIVE
        else -> Module.State.ONLINE
    }

    return ModuleStateChange(
        fit = fit,
        slotType = slotGroup.slotType,
        slotIndices = slotGroup.slotIndices,
        prevState = prevState,
        newState = newState
    )
}


/**
 * Returns a [FitEditingAction] that toggles the overloaded state of the modules in the group.
 */
fun toggleModuleOverloadStateAction(fit: Fit, slotGroup: ModuleSlotGroup): FitEditingAction? {
    val repModule = slotGroup.requireRepModule()
    if (!repModule.type.isOverloadable)
        return null

    val prevState = repModule.state
    val newState =
        if (prevState != Module.State.OVERLOADED)
            Module.State.OVERLOADED
        else
            Module.State.ACTIVE

    return ModuleStateChange(
        fit = fit,
        slotType = slotGroup.slotType,
        slotIndices = slotGroup.slotIndices,
        prevState = prevState,
        newState = newState
    )
}


/**
 * Returns a [FitEditingAction] that sets the spoolup cycles of a module.
 */
fun setSpoolupCyclesAction(module: Module, spoolupCycles: Double): FitEditingAction? {
    val prevSpoolupCycles = module.spoolupCycles?.value ?: return null
    val fit = module.fit
    val slotType = module.type.slotType
    val slotIndex = module.indexInRack()

    return object: FitEditingAction() {

        context(FitEditorUndoRedoContext)
        override fun FittingEngine.ModificationScope.performEdit() {
            fit.modules.inSlot(slotType, slotIndex)?.setSpoolupCycles(spoolupCycles)
        }

        context(FitEditorUndoRedoContext)
        override fun FittingEngine.ModificationScope.revertEdit() {
            fit.modules.inSlot(slotType, slotIndex)?.setSpoolupCycles(prevSpoolupCycles)
        }

    }
}


/**
 * Returns a [FitEditingAction] that sets the adaptation cycles of a module.
 */
fun setAdaptationCyclesAction(module: Module, adaptationCycles: Int): FitEditingAction? {
    val prevAdaptationCycles = module.adaptationCycles?.value ?: return null
    val fit = module.fit
    val slotType = module.type.slotType
    val slotIndex = module.indexInRack()

    return object: FitEditingAction() {

        context(FitEditorUndoRedoContext)
        override fun FittingEngine.ModificationScope.performEdit() {
            fit.modules.inSlot(slotType, slotIndex)?.setAdaptationCycles(adaptationCycles)
        }

        context(FitEditorUndoRedoContext)
        override fun FittingEngine.ModificationScope.revertEdit() {
            fit.modules.inSlot(slotType, slotIndex)?.setAdaptationCycles(prevAdaptationCycles)
        }

    }
}


/**
 * A [FitEditingAction] for replacing a loaded charge with another.
 */
private class ChargeReplacement(
    private val fit: Fit,
    val moduleSlotType: ModuleSlotType,
    val moduleSlotIndices: List<Int>,
    val removedChargeType: ChargeType?,
    val fitChargeType: ChargeType?
): FitEditingAction() {


    /**
     * Fits the given charge type to all the modules.
     */
    private fun setCharge(scope: FittingEngine.ModificationScope, chargeType: ChargeType?) {
        with(scope) {
            for (slotIndex in moduleSlotIndices) {
                val module = fit.modules.inSlot(moduleSlotType, slotIndex) ?: continue
                if (chargeType == null)
                    module.removeCharge()
                else
                    module.setCharge(chargeType)
            }
        }
    }


    context(FitEditorUndoRedoContext)
    override fun FittingEngine.ModificationScope.performEdit() {
        setCharge(this, fitChargeType)
    }


    context(FitEditorUndoRedoContext)
    override fun FittingEngine.ModificationScope.revertEdit() {
        setCharge(this, removedChargeType)
    }


}


/**
 * Returns a [FitEditingAction] that sets the charge loaded into the given module.
 */
fun setChargeAction(fit: Fit, moduleSlotGroup: ModuleSlotGroup, chargeType: ChargeType?): FitEditingAction? {
    val targetModule = moduleSlotGroup.requireRepModule()
    if (chargeType == targetModule.loadedCharge?.type)
        return null

    return ChargeReplacement(
        fit = fit,
        moduleSlotType = moduleSlotGroup.slotType,
        moduleSlotIndices = moduleSlotGroup.slotIndices,
        removedChargeType = targetModule.loadedCharge?.type,
        fitChargeType = chargeType
    )
}


/**
 * The indicator of the reason why the module can't be fitted, in the auto-suggest list.
 */
@Composable
private fun CannotFitModuleIndicator(

    /**
     * The text to display.
     */
    text: String,

    /**
     * Whether to display an animation that highlights this indicator temporarily.
     * This is set to `true` when the user attempts to select the module, and is immediately set back to `false`.
     */
    highlight: Boolean

) {
    val scaleAnimation = remember { ScaleAnimationToAttractAttention() }
    LaunchedEffect(highlight) {
        if (highlight)
            scaleAnimation.animate()
    }

    SingleLineText(
        text = text,
        color = TheorycrafterTheme.colors.base().errorContent,
        style = TheorycrafterTheme.textStyles.caption,
        modifier = Modifier.scale(scaleAnimation.value)
    )
}


/**
 * Returns the module auto-suggest for the given slot group.
 */
@Composable
private fun ModuleSlotGroup.rememberAutoSuggest(
    fit: Fit,
    carousel: Carousel<ModuleType>?
): AutoSuggest<ModuleType> {
    val autoSuggest = TheorycrafterContext.autoSuggest.rememberForModules(fit.ship, slotType)
    val tournamentRules = TheorycrafterContext.tournaments.activeRules
    val isFlagship = fit.isFlagship
    return remember(autoSuggest, carousel, fit, isFlagship, tournamentRules) {
        val variations = carousel?.items
        val autoSuggestWithVariations = autoSuggest.onEmptyQueryReturn { variations }

        if (tournamentRules == null)
            autoSuggestWithVariations
        else {
            val shipType = fit.ship.type
            autoSuggestWithVariations.filterResults { moduleType ->
                tournamentRules.isModuleLegal(moduleType, shipType, isFlagship)
            }
        }
    }
}


/**
 * The footer for the suggested modules dropdown menu.
 */
@Composable
private fun SuggestedModulesFooter(
    fit: Fit,
    slotGroup: ModuleSlotGroup,
) {
    FitEditorSuggestedItemsFooter {
        SingleLineText("Available:")

        HSpacer(TheorycrafterTheme.spacing.larger)

        val currentModule = slotGroup.repModule
        if (slotGroup.slotType == ModuleSlotType.RIG) {
            FitResourceAvailableInAutoSuggestFooter(
                resource = fit.fitting.calibration,
                currentItemNeed = currentModule?.calibrationNeed?.value?.let { it * slotGroup.slotCount },
                valueToText = { it.asCalibration(withUnits = true) },
            )
        }
        else {
            FitResourceAvailableInAutoSuggestFooter(
                resource = fit.fitting.power,
                currentItemNeed = currentModule?.powerNeed?.value?.let { it * slotGroup.slotCount },
                valueToText = { it.asPower(withUnits = true) },
            )

            HSpacer(TheorycrafterTheme.spacing.small)

            FitResourceAvailableInAutoSuggestFooter(
                resource = fit.fitting.cpu,
                currentItemNeed = currentModule?.cpuNeed?.value?.let { it * slotGroup.slotCount },
                valueToText = { it.asCpu(withUnits = true) },
            )
        }

        if (TheorycrafterContext.settings.prices.showInFitEditorSuggestedItems) {
            Spacer(Modifier.width(TheorycrafterTheme.sizes.fitEditorSuggestedItemsPriceWidth))
        }
    }
}


/**
 * The resource needs of a module we compute on a temporary fit.
 */
@Immutable
private data class ModuleResourceNeeds(
    val power: Double?,
    val cpu: Double?
)


/**
 * Returns a [ModuleResourceNeeds] with the actual resource needs of the given module when fitted into the current fit.
 *
 * This allows to accurately display the resource needs of modules where the ship has a bonus or penalty to the resource
 * needs. For example, Attack BCs have bonuses to fitting large weapons.
 */
@Composable
private fun realResourceNeeds(moduleType: ModuleType): ModuleResourceNeeds? {
    if ((moduleType.powerNeed == null) && (moduleType.cpuNeed == null))
        return ModuleResourceNeeds(null, null)

    val tempFittingScope = LocalTempFittingScopeProvider.current.getTempFittingScope()
    return produceState<ModuleResourceNeeds?>(
        initialValue = null,
        key1 = moduleType,
        key2 = tempFittingScope
    ) {
        value = computeRealResourceNeeds(
            tempFittingScope = tempFittingScope,
            moduleType = moduleType
        )
    }.value
}


/**
 * Actually computes the real [ModuleResourceNeeds] of the given module.
 */
private suspend fun computeRealResourceNeeds(
    tempFittingScope: TempFittingScope,
    moduleType: ModuleType
): ModuleResourceNeeds {
    with(tempFittingScope) {
        // Use a mutex because:
        // - This function is called for every item in the auto-suggest list
        // - We're calling modify twice
        // so a context switch to another realResourceNeeds producer can occur between the 1st and 2nd
        // calls to modify, which will crash because slot 0 is already taken.
        mutex.withLock {
            var module: Module? = null
            try {
                modify {
                    module = tempFit.fitModule(moduleType, 0)
                }
                return ModuleResourceNeeds(
                    power = module?.powerNeed?.value,
                    cpu = module?.cpuNeed?.value
                )
            } finally {
                module?.let {
                    withContext(NonCancellable) {
                        modify {
                            tempFit.removeModule(it)
                        }
                    }
                }
            }
        }
    }
}


/**
 * The 2nd part of the suggested module row in the auto-suggest dropdown, where the module resource use is displayed.
 */
@Composable
@Suppress("UnusedReceiverParameter")
private fun RowScope.SuggestedModuleResourceUse(


    /**
     * The fit.
     */
    fit: Fit,


    /**
     * The currently fitted slot group.
     */
    currentSlotGroup: ModuleSlotGroup,


    /**
     * The suggested module type.
     */
    suggestedModule: ModuleType,


    /**
     * The suggested number of modules.
     */
    suggestedModuleCount: Int,


) {
    val moduleResourceNeeds = realResourceNeeds(suggestedModule)

    val currentModule = currentSlotGroup.repModule
    if (currentSlotGroup.slotType == ModuleSlotType.RIG) {
        ItemResourceNeedInSuggestedItemsRow(
            resource = fit.fitting.calibration,
            resourceNeed = suggestedModule.calibrationNeed?.let { it * suggestedModuleCount },
            currentItemNeed = currentModule?.calibrationNeed?.value?.let { it * currentSlotGroup.slotCount },
            valueToText = { it.asCalibration(withUnits = true) },
        )
    }
    else {
        ItemResourceNeedInSuggestedItemsRow(
            resource = fit.fitting.power,
            resourceNeed = moduleResourceNeeds?.power?.let { it * suggestedModuleCount },
            currentItemNeed = currentModule?.powerNeed?.value?.let { it * currentSlotGroup.slotCount },
            valueToText = { it.asPower(withUnits = true) },
        )

        HSpacer(TheorycrafterTheme.spacing.small)

        ItemResourceNeedInSuggestedItemsRow(
            resource = fit.fitting.cpu,
            resourceNeed = moduleResourceNeeds?.cpu?.let { it * suggestedModuleCount },
            currentItemNeed = currentModule?.cpuNeed?.value?.let { it * currentSlotGroup.slotCount },
            valueToText = { it.asCpu(withUnits = true) },
            width = TheorycrafterTheme.sizes.fitEditorSuggestedItemsResourceUseWidth
        )
    }
}


/**
 * Returns a [ModuleSelectorProvider] that provides [ModuleSelector].
 */
@Stable
private fun ModuleSelector(slotGroup: ModuleSlotGroup, slotActions: ModuleSlotActions) = ModuleSelectorProvider {
        scope,
        carousel,
        onModuleSelected,
        onEditingCancelled ->
    with(scope) {
        ModuleSelector(
            slotGroup = slotGroup,
            carousel = carousel,
            cannotFitReason = slotActions.cannotFitReason,
            onModuleSelected = onModuleSelected,
            onEditingCancelled = onEditingCancelled
        )
    }
}


/**
 * A module selection widget.
 */
@Composable
private fun GridScope.GridRowScope.ModuleSelector(
    slotGroup: ModuleSlotGroup,
    carousel: Carousel<ModuleType>?,
    cannotFitReason: (ModuleType) -> String?,  // Returns the reason why the module type can't be chosen
    onModuleSelected: (ModuleType) -> Unit,
    onEditingCancelled: () -> Unit
) {
    val fit = slotGroup.fit
    val autoSuggest = slotGroup.rememberAutoSuggest(fit, carousel)
    val skillSet = fit.character.skillSet
    var unfittableModuleTypeUserTriedToSelect: ModuleType? by remember { mutableStateOf(null) }
    val moduleSlotGroupsState = LocalModuleSlotGroupsState.current
    val tournamentRules = TheorycrafterContext.tournaments.activeRules

    ItemSelectorRow(
        onItemSelected = onModuleSelected,
        onEditingCancelled = onEditingCancelled,
        onBeforeItemChosen = { moduleType ->
            return@ItemSelectorRow (cannotFitReason(moduleType) == null).also { canFit ->
                if (!canFit)
                    unfittableModuleTypeUserTriedToSelect = moduleType
            }
        },
        autoSuggest = autoSuggest,
        suggestedItemsFooter = {
            SuggestedModulesFooter(
                fit = fit,
                slotGroup = slotGroup,
            )
        },
        suggestedItemContent = { suggestedModuleType, _ ->
            DefaultSuggestedEveItemTypeIcon(suggestedModuleType)

            val isValid = skillSet.fulfillsAllRequirements(suggestedModuleType)
            val suggestedSlotGroup = updatedSlotGroupWhenFittingModule(
                fit = fit,
                moduleType = suggestedModuleType,
                currentGroup = slotGroup,
                groupWeapons = moduleSlotGroupsState.groupWeapons
            )
            Text(
                text = if (suggestedSlotGroup is MultiModuleSlot)
                    "${suggestedSlotGroup.slotCount}x ${suggestedModuleType.name}"
                else
                    suggestedModuleType.name,
                color = TheorycrafterTheme.colors.invalidContent(valid = isValid),
                modifier = Modifier.weight(1f)
            )

            HSpacer(TheorycrafterTheme.spacing.medium)

            val reason = cannotFitReason(suggestedModuleType)
            if (reason != null) {
                val userTriedToSelectThisModuleType = suggestedModuleType == unfittableModuleTypeUserTriedToSelect
                CannotFitModuleIndicator(
                    text = reason,
                    highlight = userTriedToSelectThisModuleType
                )

                // After triggering the animation, we clear it right away, so that if the user selects it again,
                // we can trigger the animation again.
                if (userTriedToSelectThisModuleType)
                    unfittableModuleTypeUserTriedToSelect = null
            }
            else {
                SuggestedModuleResourceUse(
                    fit = fit,
                    currentSlotGroup = slotGroup,
                    // Note that we can't pass suggestedSlotGroup here because ModuleSlotGroup only keeps slot
                    // indices, not modules, and the modules have not been fitted yet.
                    suggestedModule = suggestedModuleType,
                    suggestedModuleCount = suggestedSlotGroup.slotCount
                )
            }

            if (TheorycrafterContext.settings.prices.showInFitEditorSuggestedItems) {
                ItemPrice(
                    itemType = suggestedModuleType,
                    textAlign = TextAlign.End,
                    modifier = Modifier
                        .width(TheorycrafterTheme.sizes.fitEditorSuggestedItemsPriceWidth)
                )
            }
        },
        hint = if (slotGroup.slotType == ModuleSlotType.RIG) "Rig name" else "Module name",
        marketGroupsParent = with(TheorycrafterContext.eveData.marketGroups) {
            if (slotGroup.slotType == ModuleSlotType.RIG) rigs else shipEquipment
        },
        itemFilter = {
            (it is ModuleType) &&
                    (it.slotType == slotGroup.slotType) &&
                    fit.ship.canFit(it) &&
                    (tournamentRules?.isModuleLegal(it, fit.ship.type, fit.isFlagship) != false)
        },
        showMarketInitially = slotGroup.repModule == null  // Because we show the carousel items in this case
    )
}


/**
 * Returns the charge auto-suggest for the given module type.
 */
@Composable
private fun ModuleType.rememberChargeAutoSuggest(carousel: Carousel<ChargeType?>): AutoSuggest<ChargeType> {
    val autoSuggest = TheorycrafterContext.autoSuggest.rememberForChargeTypes(this)
    val tournamentRules = TheorycrafterContext.tournaments.activeRules

    return remember(autoSuggest, carousel, tournamentRules) {
        val variations = carousel.items.filterNotNull()
        val autoSuggestWithVariations = autoSuggest.onEmptyQueryReturn { variations }

        if (tournamentRules == null)
            autoSuggestWithVariations
        else {
            autoSuggestWithVariations.filterResults { chargeType ->
                tournamentRules.isChargeLegal(chargeType, this)
            }
        }
    }
}


/**
 * A charge selection widget.
 */
@Composable
private fun GridScope.GridRowScope.ChargeSelectorRow(
    module: Module,
    carousel: Carousel<ChargeType?>,
    onChargeSelected: (ChargeType) -> Unit,
    onEditingCancelled: () -> Unit
) {
    val autoSuggest = module.type.rememberChargeAutoSuggest(carousel = carousel)
    val tournamentRules = TheorycrafterContext.tournaments.activeRules

    ItemSelectorRow(
        onItemSelected = onChargeSelected,
        onEditingCancelled = onEditingCancelled,
        autoSuggest = autoSuggest,
        hint = "Charge name",
        suggestedItemContent = { chargeType, _ ->
            val amount = maxLoadedChargeAmount(module.type, chargeType)
            DefaultSuggestedEveItemTypeIcon(chargeType)
            Text(
                if (isScript(module.type, chargeType) || (amount == null))
                    chargeType.name
                else
                    "${amount}x ${chargeType.name}"
            )
            if (TheorycrafterContext.settings.prices.showInFitEditorSuggestedItems) {
                Spacer(Modifier.weight(1f).widthIn(min = TheorycrafterTheme.spacing.medium))
                ItemPrice(
                    itemType = chargeType,
                    amount = maxLoadedChargeAmount(module.type, chargeType) ?: 1,
                    textAlign = TextAlign.End,
                    modifier = Modifier.width(TheorycrafterTheme.sizes.fitEditorSuggestedItemsPriceWidth)
                )
            }
        },
        marketGroupsParent = TheorycrafterContext.eveData.marketGroups.ammoAndCharges,
        itemFilter = {
            (it is ChargeType) &&
                    module.type.canLoadCharge(it) &&
                    tournamentRules.isChargeLegal(it, module.type)
        },
        showMarketInitially = false
    )
}


/**
 * Returns whether the given charge is a script.
 */
private fun isScript(moduleType: ModuleType, chargeType: ChargeType): Boolean{
    return (chargeType.volume == 1.0) && (moduleType.chargeCapacity == 1.0) && chargeType.name.endsWith("Script")
}


/**
 * Displays the amount of powergrid a module uses.
 */
@Composable
private fun Powergrid(module: Module, moduleCount: Int) {
    val powerProperty = module.powerNeed ?: return
    val modulePower = powerProperty.value
    Text((modulePower * moduleCount).asPower(withUnits = false))
}


/**
 * Displays the amount of CPU a module uses.
 */
@Composable
private fun Cpu(module: Module, moduleCount: Int) {
    val cpuProperty = module.cpuNeed ?: return
    val moduleCpu = cpuProperty.value
    Text((moduleCpu * moduleCount).asCpu(withUnits = false))
}


/**
 * A [ModuleSlotContentProvider] that provides [ModuleSlotContent].
 */
private val ModuleSlotContent = ModuleSlotContentProvider {
        scope: GridScope.GridRowScope,
        module: Module,
        moduleCount: Int?,
        carousel: Carousel<ModuleType>,
        togglePrimaryState: () -> Unit,
        toggleOverloaded: () -> Unit,
        toggleOnline: () -> Unit,
        showSpoolupCyclesSelector: () -> Unit,
        showAdaptationCyclesSelector: () -> Unit,
    ->
    with(scope) {
        ModuleSlotContent(
            module = module,
            moduleCount = moduleCount,
            carousel = carousel,
            togglePrimaryState = togglePrimaryState,
            toggleOverloaded = toggleOverloaded,
            toggleOnline = toggleOnline,
            showSpoolupCyclesSelector = showSpoolupCyclesSelector,
            showAdaptationCyclesSelector = showAdaptationCyclesSelector,
        )
    }
}


/**
 * The row for a slot with a (non-`null`) module.
 */
@Composable
private fun GridScope.GridRowScope.ModuleSlotContent(
    module: Module,
    moduleCount: Int?,
    carousel: Carousel<ModuleType>,
    togglePrimaryState: () -> Unit,
    toggleOverloaded: () -> Unit,
    toggleOnline: () -> Unit,
    showSpoolupCyclesSelector: () -> Unit,
    showAdaptationCyclesSelector: () -> Unit,
) {
    cell(cellIndex = GridCols.STATE_ICON) {
        ModuleStateIcon(
            module = module,
            togglePrimaryState = togglePrimaryState,
            toggleOverloaded = toggleOverloaded,
            toggleOnline = toggleOnline
        )
    }
    cell(cellIndex = GridCols.TYPE_ICON) {
        TypeIconCellContent(module)
    }
    cell(cellIndex = GridCols.NAME) {
        VerticallyCenteredRow {
            CarouselSlotContent(
                carousel = carousel,
                itemType = module.type,
                modifier = Modifier.fillMaxWidth(),
                text = { moduleType ->
                    if (moduleCount == null)
                        moduleType.name
                    else
                        "${moduleCount}x ${moduleType.name}"
                },
                extraContent = {
                    if (module.spoolupCycles != null) {
                        HSpacer(TheorycrafterTheme.spacing.medium)
                        SpoolupCyclesIndicator(module, showSpoolupCyclesSelector)
                    }
                    if (module.adaptationCycles != null) {
                        HSpacer(TheorycrafterTheme.spacing.medium)
                        AdaptationCyclesIndicator(module, showAdaptationCyclesSelector)
                    }
                }
            )
        }
    }
    cell(cellIndex = GridCols.POWER) {
        Powergrid(module, moduleCount ?: 1)
    }
    cell(cellIndex = GridCols.CPU) {
        Cpu(module, moduleCount ?: 1)
    }
    cell(cellIndex = GridCols.RANGE) {
        TextAndTooltipCell(displayedModuleRange(module))
    }
    cell(cellIndex = GridCols.EFFECT) {
        TextAndTooltipCell(displayedModuleEffect(module.fit, module, moduleCount))
    }
    PriceCell(module.type, amount = moduleCount ?: 1)
}


/**
 * The icon representing the module state.
 */
@Composable
fun ModuleStateIcon(
    module: Module,
    modifier: Modifier = Modifier,
    togglePrimaryState: () -> Unit,
    toggleOverloaded: () -> Unit,
    toggleOnline: () -> Unit
) {
    val displayedState = when (module.type.slotType) {
        ModuleSlotType.RIG -> if (module.enabled) Module.State.ONLINE else Module.State.OFFLINE
        else -> module.state
    }
    Icons.ModuleState(
        displayedState,
        modifier = modifier
            .onMousePress(MouseButton.Left, consumeEvent = true) { // Consume to prevent selecting the row
                togglePrimaryState()
            }
            .onMousePress(MouseButton.Right, consumeEvent = true) {  // Consume to prevent opening the context menu
                toggleOverloaded()
            }
            .onMousePress(MouseButton.Middle, consumeEvent = true) {  // Consume just in case
                toggleOnline()
            }
            .thenIf(hostOs == OS.MacOS) {
                // On macOS ctrl-click is semantically the same as right-click: it opens the context menu, so we
                // should invoke the same behavior as right-click.
                // Since ctrl-click is taken, we use alt-click for toggling the online state
                // Consume to prevent opening the context menu
                onMousePress(MouseButton.Left, KeyboardModifierMatcher.Ctrl, consumeEvent = true) {
                    toggleOverloaded()
                }
            }
    )
}


/**
 * The widget used to show the spoolup or adaptation cycles of a module.
 */
@Composable
fun <T: Number> CyclesIndicator(
    value: T,
    maxValue: T,
    valueToString: (T) -> String,
    showProgress: Boolean,
    tooltipText: (cyclesText: String) -> String,
    showCyclesSelector: () -> Unit,
) {
    val cyclesText = if (value == maxValue) "max" else valueToString(value)
    val borderColor = TheorycrafterTheme.colors.base().primary
    val borderShape = RoundedCornerShape(6.dp)
    VerticallyCenteredRow(
        modifier = Modifier
            .padding(vertical = 1.dp)
            .clip(borderShape)
            .border(1.5.dp, borderColor, borderShape)
            .thenIf(showProgress) {
                drawBehind {
                    val spoolupFraction = (value.toFloat() / maxValue.toFloat())
                    drawRect(borderColor, size = Size(width = spoolupFraction * size.width, height = size.height))
                }
            }
            .backgroundOnHover(color = borderColor.copy(alpha = 0.2f))
            .onMousePress(consumeEvent = true) { showCyclesSelector() }
            .tooltip(tooltipText(cyclesText))
            .padding(horizontal = TheorycrafterTheme.spacing.medium),
        horizontalArrangement = Arrangement.Center,
    ) {
        Icons.SpoolupCycles(Modifier.padding(vertical = TheorycrafterTheme.spacing.xxsmall))
        HSpacer(TheorycrafterTheme.spacing.xxxsmall)

        val textStyle = LocalTextStyle.current
        val fontMetrics = rememberLocalFontMetrics(textStyle)
        SingleLineText(
            text = cyclesText,
            style = textStyle,
            modifier = Modifier.absoluteOffset {
                val verticalOffset = -0.2f * fontMetrics.xHeight
                IntOffset(x = 0, y = verticalOffset.toInt())
            }
        )
    }
}


/**
 * The widget showing the module's spoolup cycles.
 */
@Composable
fun SpoolupCyclesIndicator(
    module: Module,
    showSpoolupCyclesSelector: () -> Unit,
) {
    val spoolupCycles = module.spoolupCycles?.value ?: return
    val maxSpoolupCycles = module.maxSpoolupCycles ?: return
    CyclesIndicator(
        value = spoolupCycles,
        maxValue = maxSpoolupCycles,
        valueToString = Double::asSpoolupCycles,
        showProgress = true,
        tooltipText = { "Spoolup cycles: $it" },
        showCyclesSelector = showSpoolupCyclesSelector,
    )
}


/**
 * The widget showing the module's adaptation cycles.
 */
@Composable
fun AdaptationCyclesIndicator(
    module: Module,
    showAdaptationCyclesSelector: () -> Unit
) {
    val adaptationCycles = module.adaptationCycles?.value ?: return
    CyclesIndicator(
        value = adaptationCycles,
        maxValue = AdaptationCycles.Maximum.cycleCount,
        valueToString = Int::toString,
        showProgress = false,
        tooltipText = { "Adaptation cycles: $it" },
        showCyclesSelector = showAdaptationCyclesSelector,
    )
}


/**
 * The row for a slot with a (non-`null`) charge.
 */
@Composable
private fun GridScope.GridRowScope.ChargeSlotContent(
    charge: Charge?,
    moduleType: ModuleType,
    carousel: Carousel<ChargeType?>,
    textStyle: TextStyle,
    modifier: Modifier
) {
    val chargeAmount = charge?.let { maxLoadedChargeAmount(moduleType, it.type) }

    emptyCell(cellIndex = GridCols.STATE_ICON)  // No state icon

    cell(cellIndex = GridCols.TYPE_ICON) {
        TypeIconCellContent(charge)
    }

    cell(
        cellIndex = GridCols.NAME,
        colSpan = GridCols.EFFECT - GridCols.NAME,
        modifier = modifier
    ) {
        ProvideTextStyle(textStyle) {
            Row(
                modifier = Modifier.fillMaxWidth()
            ) {
                Text(text = "└ ")

                CarouselSlotContent(
                    carousel = carousel,
                    itemType = charge?.type,
                    modifier = Modifier.weight(1.0f),
                    text = { chargeType ->
                        when {
                            chargeType == null -> "No charge loaded"
                            isScript(moduleType, chargeType) || (chargeAmount == null) -> chargeType.name
                            else -> "${chargeType.name} ($chargeAmount)"
                        }
                    }
                )
            }
        }
    }

    if (charge == null) {
        EmptyPriceCell()
    } else {
        PriceCell(charge.type, amount = chargeAmount ?: 1)
    }
}


/**
 * Bundles the actions passed to [ModuleSlotRow].
 */
@Immutable
class ModuleSlotActions(
    val cannotFitReason: (ModuleType) -> String?,
    private val fit: (ModuleType, preserveState: Boolean, triggerCarouselAnimation: Boolean) -> Unit,
    // Returns an action to replace the module, without performing it.
    val replaceModuleAction: (ModuleType) -> FitEditingAction?,
    val canMoveModules: () -> Boolean,
    val moveModuleTo: ((targetSlot: Int) -> Unit)?,
    val canMoveModuleBy: ((slotOffset: Int) -> Boolean)?,
    val moveModuleBy: ((slotOffset: Int) -> Unit)?,
    val clear: () -> Unit,
    val togglePrimaryState: () -> Unit,
    val toggleOnlineState: () -> Unit,
    val toggleOverloadState: () -> Unit,
    val addOne: () -> Unit,
    val removeOne: () -> Unit,
    val setSpoolupCycles: (Module, Double) -> Unit,
    val setAdaptationCycles: (Module, Int) -> Unit,
) {

    fun fitModule(
        moduleType: ModuleType,
        preserveState: Boolean,
        triggerCarouselAnimation: Boolean = false
    ) {
        fit(moduleType, preserveState, triggerCarouselAnimation)
    }

    fun canMoveSlotUp() = canMoveModuleBy?.invoke(-1) ?: false

    fun moveSlotUp() {
        moveModuleBy?.invoke(-1)
    }

    fun canMoveSlotDown() = canMoveModuleBy?.invoke(1) ?: false

    fun moveSlotDown() {
        moveModuleBy?.invoke(1)
    }

}


/**
 * Returns a [SlotContextAction] for pasting a module into the given slot.
 */
@Composable
private fun SlotContextAction.Companion.rememberPasteModule(
    slotType: ModuleSlotType,
    actions: ModuleSlotActions
): SlotContextAction {
    val snackbarHostState = LocalSnackbarHost.current
    val coroutineScope = rememberCoroutineScope()
    return rememberPastePossiblyDynamicItem(
        dynamicItemFromClipboardText = ::dynamicModuleFromClipboardText,
        localItemFromClipboardText = ::moduleFromClipboardText,
        pasteItem = remember(slotType, actions, snackbarHostState, coroutineScope) {
            fun (moduleType: ModuleType) {
                val errorMessage = if (slotType != moduleType.slotType)
                    "Wrong slot type"
                else
                    actions.cannotFitReason(moduleType)

                if (errorMessage != null)
                    coroutineScope.launch { snackbarHostState.showSnackbar("$errorMessage (${moduleType.name})") }
                else
                    actions.fitModule(moduleType, preserveState = false)
            }
        }
    )
}


/**
 * The [SlotContextAction] for reverting a module to its base type.
 */
private fun SlotContextAction.Companion.revertToBase(
    slotGroup: ModuleSlotGroup,
    actions: ModuleSlotActions,
): SlotContextAction? {
    val moduleType = slotGroup.repModule?.type ?: return null
    return revertToBase(
        itemType = moduleType,
        action = { actions.fitModule(moduleType.baseType, preserveState = true) }
    )
}


/**
 * Returns a [SlotContextAction] to set the number of spoolup cycles of a triglavian module.
 */
private fun SlotContextAction.Companion.setSpoolupCycles(
    module: Module,
    showSpoolupCyclesSelector: () -> Unit,
): SlotContextAction {
    return SlotContextAction(
        displayName = "Set Spoolup Cycles…",
        enabled = module.spoolupCycles != null,
        icon = { Icons.SpoolupCycles() },
        shortcut = FitEditorKeyShortcuts.SetSpoolupCycles,
        action = showSpoolupCyclesSelector
    )
}


/**
 * A dialog to let the user select a spoolup cycles value.
 */
@Composable
private fun SpoolupCyclesDialog(
    value: Double,
    maxValue: Double,
    setValue: (Double) -> Unit,
    dismiss: () -> Unit
) {
    var textValue by remember {
        val text = if (value == maxValue) "max" else value.asSpoolupCycles()
        mutableStateOf(
            TextFieldValue(
                text = text,
                selection = TextRange(0, text.length)
            )
        )
    }
    fun enteredValueOrNull(): Double? {
        val text = textValue.text
        if (text.equals("max", ignoreCase = true))
            return maxValue
        return text.toIntOrNull()?.takeIf {
            (it >= 0) && (it <= maxValue.toInt())
        }?.toDouble()
    }

    InnerDialog(
        title = "Spoolup Cycles",
        confirmText = "Set",
        confirmEnabled = enteredValueOrNull() != null,
        onConfirm = { enteredValueOrNull()?.let(setValue) },
        extraButtons = @Composable {
            FlatButtonWithText(
                text = "Set Max.",
                onClick = {
                    setValue(maxValue)
                    dismiss()
                }
            )
            FlatButtonWithText(
                text = "Set Zero",
                onClick = {
                    setValue(0.0)
                    dismiss()
                }
            )
        },
        onDismiss = dismiss,
    ) {
        VerticallyCenteredRow(horizontalArrangement = Arrangement.End) {
            TheorycrafterTheme.OutlinedTextField(
                value = textValue,
                onValueChange = { textValue = it },
                singleLine = true,
                textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.End),
                isError = enteredValueOrNull() == null,
                modifier = Modifier
                    .weight(1f)
                    .onKeyShortcut(KeyShortcut.anyEnter(), onPreview = true) {
                        enteredValueOrNull()?.let {
                            setValue(it)
                            dismiss()
                        }
                    }
                    .requestInitialFocus(),
            )
            HSpacer(TheorycrafterTheme.spacing.medium)
            SingleLineText(
                text = "(0..${maxValue.toInt()} or \"max\")",
                style = TheorycrafterTheme.textStyles.caption
            )
        }
    }
}


/**
 * Returns a [SlotContextAction] to set the number of adaptation cycles a module.
 */
private fun SlotContextAction.Companion.setAdaptationCycles(
    module: Module,
    showAdaptationCyclesSelector: () -> Unit,
): SlotContextAction {
    return SlotContextAction(
        displayName = "Set Adaptation Cycles…",
        enabled = module.adaptationCycles != null,
        icon = { Icons.AdaptationCycles() },
        shortcut = FitEditorKeyShortcuts.SetAdaptationCycles,
        action = showAdaptationCyclesSelector
    )
}


/**
 * A dialog to let the user select an adaptation cycles value.
 */
@Composable
private fun AdaptationCyclesDialog(
    value: Int,
    maxValue: Int,
    setValue: (Int) -> Unit,
    dismiss: () -> Unit
) {
    var textValue by remember {
        val text = if (value == maxValue) "max" else value.toString()
        mutableStateOf(
            TextFieldValue(
                text = text,
                selection = TextRange(0, text.length)
            )
        )
    }
    fun enteredValueOrNull(): Int? {
        val text = textValue.text
        if (text.equals("max", ignoreCase = true))
            return maxValue
        return text.toIntOrNull()?.takeIf { it >= 0 }
    }

    InnerDialog(
        title = "Adaptation Cycles",
        confirmText = "Set",
        confirmEnabled = enteredValueOrNull() != null,
        onConfirm = { enteredValueOrNull()?.let(setValue) },
        extraButtons = @Composable {
            FlatButtonWithText(
                text = "Set Max.",
                onClick = {
                    setValue(maxValue)
                    dismiss()
                }
            )
            FlatButtonWithText(
                text = "Set Zero",
                onClick = {
                    setValue(0)
                    dismiss()
                }
            )
        },
        onDismiss = dismiss,
    ) {
        VerticallyCenteredRow(horizontalArrangement = Arrangement.End) {
            TheorycrafterTheme.OutlinedTextField(
                value = textValue,
                onValueChange = { textValue = it },
                singleLine = true,
                textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.End),
                isError = enteredValueOrNull() == null,
                modifier = Modifier
                    .weight(1f)
                    .onKeyShortcut(KeyShortcut.anyEnter(), onPreview = true) {
                        enteredValueOrNull()?.let {
                            setValue(it)
                            dismiss()
                        }
                    }
                    .requestInitialFocus(),
            )
            HSpacer(TheorycrafterTheme.spacing.medium)
            SingleLineText(
                text = "number of cycles or \"max\")",
                style = TheorycrafterTheme.textStyles.caption
            )
        }
    }
}


/**
 * The interface for providing the module slot content for [ModuleSlotRow].
 */
@Stable
fun interface ModuleSlotContentProvider {

    @Composable
    fun content(
        scope: GridScope.GridRowScope,
        module: Module,
        moduleCount: Int?,
        carousel: Carousel<ModuleType>,
        togglePrimaryState: () -> Unit,
        toggleOverloaded: () -> Unit,
        toggleOnline: () -> Unit,
        showSpoolupCyclesSelector: () -> Unit,
        showAdaptationCyclesSelector: () -> Unit,
    )

}


/**
 * The interface for providing the module selector for [ModuleSlotRow].
 */
@Stable
fun interface ModuleSelectorProvider {

    @Composable
    fun content(
        scope: GridScope.GridRowScope,
        carousel: Carousel<ModuleType>?,
        onModuleSelected: (ModuleType) -> Unit,
        onEditingCancelled: () -> Unit
    )

}


/**
 * The row displaying a module slot, which may or may not contain a module.
 */
@Composable
fun GridScope.ModuleSlotRow(
    testTag: String,
    slotGroup: ModuleSlotGroup,
    illegalityInTournamentReason: String? = null,
    actions: ModuleSlotActions,
    slotContentProvider: ModuleSlotContentProvider,
    moduleSelectorProvider: ModuleSelectorProvider,
) {
    CompositionCounters.recomposed(testTag)

    val module = slotGroup.repModule
    val moduleType = module?.type
    val fit = slotGroup.fit
    val isFlagship = fit.isFlagship
    val carousel = moduleType?.let {
        rememberModuleCarousel(it, fit.ship.type, isFlagship, TheorycrafterContext.tournaments.activeRules)
    }
    var moduleTypeBeingMutated: ModuleType? by remember { mutableStateOf(null) }
    var showSpoolupCyclesSelector by remember { mutableStateOf(false) }
    var showAdaptationCyclesSelector by remember { mutableStateOf(false) }

    val contextActions = rememberModuleSlotContextActions(
        slotGroup = slotGroup,
        actions = actions,
        module = module,
        openMutationEditWindow = { moduleTypeBeingMutated = it },
        showSpoolupCyclesSelector = { showSpoolupCyclesSelector = true},
        showAdaptationCyclesSelector = { showAdaptationCyclesSelector = true },
    )

    var keyShortcuts: Modifier = Modifier
    if (carousel != null) {
        // The legalPrev and legalNext here are a temporary workaround to prevent crashing when the prev/next module
        // is illegal to fit (a 2nd ancillary armor repairer, for example). It's not a good solution at least because
        // when skipping an illegal item, the prev/next animation breaks.
        // The long-term solution is for the fitting engine to report illegal modules rather than forbid them from
        // being fitted.
        keyShortcuts = keyShortcuts
            .carouselShortcuts(
                prev = { carousel.legalPrev(actions, it ) },
                next = { carousel.legalNext(actions, it ) },
                itemType = moduleType
            ) {
                actions.fitModule(it, preserveState = true, triggerCarouselAnimation = true)
            }
    }

    val slotGroupIllegalityReason = slotGroup.allModules.firstNotNullOfOrNull { it?.illegalFittingReason }

    SlotRow(
        modifier = Modifier.testTag(testTag),
        modifierWhenNotEditing = Modifier
            .then(keyShortcuts)
            .thenIf(module != null) {
                rowLevelModuleStateMouseActions(actions)
            }.thenIf((module != null) && actions.canMoveModules()) {
                dragModuleToReorder(module!!, actions)
            },
        contextActions = contextActions,
        invalidityReason = slotGroupIllegalityReason ?: illegalityInTournamentReason,
        editedRowContent = { onEditingCompleted ->
            moduleSelectorProvider.content(
                scope = this,
                carousel = carousel,
                onModuleSelected = { newModuleType ->
                    val preserveModuleState = carousel?.items?.contains(newModuleType) ?: false
                    actions.fitModule(newModuleType, preserveState = preserveModuleState)
                    onEditingCompleted()
                },
                onEditingCancelled = onEditingCompleted
            )
        }
    ) {
        if (module == null) {
            EmptyRowContent("Empty Slot")
        } else {
            slotContentProvider.content(
                scope = this,
                module = module,
                moduleCount = if (slotGroup is MultiModuleSlot) slotGroup.slotCount else null,
                carousel = carousel!!,
                togglePrimaryState = actions.togglePrimaryState,
                toggleOnline = actions.toggleOnlineState,
                toggleOverloaded = actions.toggleOverloadState,
                showSpoolupCyclesSelector = { showSpoolupCyclesSelector = true },
                showAdaptationCyclesSelector = { showAdaptationCyclesSelector = true },
            )
        }
    }

    moduleTypeBeingMutated?.let {
        LocalModuleMutationDialog(
            slotGroup = slotGroup as SingleModuleSlot,
            moduleType = it,
            actions = actions,
            undoRedoQueue = LocalFitEditorUndoRedoQueue.current,
            onCloseRequest = { moduleTypeBeingMutated = null }
        )
    }

    if (showSpoolupCyclesSelector) {
        val spoolupCycles = module?.spoolupCycles?.value!!
        SpoolupCyclesDialog(
            value = spoolupCycles,
            maxValue = module.maxSpoolupCycles!!,
            setValue = { actions.setSpoolupCycles(module, it) },
            dismiss = { showSpoolupCyclesSelector = false }
        )
    }

    if (showAdaptationCyclesSelector) {
        val cycleCount = module?.adaptationCycles?.value!!
        AdaptationCyclesDialog(
            value = cycleCount,
            maxValue = AdaptationCycles.Maximum.cycleCount,
            setValue = { actions.setAdaptationCycles(module, it) },
            dismiss = { showAdaptationCyclesSelector = false }
        )
    }
}


/**
 * Returns the [Modifier] for mouse actions that work at the level of the entire [ModuleSlotRow].
 */
fun Modifier.rowLevelModuleStateMouseActions(actions: ModuleSlotActions): Modifier {
    // On macOS ctrl-click is taken because it's semantically the same as right-click, so we use alt-click for toggling
    // the online state on there.
    val toggleOnlineKeyModifier = if (hostOs == OS.MacOS) KeyboardModifierMatcher.Alt else KeyboardModifierMatcher.Ctrl

    // Consume to prevent selecting the row
    return this
        .onMousePress(MouseButton.Left, toggleOnlineKeyModifier, consumeEvent = true) {
            actions.toggleOnlineState()
        }
        .onMousePress(MouseButton.Left, KeyboardModifierMatcher.Shift, consumeEvent = true) {
            actions.toggleOverloadState()
        }
}


/**
 * Returns the next module type in the carousel that is legal according to [ModuleSlotActions.cannotFitReason].
 */
private fun Carousel<ModuleType>.legalNext(actions: ModuleSlotActions, moduleType: ModuleType): ModuleType {
    var current = next(moduleType)
    while (actions.cannotFitReason(current) != null) {
        val next = next(current)
        if (next == current)
            return moduleType
        current = next
    }
    return current
}


/**
 * Returns the previous module type in the carousel that is legal according to [ModuleSlotActions.cannotFitReason].
 */
private fun Carousel<ModuleType>.legalPrev(actions: ModuleSlotActions, moduleType: ModuleType): ModuleType {
    var current = prev(moduleType)
    while (actions.cannotFitReason(current) != null) {
        val prev = prev(current)
        if (prev == current)
            return moduleType
        current = prev
    }
    return current
}


/**
 * Remembers and returns the list of context actions for a module slot.
 */
@Composable
private fun rememberModuleSlotContextActions(
    slotGroup: ModuleSlotGroup,
    actions: ModuleSlotActions,
    module: Module?,
    openMutationEditWindow: (ModuleType) -> Unit,
    showSpoolupCyclesSelector: () -> Unit,
    showAdaptationCyclesSelector: () -> Unit,
): List<SlotContextAction> {
    val moduleType = module?.type
    val pasteAction = SlotContextAction.rememberPasteModule(slotGroup.slotType, actions)
    val currentOpenMutationEditWindow by rememberUpdatedState(openMutationEditWindow)
    val clipboardManager = LocalClipboardManager.current
    val windowManager = LocalTheorycrafterWindowManager.current
    val editPriceOverrideAction = moduleType?.let { SlotContextAction.rememberEditPriceOverride(it) }

    return remember(
        slotGroup,
        actions,
        module,
        clipboardManager,
        windowManager,
        pasteAction,
        showSpoolupCyclesSelector,
        showAdaptationCyclesSelector,
        editPriceOverrideAction
    ) {
        buildList {
            if (moduleType == null) {
                add(pasteAction)
            }
            else {
                add(SlotContextAction.showInfo(windowManager, module))
                add(SlotContextAction.Separator)

                add(SlotContextAction.cutToClipboard(clipboardManager, actions.clear, slotGroup::clipboardText))
                add(SlotContextAction.copyToClipboard(clipboardManager, slotGroup::clipboardText))
                add(pasteAction)
                add(SlotContextAction.Separator)

                if (actions.canMoveModules()) {
                    add(SlotContextAction.moveSlotUp(enabled = actions.canMoveSlotUp(), actions::moveSlotUp))
                    add(SlotContextAction.moveSlotDown(enabled = actions.canMoveSlotDown(), actions::moveSlotDown))
                    add(SlotContextAction.Separator)
                }

                add(SlotContextAction.clear(actions.clear))
                add(SlotContextAction.addOneItem(actions.addOne))
                add(SlotContextAction.removeOneItem(actions.removeOne, showInContextMenu = slotGroup is MultiModuleSlot))
                add(SlotContextAction.togglePrimaryState(actions.togglePrimaryState))
                add(SlotContextAction.toggleOnlineState(actions.toggleOnlineState))
                add(SlotContextAction.toggleOverloadState(actions.toggleOverloadState))
                add(SlotContextAction.Separator)

                if ((slotGroup is SingleModuleSlot) && (moduleType.slotType != ModuleSlotType.RIG)) {
                    addNotNull(SlotContextAction.mutateItem(
                        itemType = moduleType,
                        openMutationEditWindow = { currentOpenMutationEditWindow(moduleType) }
                    ))
                    addNotNull(SlotContextAction.editMutation(
                        itemType = moduleType,
                        openMutationEditWindow = { currentOpenMutationEditWindow(moduleType) }
                    ))
                    addNotNull(SlotContextAction.revertToBase(slotGroup, actions))
                }
                if (moduleType.slotType != ModuleSlotType.RIG) {
                    add(SlotContextAction.setSpoolupCycles(module, showSpoolupCyclesSelector))
                    add(SlotContextAction.setAdaptationCycles(module, showAdaptationCyclesSelector))
                }
                addNotNull(editPriceOverrideAction)
            }
        }
    }
}


/**
 * Returns a modifier for dragging the given module to place it at a new slot.
 */
context(GridScope)
@Composable
private fun Modifier.dragModuleToReorder(
    module: Module,
    actions: ModuleSlotActions,
): Modifier {
    val selectionModel = LocalSlotSelectionModel.current
    val slotType = module.type.slotType

    val rowIndexToModuleSlot by remember(selectionModel, slotType) {
        selectionModel.rowIndexToModuleSlotIndexState(slotType)
    }

    return this.dragRowToReorder(
        draggableContent = { DraggedItemSlotRepresentation(module.type.name) },
        canMoveToRow = { rowIndex ->
            rowIndex in rowIndexToModuleSlot
        },
        onDrop = { draggedRowIndex, targetRowIndex ->
            val targetSlotIndex = if (targetRowIndex <= draggedRowIndex)
                targetRowIndex
            else {
                // Find the module slot that precedes targetRowIndex (note that it's not just targetRowIndex-1)
                // because that could be a charge slot
                rowIndexToModuleSlot.keys.maxOf { if (it < targetRowIndex) it else -1 }
            }

            val targetSlot = rowIndexToModuleSlot[targetSlotIndex] ?: return@dragRowToReorder
            actions.moveModuleTo?.invoke(targetSlot)
        }
    )
}


/**
 * A dialog for mutating, or editing an existing mutation of a module.
 */
@Composable
private fun LocalModuleMutationDialog(
    slotGroup: SingleModuleSlot,
    moduleType: ModuleType,
    actions: ModuleSlotActions,
    undoRedoQueue: FitEditorUndoRedoQueue,
    onCloseRequest: () -> Unit
) {
    val originalMutatedAttributeValues = remember(moduleType) {
        moduleType.mutation?.mutatedAttributesAndValues()?.toTypedArray()
    }

    // The action that sets the module; null if the dialog did not change the module
    var moduleSettingAction: FitEditingAction? by remember { mutableStateOf(null) }

    // The attribute values to which the dialog mutated; `null` if the dialog did not change the attributes
    var changedAttributeValues: List<Pair<Attribute<*>, Double>>? by remember(slotGroup) {
        mutableStateOf(null)
    }

    val allowedPowerNeed = remember(moduleType) { slotGroup.requireRepModule().maxAllowedPowerNeed() }
    val allowedCpuNeed = remember(moduleType) { slotGroup.requireRepModule().allowedCpuNeed() }

    // TODO: Fix this horror
    val fit = LocalFit.current
    val selectionModel = LocalSlotSelectionModel.current
    val moduleSlotGroupsState = LocalModuleSlotGroupsState.current
    val undoRedoContext = FitEditorUndoRedoContext(fit, selectionModel, moduleSlotGroupsState, showError = {})
    ModuleMutationDialog(
        moduleType = moduleType,
        config = MutationDialogConfig(
            powerTickValue = allowedPowerNeed,
            cpuTickValue = allowedCpuNeed
        ),
        replaceItemInFit = { replacementModuleType ->
            runBlocking {
                with(undoRedoContext) {
                    moduleSettingAction?.revert()
                }
                if (replacementModuleType != null) {
                    val action = actions.replaceModuleAction(replacementModuleType)
                    with(undoRedoContext) {
                        action?.perform()
                    }
                    moduleSettingAction = action
                }
                else
                    moduleSettingAction = null
            }
        },
        setMutatedAttributeValues = { mutatedAttrubuteValues ->
            val editedModuleType = slotGroup.requireRepModule().type

            // If the original mutated attributes are null, it means moduleType is not mutated, and this
            // function should only be called with a null value after reverting the module type to the original.
            val replacementAttributeValues = mutatedAttrubuteValues ?: originalMutatedAttributeValues
            if (replacementAttributeValues != null) {
                runBlocking {
                    TheorycrafterContext.fits.modifyAndSave {
                        editedModuleType.setMutatedAttributeValues(
                            attributesAndValues = replacementAttributeValues
                        )
                    }
                }
            }

            changedAttributeValues = if (mutatedAttrubuteValues == null)
                null
            else
                editedModuleType.mutation!!.mutatedAttributesAndValues()
        },
        onCloseRequest = {
            @Suppress("UnnecessaryVariable", "RedundantSuppression")
            val prevAttributeValues = originalMutatedAttributeValues
            val newAttributeValues = changedAttributeValues?.toTypedArray()
            if ((moduleSettingAction != null) || (newAttributeValues != null)) {
                undoRedoQueue.append(
                    restoreSelection = false,
                    action = object: FitEditorUndoRedoAction {

                        context(FitEditorUndoRedoContext)
                        override suspend fun perform() {
                            moduleSettingAction?.perform()
                            if (newAttributeValues != null) {
                                TheorycrafterContext.fits.modifyAndSave {
                                    slotGroup.requireRepModule().type.setMutatedAttributeValues(
                                        attributesAndValues = newAttributeValues
                                    )
                                }
                            }
                        }

                        context(FitEditorUndoRedoContext)
                        override suspend fun revert() {
                            moduleSettingAction?.revert()
                            if ((newAttributeValues != null) && (prevAttributeValues != null)) {
                                TheorycrafterContext.fits.modifyAndSave {
                                    slotGroup.requireRepModule().type.setMutatedAttributeValues(
                                        attributesAndValues = prevAttributeValues
                                    )
                                }
                            }
                        }
                    }
                )
            }

            onCloseRequest()
        }
    )
}


/**
 * Returns the maximum power the given module can be mutated to need before the fit reaches 100% power use.
 */
private fun Module.maxAllowedPowerNeed(): Double? {
    val moduleType = type
    val modulePowerNeed = powerNeed?.value

    // If the module's power need is not the same as the module type's, then something is modifying it,
    // and that means we can't tell the maximum allowed power need.
    // If it turns out we need to support this case, we could assume the relation is linear, compute and use a
    // coefficient instead.
    if (modulePowerNeed != moduleType.powerNeed)
        return null

    return fit.fitting.power.availableWithout(modulePowerNeed)
}


/**
 * Returns the maximum CPU the given module can be mutated to need before the fit reaches 100% CPU use.
 */
private fun Module.allowedCpuNeed(): Double? {
    val moduleType = type
    val moduleCpuNeed = cpuNeed?.value

    // If the module's cpu need is not the same as the module type's, then something is modifying it,
    // and that means we can't tell the maximum allowed cpu need.
    // If it turns out we need to support this case, we could assume the relation is linear, compute and use a
    // coefficient instead.
    if (moduleCpuNeed != moduleType.cpuNeed)
        return null

    return fit.fitting.cpu.availableWithout(moduleCpuNeed)
}


/**
 * The [SlotContextAction] for pasting into a charge slot.
 */
private fun SlotContextAction.Companion.pasteCharge(
    clipboardManager: ClipboardManager,
    module: Module,
    loadCharge: (ChargeType) -> Unit
) = pasteFromClipboard(
    clipboardManager = clipboardManager,
    itemFromClipboardText = ::chargeFromClipboardText,
    pasteItem = {
        if (module.type.canLoadCharge(it))
            loadCharge(it)
    }
)


/**
 * The row displaying a charge slot, which may or may not contain a charge.
 */
@Composable
fun GridScope.ChargeSlotRow(
    testTag: String,
    charge: Charge?,
    module: Module,
    textStyle: TextStyle = TheorycrafterTheme.textStyles.fitEditorSecondarySlot,
    slotContentModifier: Modifier = Modifier,
    loadCharge: (ChargeType?, triggerCarouselAnimation: Boolean) -> Unit,
    isRemoteFit: Boolean = false
) {
    CompositionCounters.recomposed(testTag)

    val chargeType = charge?.type
    val moduleType = module.type
    val tournamentRules = TheorycrafterContext.tournaments.activeRules

    val carousel = rememberChargeCarousel(moduleType, tournamentRules)
    val contextActions = rememberChargeSlotContextActions(module, charge, loadCharge, carousel, isRemoteFit)
    val keyShortcuts = Modifier
        .carouselShortcuts(carousel, chargeType) {
            loadCharge(it, true)
        }

    val illegalityInTournamentReason = remember(tournamentRules, moduleType, chargeType) {
        itemIllegalityInTournamentReason(chargeType) { tournamentRules.isChargeLegal(chargeType, moduleType) }
    }

    SlotRow(
        modifier = Modifier.testTag(testTag),
        modifierWhenNotEditing = keyShortcuts,
        contextActions = contextActions,
        invalidityReason = charge?.illegalFittingReason ?: illegalityInTournamentReason,
        editedRowContent = { onEditingCompleted ->
            ChargeSelectorRow(
                module = module,
                carousel = carousel,
                onChargeSelected = {
                    loadCharge(it, false)
                    onEditingCompleted()
                },
                onEditingCancelled = onEditingCompleted
            )
        }
    ) {
        // Whenever the module changes, clear the state remembered by the AnimatedContent inside ChargeSlotContent.
        // Otherwise, we get animations we don't want in a few scenarios:
        // - Two identical modules with charges are fitted in consecutive slots and then the first one is removed.
        //   Without `key(module)`, the AnimatedContent of the 2nd ChargeSlotContent will read its current state
        //   from the 1st module call and animate towards its actual value.
        // - When a module with a charge is replaced with a different module whose preloaded charge is a neighbour
        //   of the current charge. For example, load a navy 400 cap booster into XLASB and then change the module
        //   to a medium cap booster (whose preloaded charge is a navy 800 cap booster).
        key(module) {
            ChargeSlotContent(
                charge = charge,
                moduleType = moduleType,
                carousel = carousel,
                textStyle = textStyle,
                modifier = slotContentModifier
            )
        }
    }
}


/**
 * Remembers and returns the list of context actions for a charge slot.
 */
@Composable
private fun rememberChargeSlotContextActions(
    module: Module,
    charge: Charge?,
    loadCharge: (ChargeType?, triggerCarouselAnimation: Boolean) -> Unit,
    carousel: Carousel<ChargeType?>,
    isRemoteFit: Boolean
): List<SlotContextAction> {
    val clipboardManager = LocalClipboardManager.current
    val undoRedoQueue = LocalFitEditorUndoRedoQueue.current
    val windowManager = LocalTheorycrafterWindowManager.current
    val fit = LocalFit.current
    val chargeType = charge?.type
    val editPriceOverrideAction = chargeType?.let { SlotContextAction.rememberEditPriceOverride(it) }

    return remember(
        module,
        charge,
        loadCharge,
        carousel,
        isRemoteFit,
        clipboardManager,
        undoRedoQueue,
        windowManager,
        fit,
        editPriceOverrideAction
    ) {
        val pasteAction = SlotContextAction.pasteCharge(clipboardManager, module) {
            loadCharge(it, false)
        }
        buildList(3) {
            if (chargeType == null) {
                add(pasteAction)
            }
            else {
                add(SlotContextAction.showInfo(windowManager, charge))
                add(SlotContextAction.Separator)

                val clearAction = if (carousel.contains(null)) { -> loadCharge(null, false) } else null
                if (clearAction != null)
                    add(SlotContextAction.cutToClipboard(clipboardManager, clearAction, chargeType::clipboardText))
                add(SlotContextAction.copyToClipboard(clipboardManager, chargeType::clipboardText))
                add(pasteAction)

                add(SlotContextAction.Separator)
                if (clearAction != null)
                    add(SlotContextAction.clear(clearAction))
                if (!isRemoteFit) {
                    val amount = maxLoadedChargeAmount(module.type, chargeType)
                    if (amount != null)
                        add(SlotContextAction.addToCargo(undoRedoQueue, fit, chargeType, amount))
                }
                addNotNull(editPriceOverrideAction)
            }
        }
    }
}


/**
 * Returns a short string describing why the given module type can't be fitted onto the fit at the given slot,
 * or `null` if it can.
 * The string will be displayed in the UI next to the module type suggestion.
 */
private fun Fit.cannotFitReason(moduleType: ModuleType, slotIndex: Int): String? {
    if (!ship.canFit(moduleType))
        return "Incompatible for ship"

    val fittedModules = modules.all

    // Checks that by fitting the module, the given limit will not be exceeded.
    // Returns `reason` if it will be, `null` if it won't
    fun limitReason(limit: Int?, countsTowardsLimit: (ModuleType) -> Boolean, reason: String): String? {
        if ((limit == null) || !countsTowardsLimit(moduleType))
            return null

        val replacedModule = modules.inSlot(moduleType.slotType, slotIndex)
        val currentCount = fittedModules
            .count { countsTowardsLimit(it.type) }
            .minus( // Subtract one if the module being replaced is also in the group
                if ((replacedModule != null) && countsTowardsLimit(replacedModule.type)) 1 else 0
            )

        return if (currentCount == limit)  // Already at maximum
            reason
        else
            null
    }

    // Check maxGroupFitted
    val groupId = moduleType.groupId
    limitReason(
        limit = moduleType.maxGroupFitted,
        countsTowardsLimit = { it.groupId == groupId },
        reason = "Group max. reached"
    )?.let {
        return it
    }

    // Check turret hardpoints
    limitReason(
        limit = ship.turretHardpoints.value,
        countsTowardsLimit = ModuleType::takesTurretHardpoint,
        reason = "Max. turrets reached"
    )?.let {
        return it
    }

    // Check launcher hardpoints
    limitReason(
        limit = ship.launcherHardpoints.value,
        countsTowardsLimit = ModuleType::takesLauncherHardpoint,
        reason = "Max. launchers reached"
    )?.let {
        return it
    }

    return null
}


/**
 * Appends the unit name to the value, optionally adding an "s" if it's plural.
 */
private fun Int.withUnits(unitName: String, usePlural: Boolean = true): String{
    return "$this $unitName" + (if (usePlural && (this != 1)) "s" else "")
}


/**
 * The title for the rack.
 */
@Composable
private fun rackTitle(fit: Fit, rackType: ModuleSlotType) = buildAnnotatedString {
    append(
        fit.fitting.slots[rackType].withUnits("${rackType.slotName} Slot")
    )

    /**
     * Returns text describing the utilization of the given resource.
     */
    @Composable
    fun resourceUseText(
        resource: Fit.Resource<Int>,
        unitName: String,
        usePlural: Boolean = true
    ): Pair<String?, Boolean> {
        val total = resource.total
        val used = resource.used
        val remainingByTotalText = "${(total - used)}/${total.withUnits(unitName, usePlural = usePlural)}"
        return when {
            used > total -> remainingByTotalText to false
            total == 0 -> null to true
            used == 0 -> total.withUnits(unitName, usePlural = usePlural) to true
            else -> remainingByTotalText to true
        }
    }

    val errorColor = TheorycrafterTheme.colors.base().errorContent

    // Append hardpoint counts
    if (rackType == ModuleSlotType.HIGH) {
        val (turretHardpointsText, turretsUsageValid) = resourceUseText(fit.fitting.turretHardpoints, "turret")
        val (launcherHardpointsText, launchersUsageValid) = resourceUseText(fit.fitting.launcherHardpoints, "launcher")
        if ((turretHardpointsText != null) || (launcherHardpointsText != null)) {
            appendSectionTitleExtraInfo {
                if (turretHardpointsText != null)
                    append(turretHardpointsText.withValidUsageStyle(turretsUsageValid, errorColor))
                if ((turretHardpointsText != null) && (launcherHardpointsText != null))
                    append("  ")
                if (launcherHardpointsText != null)
                    append(launcherHardpointsText.withValidUsageStyle(launchersUsageValid, errorColor))
            }
        }
    }

    // Append calibration
    if (rackType == ModuleSlotType.RIG) {
        val (calibrationText, isValid) = resourceUseText(
            resource = fit.fitting.calibration,
            valueToText = { value, withUnits ->  value.asCalibration(withUnits = withUnits) }
        )
        appendSectionTitleExtraInfo {
            append(calibrationText.withValidUsageStyle(isValid, errorColor))
        }
    }

}


/**
 * Remembers and returns the [ModuleSlotActions] for the given module slot group.
 */
@Composable
fun rememberModuleSlotActions(
    fit: Fit,
    slotGroup: ModuleSlotGroup,
    canMoveSlots: Boolean,
    rackSlotGroupingActions: RackSlotGroupingActions?,
    undoRedoQueue: FitEditorUndoRedoQueue,
): ModuleSlotActions {
    val selectionModel = LocalSlotSelectionModel.current
    val moduleSlotGroupsState = LocalModuleSlotGroupsState.current
    val currentCanMoveSlots by rememberUpdatedState(canMoveSlots)

    return rememberSlotActions(fit, slotGroup, rackSlotGroupingActions, undoRedoQueue, selectionModel, moduleSlotGroupsState) {
        val slotType = slotGroup.slotType

        fun removeSlotGroupAction(slotGroup: ModuleSlotGroup): FitEditorUndoRedoAction? {
            val action = removeSlotGroupAction(fit, slotGroup) ?: return null
            return undoRedoTogether(
                action1 = action,
                action2 =
                if (slotGroup is MultiModuleSlot)
                    rackSlotGroupingActions?.disbandGroupAction(slotGroup)
                else
                    null,
                action3 = markStaleAction()
            )
        }

        ModuleSlotActions(
            cannotFitReason = { moduleType ->
                fit.cannotFitReason(moduleType, slotGroup.slotIndices.first())
            },
            fit = { moduleType, preserveState, triggerCarouselAnimation ->
                if (stale)
                    return@ModuleSlotActions

                val updatedGroup = updatedSlotGroupWhenFittingModule(
                    fit = fit,
                    moduleType = moduleType,
                    currentGroup = slotGroup,
                    groupWeapons = moduleSlotGroupsState.groupWeapons
                )

                undoRedoQueue.performAndAppendComposite {
                    // Remove previous group; needed for changing a multi-slot to a single-slot group
                    if ((updatedGroup != slotGroup) && (slotGroup is MultiModuleSlot)) {
                        removeSlotGroupAction(slotGroup)?.let {
                            performAndAddStep(it)
                        }
                    }

                    // Fit modules and optionally assemble group
                    val newModuleState = if (preserveState) NewModuleState.PreserveReplaced else NewModuleState.DefaultInitial
                    fitModulesToSlotGroupAction(fit, moduleType, newModuleState, updatedGroup)?.let { fitModulesAction ->
                        val assembleGroupAction = if ((updatedGroup != slotGroup) && (updatedGroup is MultiModuleSlot))
                            rackSlotGroupingActions?.assembleGroupAction(updatedGroup)
                        else
                            null

                        performAndAddStep(
                            undoRedoTogether(
                                action1 = fitModulesAction.withCarouselAnimation(triggerCarouselAnimation),
                                action2 = assembleGroupAction
                            )
                        )
                    }
                }
            },
            replaceModuleAction = { moduleType ->
                fitModulesToSlotGroupAction(fit, moduleType, newModuleState = NewModuleState.PreserveReplaced, slotGroup)
            },
            canMoveModules = { currentCanMoveSlots },
            moveModuleTo = { targetSlot ->
                if (stale)
                    return@ModuleSlotActions

                if (slotGroup !is SingleModuleSlot)
                    return@ModuleSlotActions

                moveSlotAction(fit, slotGroup, targetSlot)?.let {
                    undoRedoQueue.performAndAppend(
                        action = it,
                        restoreSelection = false
                    )
                }
            },
            canMoveModuleBy = { slotOffset ->
                if (slotGroup !is SingleModuleSlot)
                    return@ModuleSlotActions false

                val targetSlot = slotGroup.slotIndex + slotOffset
                return@ModuleSlotActions targetSlot in 0 until fit.modules.relevantSlotCount(slotType)
            },
            moveModuleBy = { slotOffset ->
                if (stale)
                    return@ModuleSlotActions

                if (slotGroup !is SingleModuleSlot)
                    return@ModuleSlotActions

                val currentSlot = slotGroup.slotIndex
                moveSlotAction(fit, slotGroup, targetSlot = currentSlot + slotOffset)?.let {
                    undoRedoQueue.performAndAppend(
                        action = it,
                        restoreSelection = false
                    )
                }
            },
            clear = {
                if (stale)
                    return@ModuleSlotActions

                val action = removeSlotGroupAction(slotGroup) ?: return@ModuleSlotActions
                undoRedoQueue.performAndAppend(action)
            },
            togglePrimaryState = {
                if (stale)
                    return@ModuleSlotActions
                undoRedoQueue.performAndAppend(
                    toggleModulePrimaryStateAction(fit, slotGroup)
                )
            },
            toggleOnlineState = {
                if (stale)
                    return@ModuleSlotActions
                undoRedoQueue.performAndAppend(
                    toggleModuleOnlineStateAction(fit, slotGroup)
                )
            },
            toggleOverloadState = {
                if (stale)
                    return@ModuleSlotActions
                toggleModuleOverloadStateAction(fit, slotGroup)?.let {
                    undoRedoQueue.performAndAppend(it)
                }
            },
            addOne = {
                if (stale)
                    return@ModuleSlotActions

                val module = slotGroup.repModule ?: return@ModuleSlotActions
                val moduleType = module.type

                val emptySlotIndex = findEmptySlotForAddingToGroup(fit, slotType, slotGroup.slotIndices.first())
                if (emptySlotIndex == null)
                    return@ModuleSlotActions

                if (fit.cannotFitReason(moduleType, emptySlotIndex) != null)
                    return@ModuleSlotActions

                val action =
                    if (slotGroup is MultiModuleSlot) {
                        fitModuleAction(fit, slotGroup.requireRepModule(), emptySlotIndex)?.let { action ->
                            undoRedoTogether(
                                action,
                                rackSlotGroupingActions?.addSlotAction(slotGroup, emptySlotIndex)
                            )
                        }
                    }
                    else {
                        fitModulesToSlotGroupAction(
                            fit = fit,
                            moduleType = moduleType,
                            newModuleState = NewModuleState.Specified(module.state),
                            slotGroup = SingleModuleSlot(fit, slotType, emptySlotIndex),
                            chargeType = module.loadedCharge?.type
                        )
                    }

                if (action != null) {
                    undoRedoQueue.performAndAppend(action)
                }
            },
            removeOne = {
                if (stale)
                    return@ModuleSlotActions

                if ((slotGroup is MultiModuleSlot) && (slotGroup.slotCount <= 1))
                    return@ModuleSlotActions

                val action: FitEditorUndoRedoAction? = when (slotGroup) {
                    is MultiModuleSlot -> {
                        val slotIndex = slotToClearWhenRemovingFromGroup(slotGroup)
                        removeModuleAction(fit, slotType, slotIndex)?.let { action ->
                            undoRedoTogether(
                                action,
                                rackSlotGroupingActions?.removeSlotAction(slotGroup, slotIndex)
                            )
                        }
                    }
                    is SingleModuleSlot -> {
                        val slotIndex = slotToClearWhenRemovingSameItem(fit, slotType, slotGroup.slotIndex)
                        if (slotIndex != null)
                            removeModuleAction(fit, slotType, slotIndex)
                        else
                            null
                    }
                }

                if (action == null)
                    return@ModuleSlotActions
                undoRedoQueue.performAndAppend(action)
            },
            setSpoolupCycles = { module, cycles ->
                if (stale)
                    return@ModuleSlotActions

                val action = setSpoolupCyclesAction(module, cycles) ?: return@ModuleSlotActions
                undoRedoQueue.performAndAppend(action)
            },
            setAdaptationCycles = { module, cycles ->
                if (stale)
                    return@ModuleSlotActions

                val action = setAdaptationCyclesAction(module, cycles) ?: return@ModuleSlotActions
                undoRedoQueue.performAndAppend(action)
            }
        )
    }
}


/**
 * A rack of module slots (high, mid, etc.)
 * Returns the number of rows it has added to the grid.
 */
@Composable
fun GridScope.ModuleSlotRack(
    firstRowIndex: Int,
    isFirst: Boolean = false,
    fit: Fit,
    slotType: ModuleSlotType,
    slotGroups: List<ModuleSlotGroup>,
    illegalityInTournamentReason: (Int) -> String?,
    rackSlotGroupingActions: RackSlotGroupingActions,
): Int {
    if (slotGroups.isEmpty())
        return 0

    var rowIndex = firstRowIndex

    SectionTitleRow(
        rowIndex = rowIndex++,
        isFirst = isFirst,
        text = rackTitle(fit, slotType),
        extraContent = rackTitleExtraContent(slotType)
    )

    // Rack slots
    val undoRedoQueue = LocalFitEditorUndoRedoQueue.current
    val rackHasMultiModuleSlots = slotGroups.any { it is MultiModuleSlot }
    for ((index, slotGroup) in slotGroups.withIndex()) {
        inRow(rowIndex++) {
            val slotActions = rememberModuleSlotActions(
                fit = fit,
                slotGroup = slotGroup,
                canMoveSlots = !rackHasMultiModuleSlots,
                rackSlotGroupingActions = rackSlotGroupingActions,
                undoRedoQueue = undoRedoQueue
            )
            ModuleSlotRow(
                testTag = TestTags.FitEditor.moduleRow(slotGroup.slotType, index),
                slotGroup = slotGroup,
                illegalityInTournamentReason = illegalityInTournamentReason(slotGroup.firstSlotIndex),
                actions = slotActions,
                slotContentProvider = ModuleSlotContent,
                moduleSelectorProvider = remember(slotGroup, slotActions) {
                    ModuleSelector(slotGroup, slotActions = slotActions)
                }
            )
        }

        val module = slotGroup.repModule
        if (module?.canLoadCharges == true) {
            inRow(rowIndex++) {
                val charge = module.loadedCharge
                ChargeSlotRow(
                    testTag = TestTags.FitEditor.chargeRow(module.type.slotType, index),
                    charge = charge,
                    module = module,
                    loadCharge = remember(fit, slotGroup, undoRedoQueue) {
                        { chargeType, triggerCarouselAnimation ->
                            setChargeAction(fit, slotGroup, chargeType)?.let {
                                undoRedoQueue.performAndAppend(
                                    it.withCarouselAnimation(triggerCarouselAnimation)
                                )
                            }
                        }
                    }
                )
            }
        }

        // Here's what happens here w.r.t. recompositions.
        // 1. When a single module changes, SlotRack (of the rack where the module changed) has to be recomposed,
        //    because it reads all the modules.
        // 2. Because SlotRack returns a value, FitGrid which calls it, must also be called (actually, only the
        //    lambda passed to SimpleGrid), which in turn calls SlotRack for the other racks too.
        //    This *has* to be done, because the number of rows in a rack is not constant - when a module that takes
        //    charges is fitted or removed, the number of rows changes, and therefore the indices of the rows in racks
        //    that follow it also must change.
        // 3. FitGrid, in turn, calls SlotRack on all the racks (and all other sections). The calls from SlotRack to
        //    ModuleSlotRow get all skipped, except for the one where the module actually changed.
    }

    return rowIndex - firstRowIndex
}


/**
 * Returns a composable to be put to the right of the rack title; `null` if none.
 */
private fun rackTitleExtraContent(slotType: ModuleSlotType): (@Composable RowScope.() -> Unit)? {
    if (slotType == ModuleSlotType.HIGH) {
        @Suppress("RedundantLambdaArrow")
        return { ->
            val undoRedoQueue = LocalFitEditorUndoRedoQueue.current
            val moduleSlotGroupsState = LocalModuleSlotGroupsState.current

            Box(
                // Setting the height to 1.dp "resets" its intrinsic size; without this, it stretches the row
                modifier = Modifier.fillMaxHeight().height(1.dp)
            ) {
                IconButton(
                    onClick = {
                        undoRedoQueue.performAndAppend(
                            toggleGroupWeaponsAction(moduleSlotGroupsState)
                        )
                    },
                    modifier = Modifier
                        .wrapContentSize(unbounded = true)  // Allow the icon to extend beyond the row
                        .tooltip(
                            text = "Toggle weapon grouping",
                            keyShortcut = FitEditorKeyShortcuts.ToggleGroupWeapons,
                            placement = EasyTooltipPlacement.ElementTopCenter
                        ),
                ) {
                    Icons.Grouped(
                        grouped = moduleSlotGroupsState.groupWeapons,
                        modifier = Modifier
                            .padding(TheorycrafterTheme.spacing.xxsmall)
                            .height(TheorycrafterTheme.sizes.fitEditorSlotRowHeight -
                                    SLOT_ROW_PADDING.calculateTopPadding() -
                                    SLOT_ROW_PADDING.calculateBottomPadding()
                            )
                    )
                }
            }
        }
    }

    return null
}
