/**
 * The panel where the fit can be edited, modules fitted, etc.
 */

package theorycrafter.ui.fiteditor

import androidx.compose.animation.*
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.SnapSpec
import androidx.compose.animation.core.tween
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.*
import androidx.compose.ui.window.Popup
import compose.input.KeyShortcut
import compose.input.onKeyShortcut
import compose.input.onMousePress
import compose.input.onOpenContextMenu
import compose.utils.*
import compose.widgets.*
import eve.data.*
import eve.data.typeid.isEnergyNeutralizationBurstProjector
import eve.data.typeid.isEnergyNeutralizer
import eve.data.typeid.isEnergyNeutralizerDrone
import eve.data.typeid.isEnergyNosferatu
import eve.data.utils.valueByEnum
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.selects.select
import theorycrafter.*
import theorycrafter.fitting.*
import theorycrafter.fitting.utils.associateWithIndex
import theorycrafter.optimizer.FitOptimizerDialog
import theorycrafter.tournaments.TournamentRules
import theorycrafter.tournaments.moduleLegalityStateByRack
import theorycrafter.ui.*
import theorycrafter.ui.graphs.GraphsWindowPane
import theorycrafter.ui.market.MiniMarketTree
import theorycrafter.ui.settings.EditPriceOverrideDialog
import theorycrafter.ui.settings.SettingsPane
import theorycrafter.ui.tooltip
import theorycrafter.ui.widgets.*
import theorycrafter.utils.*
import java.util.concurrent.atomic.AtomicReference
import kotlin.math.log10


/**
 * The state that is preserved across edit sessions of the same fit.
 */
class FitEditorFitSavedState {


    /**
     * The undo-redo queue for actions in the fit editor.
     */
    val undoRedoQueue: UndoRedoQueue<FitEditorUndoRedoContext> =
        UndoRedoQueue(
            contextProvider = {
                undoRedoQueueContext.get()
                    ?: throw IllegalStateException("Attempting to use the fit editor undo-redo queue without a context")
            }
        )


    /**
     * Whether weapons should be grouped.
     */
    var groupWeapons: Boolean = true


    /**
     * The live context provided to the [undoRedoQueue] when the fit is being actively edited.
     */
    var undoRedoQueueContext: AtomicReference<FitEditorUndoRedoContext?> = AtomicReference(null)


    /**
     * Invoked when the fit has been modified not via the fit editor.
     *
     * In this case we need to clear the undo-redo queue, or else we risk an undo-redo action crashing because the state
     * of the fit is not what it expects it to be.
     */
    fun onFitEditedExternally() {
        if (!undoRedoQueue.isEmpty()) {
            undoRedoQueue.clear()
            // We would like to append an action that would show an error message, but we don't have
            // access to a composable here.
        }
    }


}


/**
 * Provides the context to the undo-redo queue as long as this composable is in the composition.
 */
@Composable
private fun provideContextToUndoRedoQueue(
    savedState: FitEditorFitSavedState,
    context: FitEditorUndoRedoContext
) {
    DisposableEffect(savedState, context) {
        savedState.undoRedoQueueContext.set(context)
        onDispose {
            savedState.undoRedoQueueContext.compareAndSet(context, null)
        }
    }
}


/**
 * The fit editor, including the title and action button.
 */
@Composable
fun FitEditor(
    fit: Fit,
    fitHandle: FitHandle? = null,  // This function is used from warmup and tests, where there's no handle
    savedFitState: FitEditorFitSavedState = remember { FitEditorFitSavedState() },
    requestInitialFocus: Boolean = false,
    modifier: Modifier = Modifier,
) {
    key(fit) {  // Clear all remembered state when the fit changes to avoid accidental reuse
        val moduleSlotGroupsState = rememberSlotGroupsState(fit, savedFitState)
        val selectionModel = remember(fit, moduleSlotGroupsState) { SlotSelectionModel(fit, moduleSlotGroupsState) }
        val dialogs = LocalStandardDialogs.current
        val undoRedoQueueContext = rememberUndoRedoQueueContext(
            fit = fit,
            selectionModel = selectionModel,
            moduleSlotGroupsState = moduleSlotGroupsState,
            showError = remember(dialogs) {
                { dialogs.showErrorDialog(it) }
            }
        )
        provideContextToUndoRedoQueue(savedFitState, undoRedoQueueContext)

        val undoRedoQueue = remember(savedFitState, selectionModel) {
            FitEditorUndoRedoQueueImpl(
                selectedIndex = { selectionModel.selectedIndex },
                delegate = savedFitState.undoRedoQueue,
            )
        }

        LaunchedEffect(selectionModel, requestInitialFocus) {
            if (requestInitialFocus)
                selectionModel.selectFirst()
        }

        val tempFittingScopeProvider = rememberUpdatedTempFittingScopeProvider(fit)

        CompositionLocalProvider(
            LocalFit provides fit,
            LocalTempFittingScopeProvider provides tempFittingScopeProvider,
            LocalSlotSelectionModel provides selectionModel,
            LocalModuleSlotGroupsState provides moduleSlotGroupsState,
            LocalFitEditorUndoRedoQueue provides undoRedoQueue
        ) {
            var editFitName by remember { mutableStateOf(false) }
            var showEditTagsDialog by remember { mutableStateOf(false) }
            var showPackForBattleDialog by remember { mutableStateOf(false) }
            var showFitOptimizerDialog by remember { mutableStateOf(false) }

            TitledPanel(
                title = {
                    if (fitHandle != null) {
                        FitEditorTitle(
                            fit = fit,
                            fitHandle = fitHandle,
                            editFitName = editFitName,
                            onEditingFitNameFinished = { editFitName = false }
                        )
                    }
                },
                actionsButton = {
                    if (fitHandle != null) {
                        VerticallyCenteredRow(
                            horizontalArrangement = Arrangement.spacedBy(TheorycrafterTheme.spacing.large)
                        ) {
                            InteractiveFitAnalysisIcon(fit)
                            FitActionsButton(
                                fit = fit,
                                fitHandle = fitHandle,
                                startEditingFitName = { editFitName = true },
                                showEditTagsDialog = { showEditTagsDialog = true },
                                showPackForBattleDialog = { showPackForBattleDialog = true },
                                showFitOptimizerDialog = { showFitOptimizerDialog = true },
                            )
                        }
                    }
                },
                modifier = modifier
            ) {
                Box {
                    Column(Modifier.fillMaxSize()) {
                        HeaderRow(
                            modifier = Modifier
                                .fillMaxWidth()
                                .padding(
                                    top = TheorycrafterTheme.spacing.larger,
                                    bottom = TheorycrafterTheme.spacing.xsmall
                                )
                        )
                        FitGrid(
                            fit = fit,
                            fitHandle = fitHandle,
                            selectionModel = selectionModel,
                            moduleSlotGroupsState = moduleSlotGroupsState,
                            startEditingFitName = { editFitName = true },
                            showEditTagsDialog = { showEditTagsDialog = true },
                            showPackForBattleDialog = { showPackForBattleDialog = true },
                            showFitOptimizerDialog = { showFitOptimizerDialog = true }
                        )
                    }
                    KeyShortcutsIcon(
                        modifier = Modifier
                            .align(Alignment.BottomEnd)
                            .padding(TheorycrafterTheme.spacing.horizontalEdgeMargin)
                    ) {
                        FitEditorKeyShortcutsTooltip()
                    }
                }
            }

            if (showEditTagsDialog && (fitHandle != null)) {
                FitTagEditorDialog(
                    fitHandle = fitHandle,
                    onDismiss = { showEditTagsDialog = false },
                )
            }

            if (showPackForBattleDialog) {
                PackForBattleDialog(
                    fit = fit,
                    onDismiss = { showPackForBattleDialog = false },
                    onPack = { itemsAndAmounts, completeToAmount ->
                        if (!packCargo(fit, undoRedoQueue, itemsAndAmounts, completeToAmount)) {
                            dialogs.showErrorDialog(
                                "Nothing was added to the cargo because all the required items are already present."
                            )
                        }
                    }
                )
            }

            if (fitHandle != null) {
                LocalKeyShortcutsManager.current.register(
                    shortcut = FitWindowKeyShortcuts.ShowPackForBattleDialog,
                    action = { showPackForBattleDialog = true },
                )
            }

            if (showFitOptimizerDialog && (fitHandle != null)) {
                FitOptimizerDialog(
                    fit = fit,
                    fitHandle = fitHandle,
                    onCloseRequest = { showFitOptimizerDialog = false },
                )
            }

            if (fitHandle != null) {
                LocalKeyShortcutsManager.current.register(
                    shortcut = FitWindowKeyShortcuts.OptimizeFit,
                    action = { showFitOptimizerDialog = true },
                )
            }

            LocalKeyShortcutsManager.current.register(
                shortcut = FitEditorKeyShortcuts.TogglePriceColumnKeyShortcut,
                action = {
                    TheorycrafterContext.settings.prices.showInFitEditor = !TheorycrafterContext.settings.prices.showInFitEditor
                }
            )
        }
    }
}


/**
 * The title of the fit editor.
 */
@Composable
private fun FitEditorTitle(
    fit: Fit,
    fitHandle: FitHandle,
    editFitName: Boolean,
    onEditingFitNameFinished: () -> Unit,
) {
    VerticallyCenteredRow(
        horizontalArrangement = Arrangement.spacedBy(TheorycrafterTheme.spacing.xxlarge),
        modifier = Modifier
            .height(IntrinsicSize.Min)
    ) {
        val fitSkillSet = TheorycrafterContext.skillSets.skillSetHandleOfFit(fitHandle)
        IconNameAndShipTypeInTitle(
            fitHandle = fitHandle,
            skillSetHandle = fitSkillSet,
            editFitName = editFitName,
            onEditingFitNameFinished = onEditingFitNameFinished,
            modifier = Modifier.fillMaxHeight()
        )
        TheorycrafterContext.tournaments.activeRules?.let { rules ->
            if (rules.fittingRules.canBeFlagship(fit.ship.type)) {
                FlagshipSelector(fit)
            }
        }
        SkillSetSelector(fitHandle, fitSkillSet, Modifier.fillMaxHeight())
        SecurityStatusSelector(fit, Modifier.fillMaxHeight())
    }
}


/**
 * The icon, fit name and ship type part of the fit title.
 */
@Composable
private fun IconNameAndShipTypeInTitle(
    fitHandle: FitHandle,
    skillSetHandle: SkillSetHandle?,
    editFitName: Boolean,
    onEditingFitNameFinished: () -> Unit,
    modifier: Modifier
) {
    val shipType = TheorycrafterContext.eveData.shipType(fitHandle.shipTypeId)
    val skillSet = produceState<SkillSet?>(initialValue = null, key1 = skillSetHandle) {
        value = TheorycrafterContext.skillSets.engineSkillSetOf(skillSetHandle)
    }.value

    val isLegalForTournament = TheorycrafterContext.tournaments.activeRules.isShipLegal(shipType)

    val tooltipPlacement = EasyTooltipPlacement.ElementBottomCenter(
        offset = DpOffsetY(TheorycrafterTheme.spacing.small)
    )
    VerticallyCenteredRow(
        horizontalArrangement = Arrangement.spacedBy(TheorycrafterTheme.spacing.xsmall),
        modifier = modifier.then(
            when {
                !isLegalForTournament ->
                    Modifier.tooltip(
                        placement = tooltipPlacement,
                        text = "${shipType.name} is illegal in ${TheorycrafterContext.tournaments.activeTournamentDescriptor?.name}"
                    )
                skillSet != null -> {
                    val unfulfilledRequirements = skillSet.unfulfilledRequirements(shipType)
                    if (unfulfilledRequirements.isNotEmpty()) {
                        Modifier.tooltip(placement = tooltipPlacement) {
                            UnfulfilledSkillRequirementsTooltipContent(unfulfilledRequirements)
                        }
                    } else Modifier.shipTypeTraitsTooltip(shipType = shipType)
                }
                else -> Modifier
            }
        )
    ) {
        Icons.EveItemType(
            itemType = shipType,
            modifier = Modifier.size(TheorycrafterTheme.sizes.eveTypeIconLarge)
        )
        Column {
            val coroutineScope = rememberCoroutineScope()
            if (editFitName) {
                ItemNameEditor(
                    itemName = fitHandle.name,
                    onRename = {
                        coroutineScope.launch {
                            TheorycrafterContext.fits.setName(fitHandle, it)
                        }
                    },
                    onEditingFinished = onEditingFitNameFinished,
                    modifier = Modifier
                        .width(IntrinsicSize.Min)
                )
            } else {
                Text(
                    text = fitHandle.name,
                    modifier = Modifier.testTag(TestTags.FitEditor.TitleFitName)
                )
            }

            val areSkillsFulfilled = (skillSet == null) || skillSet.fulfillsAllRequirements(shipType)
            val isValid = isLegalForTournament && areSkillsFulfilled
            Text(
                text = shipType.shortName(),
                style = TheorycrafterTheme.textStyles.detailedText(),
                color = TheorycrafterTheme.colors.invalidContent(valid = isValid),
            )
        }
    }
}


/**
 * Indicates and allows modifying the fit's flagship status.
 */
@Composable
private fun FlagshipSelector(
    fit: Fit,
    modifier: Modifier = Modifier,
) {
    val coroutineScope = rememberCoroutineScope()
    CheckboxedText(
        text = "Flagship",
        checked = fit.isFlagship,
        onCheckedChange = {
            coroutineScope.launch {
                TheorycrafterContext.fits.setFlagship(fit, it)
            }
        },
        modifier = modifier
    )
}


/**
 * An item in the skill sets dropdown menu.
 */
private sealed interface SkillSetDropdownItem {

    class SkillSetItem(val skillSetHandle: SkillSetHandle?): SkillSetDropdownItem {

        override fun toString(): String {
            return skillSetHandle?.name ?: "Default Skill Set"
        }

    }

    object ManageSkillSets: SkillSetDropdownItem {

        override fun toString() = "Manage Skill Sets…"

    }

    data object Separator: SkillSetDropdownItem

}


/**
 * An [UndoRedoAction] that sets the fit's skill set.
 */
private fun setFitSkillSetAction(fitHandle: FitHandle, skillSetHandle: SkillSetHandle?): FitEditorUndoRedoAction {
    val newSkillSetId = skillSetHandle?.skillSetId
    val prevSkillSetId = TheorycrafterContext.skillSets.skillSetHandleOfFit(fitHandle)?.skillSetId

    return object: FitEditorUndoRedoAction {

        private fun skillSetHandle(skillSetId: Int?): SkillSetHandle? {
            if (skillSetId == null)  // Default skill set
                return null
            else {
                val realSkillSet = TheorycrafterContext.skillSets.handleByIdOrNull(skillSetId)
                if (realSkillSet == null) {
                    // The id is not null, but the skill we got from handleByIdOrNull is null;
                    // it means the skill has been deleted
                    throw FitEditorUndoRedoActionFailed("Target skill set has been deleted")
                }
                return realSkillSet
            }
        }

        context(FitEditorUndoRedoContext)
        override suspend fun perform() {
            TheorycrafterContext.fits.setSkillSet(fitHandle, skillSetHandle(newSkillSetId))
        }

        context(FitEditorUndoRedoContext)
        override suspend fun revert() {
            TheorycrafterContext.fits.setSkillSet(fitHandle, skillSetHandle(prevSkillSetId))
        }

    }
}


/**
 * Displays and allows changing the fit's skill set.
 */
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun SkillSetSelector(
    fitHandle: FitHandle,
    fitSkillSet: SkillSetHandle?,
    modifier: Modifier
) {
    val skillSetItems by remember {
        derivedStateOf {
            buildList {
                add(SkillSetDropdownItem.SkillSetItem(null))  // Default skill set
                addAll(TheorycrafterContext.skillSets.builtInHandles.map(SkillSetDropdownItem::SkillSetItem))
                if (TheorycrafterContext.skillSets.userHandles.isNotEmpty()) {
                    add(SkillSetDropdownItem.Separator)
                    addAll(TheorycrafterContext.skillSets.userHandles.map(SkillSetDropdownItem::SkillSetItem))
                }
                add(SkillSetDropdownItem.Separator)
                add(SkillSetDropdownItem.ManageSkillSets)
            }
        }
    }

    val coroutineScope = rememberCoroutineScope()
    val undoRedoQueue = LocalFitEditorUndoRedoQueue.current

    val windowManager = LocalTheorycrafterWindowManager.current

    val selectedIndex = skillSetItems.indexOfFirst {
        (it as? SkillSetDropdownItem.SkillSetItem)?.skillSetHandle == fitSkillSet
    }

    val (expanded, setExpanded) = remember { mutableStateOf(false) }
    ExposedDropdownMenuBox(
        expanded = expanded,
        onExpandedChange = { setExpanded(it) },
    ) {
        VerticallyCenteredRow(
            modifier = modifier
                .highlightOnHover(shape = MaterialTheme.shapes.medium)
                .padding(start = 7.dp, end = 4.dp)  // The arrow has a bit of padding built-in
                .padding(vertical = TheorycrafterTheme.spacing.xxsmall)
                .width(IntrinsicSize.Max)
                .tooltip(
                    text = "The default skill set is \"${TheorycrafterContext.skillSets.default.name}\""
                        .takeIf { fitSkillSet == null },
                    placement = EasyTooltipPlacement.ElementBottomCenter(
                        offset = DpOffsetY(TheorycrafterTheme.spacing.small)
                    )
                )
        ) {
            val textStyle = LocalTextStyle.current
            SingleLineText(
                text = skillSetItems[selectedIndex].toString(),
                style = textStyle.copy(fontWeight = FontWeight.ExtraLight),
                modifier = Modifier.weight(1f)
            )

            val iconSize = with(LocalDensity.current) { textStyle.fontSize.toDp() }
            ExposedDropdownFieldTrailingIcon(
                expanded = expanded,
                modifier = Modifier.size(iconSize)
            )
        }

        DropdownMenu(
            expanded = expanded,
            onDismissRequest = { setExpanded(false) }
        ) {
            for (item in skillSetItems) {
                if (item is SkillSetDropdownItem.Separator) {
                    MenuSeparator()
                }
                else {
                    MenuItem(
                        text = item.toString(),
                        action = {
                            when (item) {
                                is SkillSetDropdownItem.SkillSetItem -> {
                                    coroutineScope.launch {
                                        undoRedoQueue.performAndAppend(
                                            setFitSkillSetAction(fitHandle, item.skillSetHandle)
                                        )
                                    }
                                }
                                is SkillSetDropdownItem.ManageSkillSets -> {
                                    windowManager.showSettingsWindow(SettingsPane.SkillSets)
                                }
                                else -> {}
                            }
                        },
                        onCloseMenu = { setExpanded(false) }
                    )
                }
            }
        }
    }
}


/**
 * A [FitEditingAction] that sets the ship's security status.
 */
private class SetSecurityStatusAction(
    val prevValue: Double,
    val newValue: Double
): FitEditingAction() {

    context(FitEditorUndoRedoContext)
    override fun FittingEngine.ModificationScope.performEdit() {
        fit.ship.setPilotSecurityStatus(newValue)
    }

    context(FitEditorUndoRedoContext)
    override fun FittingEngine.ModificationScope.revertEdit() {
        fit.ship.setPilotSecurityStatus(prevValue)
    }
}


/**
 * The selector/editor of ship security status.
 */
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun SecurityStatusSelector(
    fit: Fit,
    modifier: Modifier
) {
    val ship = fit.ship
    val securityStatus = ship.pilotSecurityStatus ?: return

    val undoRedoQueue = LocalFitEditorUndoRedoQueue.current

    fun Double.toDisplayedText() = withUnitsAndSign(
        number = toDecimalWithPrecision(1),
        withSign = true,
        withUnits = false,
        units = ""
    )

    var textFieldValue by remember { mutableStateOf(TextFieldValue()) }
    remember(securityStatus.value) {
        textFieldValue = TextFieldValue(text = securityStatus.value.toDisplayedText())
    }

    fun applySecurityStatusChange(newValue: Double) {
        undoRedoQueue.performAndMergeOrAppend { lastAction ->
            if (lastAction is SetSecurityStatusAction) {
                SetSecurityStatusAction(
                    prevValue = lastAction.prevValue,
                    newValue = newValue
                ) to true
            } else {
                SetSecurityStatusAction(
                    prevValue = securityStatus.value,
                    newValue = newValue
                ) to false
            }
        }
    }

    fun applyTextFieldValue() {
        val textValue = textFieldValue.text.toDoubleOrNull()
        if (textValue == null)
            textFieldValue = TextFieldValue(securityStatus.value.toDisplayedText())
        else
            applySecurityStatusChange(textValue)
    }

    VerticallyCenteredRow(modifier) {
        SingleLineText(
            text = "Security Status:",
            fontWeight = FontWeight.ExtraLight
        )
        HSpacer(TheorycrafterTheme.spacing.medium)
        AttributeMutationSlider(
            baseValue = 0.0,
            highIsGood = true,
            value = securityStatus.value,
            valueRange = -10.0 .. 10.0,
            onValueChange = {
                applySecurityStatusChange(it)
            },
            modifier = Modifier
                .width(100.dp)
                .fillMaxHeight(),
        )

        HSpacer(TheorycrafterTheme.spacing.small)

        val interactionSource = remember { MutableInteractionSource() }
        TheorycrafterTheme.OutlinedTextField(
            value = textFieldValue,
            onValueChange = { textFieldValue = it },
            singleLine = true,
            textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.End),
            interactionSource = interactionSource,
            modifier = Modifier
                .width(180.dp)
                .onKeyShortcut(KeyShortcut.anyEnter(), onPreview = true) {
                    applyTextFieldValue()
                }
                .onPointerEvent(PointerEventType.Release, pass = PointerEventPass.Final) {
                    textFieldValue = textFieldValue.withAllTextSelected()
                },
            scaleHackFactor = 0.3f
        )
        interactionSource.onLostFocus {
            applyTextFieldValue()
        }
    }
}


/**
 * The button opening a menu of fit-related actions.
 */
@Composable
private fun FitActionsButton(
    fit: Fit,
    fitHandle: FitHandle,
    startEditingFitName: () -> Unit,
    showEditTagsDialog: () -> Unit,
    showPackForBattleDialog: () -> Unit,
    showFitOptimizerDialog: () -> Unit,
) {
    FitActions(
        fit = fit,
        fitHandle = fitHandle,
        startEditingFitName = startEditingFitName,
        showEditTagsDialog = showEditTagsDialog,
        showPackForBattleDialog = showPackForBattleDialog,
        showFitOptimizerDialog = showFitOptimizerDialog,
    ) { menuContent ->
        ActionsMenuButton(
            contentDescription = "Fit Actions"
        ) {
            menuContent()
        }
    }
}


/**
 * A context menu of fit-related actions.
 */
@Composable
private fun FitActionsContextMenu(
    state: DropdownMenuState,
    fit: Fit,
    fitHandle: FitHandle,
    startEditingFitName: () -> Unit,
    showEditTagsDialog: () -> Unit,
    showPackForBattleDialog: () -> Unit,
    showFitOptimizerDialog: () -> Unit,
) {
    FitActions(
        fit = fit,
        fitHandle = fitHandle,
        startEditingFitName = startEditingFitName,
        showEditTagsDialog = showEditTagsDialog,
        showPackForBattleDialog = showPackForBattleDialog,
        showFitOptimizerDialog = showFitOptimizerDialog,
    ) { menuContent ->
        DropdownMenu(
            state = state,
            onDismissRequest = state.closeFunction
        ) {
            val scope = remember(state) {
                ActionsMenuScope(
                    onCloseMenu = state.closeFunction
                )
            }
            scope.menuContent()
        }
    }
}


/**
 * Returns a [FitEditorUndoRedoAction] that fits the preloaded charges into the given list of empty modules.
 */
private fun loadMissingChargesAction(fit: Fit, moduleSlotGroups: List<ModuleSlotGroup>): FitEditorUndoRedoAction {
    val actions = moduleSlotGroups.mapNotNull { slotGroup ->
        // Neither the module type nor the charge should be null, because the list of module slot groups is pre-filtered
        // to only be those where there is a preloaded charge. But we check just in case anyway
        val moduleType = slotGroup.repModule?.type ?: return@mapNotNull null
        val charge = preloadedCharge(fit, moduleType) ?: return@mapNotNull null
        setChargeAction(fit, slotGroup, charge)
    }

    return compositeFitEditingAction(actions)
}


/**
 * The fit-related actions whose menu items are emitted into the given dropdown menu.
 */
@Composable
private fun FitActions(
    fit: Fit,
    fitHandle: FitHandle,
    startEditingFitName: () -> Unit,
    showEditTagsDialog: () -> Unit,
    showPackForBattleDialog: () -> Unit,
    showFitOptimizerDialog: () -> Unit,
    dropdownMenu: @Composable (content: @Composable ActionsMenuScope.() -> Unit) -> Unit
) {
    val fitCopier = rememberFitCopier()
    val windowManager = LocalTheorycrafterWindowManager.current
    val undoRedoQueue = LocalFitEditorUndoRedoQueue.current
    val settings = TheorycrafterContext.settings

    dropdownMenu {
        menuItem(
            text = "Ship Info",
            icon = { Icons.EveItemInfo() },
            reserveSpaceForKeyShortcut = true,
            action = { windowManager.showItemInfoWindow(fit.ship) }
        )

        separator()

        menuItem(
            text = "Copy Fit to Clipboard",
            icon = { Icons.Copy() },
            keyShortcut = FitWindowKeyShortcuts.CopyFitToClipboard,
            action = { fitCopier.copy(fitHandle) }
        )
        menuItem(
            text = "Copy Fit with Options…",
            icon = { Icons.CopyWithOptions() },
            keyShortcut = FitWindowKeyShortcuts.CopyFitWithOptionsToClipboard,
            action = { fitCopier.copyWithOptions(fitHandle) }
        )
        menuItem(
            text = "Rename Fit",
            icon = { Icons.Edit() },
            reserveSpaceForKeyShortcut = true,
            action = { startEditingFitName() }
        )
        menuItem(
            text = "Edit Fit Tags…",
            icon = { Icons.Hashtag() },
            reserveSpaceForKeyShortcut = true,
            action = { showEditTagsDialog() }
        )

        separator()

        menuItem(
            text = if (settings.prices.showInFitEditor) "Hide prices" else "Show prices",
            icon = { Icons.Prices() },
            keyShortcut = FitEditorKeyShortcuts.TogglePriceColumnKeyShortcut,
            action = { settings.prices.showInFitEditor = !settings.prices.showInFitEditor }
        )

        separator()

        menuItem(
            text = "Pack Cargohold for Battle…",
            icon = { Icons.PackForBattle() },
            keyShortcut = FitWindowKeyShortcuts.ShowPackForBattleDialog,
            action = showPackForBattleDialog
        )

        val moduleSlotsWithMissingCharges = LocalModuleSlotGroupsState.current.all().filter {
            val module = it.repModule ?: return@filter false
            module.canLoadCharges && (module.loadedCharge == null) && hasPreloadedCharge(fit, module.type)
        }
        menuItem(
            text = "Load Missing Charges",
            enabled = moduleSlotsWithMissingCharges.isNotEmpty(),
            icon = { Icons.LoadCharges() },
            reserveSpaceForKeyShortcut = true,
            action = {
                undoRedoQueue.performAndAppend(
                    loadMissingChargesAction(fit, moduleSlotsWithMissingCharges)
                )
            }
        )
        menuItem(
            text = "Optimize Fit (Beta)… ",
            icon = { Icons.OptimizeFit() },
            keyShortcut = FitWindowKeyShortcuts.OptimizeFit,
            action = showFitOptimizerDialog
        )

        separator()

        ShowGraphMenuItems(fit, fitHandle)
    }
}


/**
 * The "Show X Graph" menu items in the fit actions menu.
 */
@Composable
private fun ActionsMenuScope.ShowGraphMenuItems(fit: Fit, fitHandle: FitHandle) {

    @Composable
    fun graphMenuItem(text: String, action: () -> Unit) =
        menuItem(
            text = text,
            icon = null,
            action = action
        )

    fun AttributeProperty<Double>?.isNegative() = (this != null) && value < 0.0
    fun AttributeProperty<Double>?.isPositive() = (this != null) && value > 0.0

    with(TheorycrafterContext.eveData) {
        val projectedModules = fit.modules.active.filter { it.type.isProjected }
        val drones = fit.drones.active
        val projectedModulesAndDrones = projectedModules + drones
        val windowManager = LocalTheorycrafterWindowManager.current

        if (fit.firepower.totalDps != 0.0) {
            graphMenuItem(
                text = "Damage Graph",
                action = { windowManager.showGraphsWindow(GraphsWindowPane.Damage, fitHandle) }
            )
        }

        if (fit.remoteRepairs.total != 0.0) {
            graphMenuItem(
                text = "Remote Repairs Graph",
                action = { windowManager.showGraphsWindow(GraphsWindowPane.RemoteRepairs, fitHandle) }
            )
        }

        val canNeutralizeEnergy = projectedModules.any {
            with(it.type) {
                isEnergyNeutralizer() || isEnergyNosferatu() || isEnergyNeutralizationBurstProjector()
            }
        } || drones.any { it.type.isEnergyNeutralizerDrone() }
        if (canNeutralizeEnergy) {
            graphMenuItem(
                text = "Neutralization Graph",
                action = { windowManager.showGraphsWindow(GraphsWindowPane.EnergyNeutralization, fitHandle) }
            )
        }

        val hasEcm = projectedModulesAndDrones.any { moduleOrDrone ->
            moduleOrDrone.ecmStrength.values.any { it.isPositive() }
        }
        if (hasEcm) {
            graphMenuItem(
                text = "ECM Effectiveness Graph",
                action = { windowManager.showGraphsWindow(GraphsWindowPane.EcmEffectiveness, fitHandle) }
            )
        }

        val hasWebs = projectedModulesAndDrones.any { it.speedFactorBonus.isNegative() }
        if (hasWebs) {
            graphMenuItem(
                text = "Webification Graph",
                action = { windowManager.showGraphsWindow(GraphsWindowPane.Webification, fitHandle) }
            )
        }

        val hasSensorDampeners = projectedModulesAndDrones.any {
            it.targetingRangeBonus.isNegative() || it.scanResolutionBonus.isNegative()
        }
        if (hasSensorDampeners) {
            graphMenuItem(
                text = "Targeting Range Dampening Graph",
                action = { windowManager.showGraphsWindow(GraphsWindowPane.TargetingRangeDampening, fitHandle) }
            )
        }

        val hasTurretDisruption = projectedModulesAndDrones.any { it.optimalRangeBonus.isNegative() }
        if (hasTurretDisruption) {
            graphMenuItem(
                text = "Turret Disruption Graph",
                action = { windowManager.showGraphsWindow(GraphsWindowPane.TurretRangeDisruption, fitHandle) }
            )
        }

        val hasMissileDisruption = projectedModules.any {
            it.missileFlightTimeBonus.isNegative() || it.missileVelocityBonus.isNegative()
        }
        if (hasMissileDisruption) {
            graphMenuItem(
                text = "Missile Disruption Graph",
                action = { windowManager.showGraphsWindow(GraphsWindowPane.MissileRangeDisruption, fitHandle) }
            )
        }

        val hasTargetPainting = projectedModulesAndDrones.any { it.signatureRadiusBonus.isPositive() }
        if (hasTargetPainting) {
            graphMenuItem(
                text = "Target Painting Graph",
                action = { windowManager.showGraphsWindow(GraphsWindowPane.TargetPainting, fitHandle) }
            )
        }

        graphMenuItem(
            text = "Capacitor Graph",
            action = { windowManager.showGraphsWindow(GraphsWindowPane.CapacitorLevel, fitHandle) }
        )

        graphMenuItem(
            text = "Lock Time Graph",
            action = { windowManager.showGraphsWindow(GraphsWindowPane.LockTime, fitHandle) }
        )
    }
}


/**
 * Adds the given items to the cargo.
 *
 * If [completeToAmount] is `true`, adds items up to, and not exceeding, the target amount.
 * Returns whether any items were actually added.
 */
private fun packCargo(
    fit: Fit,
    undoRedoQueue: FitEditorUndoRedoQueue,
    itemsAndAmounts: Collection<Pair<EveItemType, Int>>,
    completeToAmount: Boolean
): Boolean {
    val itemsToAdd = if (!completeToAmount) itemsAndAmounts else {
        // Compute the current amounts of each type
        val currentAmountByItemType = fit.cargohold.contents
            .groupBy { it.type }
            .mapValues { (_, items) ->
                items.sumOf { it.amount }
            }
        // Compute how many we need to add; filter out zeroes
        itemsAndAmounts.mapNotNull { (type, amount) ->
            val newAmount = amount - currentAmountByItemType.getOrDefault(type, 0)
            if (newAmount <= 0)
                return@mapNotNull null
            Pair(type, newAmount)
        }
    }

    if (itemsToAdd.isNotEmpty()) {
        undoRedoQueue.performAndAppend(
            bulkAddCargoAction(fit, itemsToAdd)
        )
        return true
    }
    else
        return false
}


/**
 * The icon explaining the key shortcuts.
 */
@Composable
fun KeyShortcutsIcon(
    modifier: Modifier,
    content: @Composable () -> Unit,
) {
    Icons.Info(
        contentDescription = "Key Shortcuts",
        modifier = modifier
            .tooltip(
                delayMillis = 100,  // Short delay so it appears before the user tries to click the icon
                placement = EasyTooltipPlacement.Element(
                    anchor = Alignment.BottomStart,
                    alignment = Alignment.TopStart,
                    offset = DpOffset(x = -TheorycrafterTheme.spacing.small, y = 0.dp)
                ),
                content = content
            )
    )
}


/**
 * The row for an empty module/charge slot.
 */
@Composable
fun GridScope.GridRowScope.EmptyRowContent(text: String) {
    emptyCell(cellIndex = GridCols.STATE_ICON)  // No state icon
    emptyCell(cellIndex = GridCols.TYPE_ICON)  // No type icon
    cell(
        cellIndex = GridCols.NAME,
        modifier = Modifier.weight(1f),
        contentAlignment = Alignment.CenterStart
    ) {
        Text(
            text = text,
            style = LocalTextStyle.current.copy(fontWeight = FontWeight.ExtraLight),
        )
    }
}


/**
 * An eve item selection widget.
 *
 * Used as the content of the row when editing the item in that slot.
 */
@Composable
fun <P: EveItemType> GridScope.GridRowScope.ItemSelectorRow(
    onItemSelected: (P) -> Unit,
    onEditingCancelled: () -> Unit,
    onBeforeItemChosen: (P) -> Boolean = { true },
    autoSuggest: AutoSuggest<P>,
    autoSuggestInputTransform: ((String) -> String)? = null,
    autoSuggestItemToString: (P) -> String = EveItemType::name,
    onValueChange: ((String) -> Unit)? = null,
    suggestedItemsFooter: @Composable (() -> Unit)? = null,
    suggestedItemContent: @Composable RowScope.(P, String) -> Unit =
        { itemType, text -> DefaultSuggestedEveItemTypeContent(itemType, text) },
    autoSuggestHorizontalAnchorPadding: DpRect = AutoSuggestHorizontalAnchorPaddingWithIcon,
    hint: String,
    marketGroups: List<MarketGroup>? = null,
    marketGroupsTitle: String = "",
    itemFilter: ((EveItemType) -> Boolean)? = null,
    showMarketInitially: Boolean = false,
) {
    SelectorRow(
        onItemSelected = onItemSelected,
        onEditingCancelled = onEditingCancelled,
        onBeforeItemChosen = onBeforeItemChosen,
        autoSuggest = autoSuggest,
        autoSuggestInputTransform = autoSuggestInputTransform,
        autoSuggestItemToString = autoSuggestItemToString,
        onValueChange = onValueChange,
        suggestedItemsFooter = suggestedItemsFooter,
        suggestedItemContent = suggestedItemContent,
        autoSuggestHorizontalAnchorPadding = autoSuggestHorizontalAnchorPadding,
        hint = hint,
        marketGroups = marketGroups,
        marketGroupsTitle = marketGroupsTitle,
        itemFilter = itemFilter,
        showMarketInitially = showMarketInitially
    )
}


/**
 * An eve item selection widget. This variant takes the parent of the market groups to display in the mini market tree.
 *
 * Used as the content of the row when editing the item in that slot.
 */
@Composable
fun <P: EveItemType> GridScope.GridRowScope.ItemSelectorRow(
    onItemSelected: (P) -> Unit,
    onEditingCancelled: () -> Unit,
    onBeforeItemChosen: (P) -> Boolean = { true },
    autoSuggest: AutoSuggest<P>,
    autoSuggestInputTransform: ((String) -> String)? = null,
    autoSuggestItemToString: (P) -> String = EveItemType::name,
    onValueChange: ((String) -> Unit)? = null,
    suggestedItemsFooter: @Composable (() -> Unit)? = null,
    suggestedItemContent: @Composable RowScope.(P, String) -> Unit =
        { itemType, text -> DefaultSuggestedEveItemTypeContent(itemType, text) },
    autoSuggestHorizontalAnchorPadding: DpRect = AutoSuggestHorizontalAnchorPaddingWithIcon,
    hint: String,
    marketGroupsParent: MarketGroup,
    itemFilter: ((EveItemType) -> Boolean)? = null,
    showMarketInitially: Boolean = false,
) {
    ItemSelectorRow(
        onItemSelected = onItemSelected,
        onEditingCancelled = onEditingCancelled,
        onBeforeItemChosen = onBeforeItemChosen,
        autoSuggest = autoSuggest,
        autoSuggestInputTransform = autoSuggestInputTransform,
        autoSuggestItemToString = autoSuggestItemToString,
        onValueChange = onValueChange,
        suggestedItemsFooter = suggestedItemsFooter,
        suggestedItemContent = suggestedItemContent,
        autoSuggestHorizontalAnchorPadding = autoSuggestHorizontalAnchorPadding,
        hint = hint,
        marketGroups = with(TheorycrafterContext.eveData.marketGroups) {
            childrenOf(marketGroupsParent)
        },
        marketGroupsTitle = marketGroupsParent.name,
        itemFilter = itemFilter,
        showMarketInitially = showMarketInitially,
    )
}


/**
 * The default content for an eve item in the auto-suggest dropdown.
 */
@Suppress("UnusedReceiverParameter")
@Composable
private fun RowScope.DefaultSuggestedEveItemTypeContent(
    itemType: EveItemType,
    text: String,
) {
    DefaultSuggestedEveItemTypeIcon(itemType)
    Text(text)
}


/**
 * Non-rendering icons have a natural empty padding in the image itself,
 * so we need to add artifical padding around renderings, to make them visually the same size.
 */
private val IconRenderingPadding = 3.dp


/**
 * The horizontal padding for the autosuggest dropdown anchor, to align the input text with the suggested item text.
 * This value is for cases where the dropdown contains item icons.
 */
val AutoSuggestHorizontalAnchorPaddingWithIcon = DpRect(
    left =
        // This matches the menu item padding in TheorycrafterAutoSuggestMenuItemProvider
        TheorycrafterTheme.spacing.horizontalEdgeMargin +
            // This matches the width of the icon
            TheorycrafterTheme.sizes.eveTypeIconMedium + IconRenderingPadding +
            // The spacing between the icon and text
            TheorycrafterTheme.spacing.xxsmall,
    right = TheorycrafterTheme.spacing.horizontalEdgeMargin,
    top = 0.dp,
    bottom = 0.dp
)


/**
 * The horizontal padding for the autosuggest dropdown anchor, to align the input text with the suggested item text.
 * This value is for cases where the dropdown contains only text.
 */
val AutoSuggestHorizontalAnchorPadding = DpRect(
    left = TheorycrafterTheme.spacing.horizontalEdgeMargin,
    right = TheorycrafterTheme.spacing.horizontalEdgeMargin,
    top = 0.dp,
    bottom = 0.dp
)


/**
 * The default item type icon in the auto-suggest dropdown.
 *
 * The icon already includes the icon-text spacing.
 */
@Composable
fun DefaultSuggestedEveItemTypeIcon(itemType: EveItemType) {
    // We put it in a box because the space needs to be taken even if the item type has no icon
    DefaultSuggestedIconContainer {
        Icons.EveItemType(
            itemType = itemType,
            modifier = Modifier
                .fillMaxSize()
                .thenIf(itemType.isIconRendering) {
                    padding(IconRenderingPadding)
                }
        )
    }
}


/**
 * The default container for the icon in the auto-suggest dropdown.
 *
 * The container already includes the icon-text spacing.
 */
@Composable
fun DefaultSuggestedIconContainer(
    content: @Composable () -> Unit
) {
    // We put it in a box because the space needs to be taken even if the item type has no icon
    Box(
        modifier = Modifier
            .padding(vertical = TheorycrafterTheme.spacing.xxxsmall)
            .padding(end = TheorycrafterTheme.spacing.xxsmall)  // Spacing between the icon and text
            .size(TheorycrafterTheme.sizes.eveTypeIconMedium + IconRenderingPadding),
    ) {
        content()
    }
}


/**
 * A selection widget for eve item types, but also allows additional types to be selected.
 * The type of selected objects is generic, but the user may also select items via the market tree.
 *
 * Used as the content of the row when editing the item in that slot.
 */
@Composable
fun <P> GridScope.GridRowScope.SelectorRow(
    onItemSelected: (P) -> Unit,
    onEditingCancelled: () -> Unit,
    onBeforeItemChosen: (P) -> Boolean = { true },
    autoSuggest: AutoSuggest<P>,
    autoSuggestInputTransform: ((String) -> String)? = null,
    autoSuggestItemToString: (P) -> String = Any?::toString,
    onValueChange: ((String) -> Unit)? = null,
    suggestedItemsFooter: @Composable (() -> Unit)? = null,
    suggestedItemContent: @Composable RowScope.(P, String) -> Unit,
    autoSuggestHorizontalAnchorPadding: DpRect = AutoSuggestHorizontalAnchorPaddingWithIcon,
    hint: String,
    marketGroups: List<MarketGroup>? = null,
    marketGroupsTitle: String = "",
    itemFilter: ((EveItemType) -> Boolean)? = null,
    showMarketInitially: Boolean = false,
) {
    var showMarketTree by remember { mutableStateOf(showMarketInitially) }

    if (marketGroups == null) {
        emptyCell(cellIndex = GridCols.STATE_ICON)  // No state icon
        emptyCell(cellIndex = GridCols.TYPE_ICON)  // No type icon
    } else {
        cell(
            cellIndex = GridCols.STATE_ICON,
            colSpan = 2,
            contentAlignment = Alignment.CenterEnd,
            modifier = defaultCellModifier
                .padding(end = TheorycrafterTheme.spacing.small),
        ) {
            Icons.Market(
                modifier = Modifier
                    .onMousePress {
                        showMarketTree = true
                    }
                    .tooltip("Select from Market Tree", placement = EasyTooltipPlacement.ElementTopCenter)
            )
        }
    }

    cell(
        cellIndex = GridCols.NAME,
        modifier = Modifier.weight(1f)
    ) {
        Selector(
            onItemSelected = onItemSelected,
            onEditingCancelled = onEditingCancelled,
            onBeforeItemChosen = onBeforeItemChosen,
            autoSuggest = autoSuggest,
            autoSuggestInputTransform = autoSuggestInputTransform,
            autoSuggestItemToString = autoSuggestItemToString,
            onValueChange = {
                showMarketTree = false
                onValueChange?.invoke(it)
            },
            suggestedItemsFooter = suggestedItemsFooter,
            suggestedItemContent = suggestedItemContent,
            autoSuggestHorizontalAnchorPadding = autoSuggestHorizontalAnchorPadding,
            hint = hint,
        )
    }

    if (showMarketTree && (marketGroups != null)) {
        val maxPopupHeight = TheorycrafterTheme.sizes.itemSelectorSuggestionDropdownMaxHeight
        Popup(popupPositionProvider = rememberDropdownMenuPositionProvider(reservedHeight = maxPopupHeight)) {
            Surface(
                elevation = AutoSuggestDropdownElevation,
                modifier = Modifier
                    .heightIn(max = maxPopupHeight)
            ) {
                MiniMarketTree(
                    toplevelMarketGroups = marketGroups,
                    toplevelTitle = marketGroupsTitle,
                    itemFilter = itemFilter,
                    onItemPressed = {
                        @Suppress("UNCHECKED_CAST")
                        val item = it as P
                        if (onBeforeItemChosen(item))
                            onItemSelected(item)
                    },
                    itemContent = { item ->
                        @Suppress("UNCHECKED_CAST")
                        suggestedItemContent(item as P, autoSuggestItemToString(item))
                    },
                    modifier = Modifier
                        .width(TheorycrafterTheme.sizes.fitEditorMiniMarketPopupTreeWidth)
                )
            }
        }
    }
}


/**
 * A selection widget for any type of items.
 *
 * Used as the content of the name cell when editing the item in that slot.
 */
@Composable
fun <P> GridScope.GridCellScope.Selector(
    modifier: Modifier = Modifier,
    onItemSelected: (P) -> Unit,
    onEditingCancelled: () -> Unit,
    onBeforeItemChosen: (P) -> Boolean = { true },
    autoSuggest: AutoSuggest<P>,
    autoSuggestInputTransform: ((String) -> String)? = null,
    autoSuggestItemToString: (P) -> String = Any?::toString,
    onValueChange: ((String) -> Unit)? = null,
    suggestedItemsFooter: @Composable (() -> Unit)? = null,
    suggestedItemContent: @Composable RowScope.(P, String) -> Unit = { _, text -> defaultTextItemContent(text) },
    autoSuggestHorizontalAnchorPadding: DpRect = DpRect.Zero,
    hint: String,
) {
    val selectionModel = LocalSlotSelectionModel.current
    Selector(
        modifier = modifier
            .align(Alignment.CenterStart),
        onItemSelected = onItemSelected,
        onEditingCancelled = onEditingCancelled,
        onBeforeItemChosen = onBeforeItemChosen,
        autoSuggest = autoSuggest,
        autoSuggestInputTransform = autoSuggestInputTransform,
        autoSuggestItemToString = autoSuggestItemToString,
        onValueChange = onValueChange,
        suggestedItemsFooter = suggestedItemsFooter,
        suggestedItemContent = suggestedItemContent,
        autoSuggestHorizontalAnchorPadding = autoSuggestHorizontalAnchorPadding,
        hint = hint,
        selectNextPrimaryRow = selectionModel::selectNextPrimaryRow,
    )
}


/**
 * The content of standard cell displaying a value and a tooltip.
 */
@Composable
fun TextAndTooltipCell(textAndTooltip: TextAndTooltip?) {
    if (textAndTooltip == null)
        return

    SingleLineText(
        text = textAndTooltip.text,
        color = textAndTooltip.textColor,
        overflow = TextOverflow.Visible,
        modifier = Modifier
            .thenIf(textAndTooltip.tooltipContent != null) {
                tooltip(
                    placement = EasyTooltipPlacement.ElementCenterEnd(
                        offset = DpOffsetX(TheorycrafterTheme.spacing.small)
                    ),
                    content = textAndTooltip.tooltipContent!!
                )
            }
    )
}


/**
 * A context action for item slots.
 */
class SlotContextAction(


    /**
     * Returns the text to display for this action in a context menu.
     */
    val displayName: String?,


    /**
     * Whether the action is enabled.
     */
    val enabled: Boolean = true,


    /**
     * The icon to display in the context menu item.
     */
    val icon: (@Composable () -> Unit)? = null,


    /**
    * The key shortcuts that trigger the action.
     */
    val shortcuts: Collection<KeyShortcut> = emptyList(),


    /**
     * The action.
     */
    val action: () -> Unit


) {


    /**
     * A constructor that takes a single shortcut.
     */
    constructor(
        displayName: String?,
        enabled: Boolean = true,
        icon: (@Composable () -> Unit)? = null,
        shortcut: KeyShortcut?,
        action: () -> Unit
    ) : this(
        displayName = displayName,
        enabled = enabled,
        icon = icon,
        shortcuts = shortcut?.let { listOf(it) } ?: emptyList(),
        action = action,
    )


    companion object {


        /**
         * A menu separator.
         */
        val Separator = SlotContextAction(
            displayName = "",
            shortcut = null,
            action = {}
        )


        /**
         * An action to clear the slot.
         */
        fun clear(action: () -> Unit) = SlotContextAction(
            displayName = "Remove",
            icon = { Icons.Delete() },
            shortcuts = FitEditorKeyShortcuts.ClearSlot,
            action = action
        )


        /**
         * An action to add another item like the selected one.
         */
        fun addOneItem(addAmount: (Int) -> Unit) = SlotContextAction(
            displayName = "Add Another",
            icon = { Icons.PlusOne() },
            shortcuts = FitEditorKeyShortcuts.AddOneItem,
            action = { addAmount(1) }
        )


        /**
         * An action to remove an item like the selected one.
         */
        fun removeOneItem(
            addAmount: (Int) -> Unit,
            enabled: Boolean = true,
            showInContextMenu: Boolean,
        ) = SlotContextAction(
            displayName = if (showInContextMenu) "Remove One" else null,
            enabled = enabled,
            icon = { Icons.MinusOne() },
            shortcuts = FitEditorKeyShortcuts.RemoveOneItem,
            action = { addAmount(-1) }
        )


        /**
         * An action to add another item like the selected one.
         */
        fun addOneItem(addOne: () -> Unit) = SlotContextAction(
            displayName = "Add Another",
            icon = { Icons.PlusOne() },
            shortcuts = FitEditorKeyShortcuts.AddOneItem,
            action = addOne
        )


        /**
         * An action to remove an item like the selected one.
         */
        fun removeOneItem(
            removeOne: () -> Unit,
            enabled: Boolean = true,
            showInContextMenu: Boolean,
        ) = SlotContextAction(
            displayName = if (showInContextMenu) "Remove One" else null,
            enabled = enabled,
            icon = if (showInContextMenu) { -> Icons.MinusOne() } else null,
            shortcuts = FitEditorKeyShortcuts.RemoveOneItem,
            action = removeOne
        )


        /**
         * An action to toggle the enabled state of the item.
         */
        fun toggleEnabledState(action: () -> Unit) = SlotContextAction(
            displayName = null,
            shortcuts = FitEditorKeyShortcuts.ToggleItemEnabled,
            action = action,
        )


        /**
         * An action to toggle the primary state of the item.
         */
        fun togglePrimaryState(action: () -> Unit) = SlotContextAction(
            displayName = null,
            shortcuts = FitEditorKeyShortcuts.ToggleItemPrimaryState,
            action = action
        )


        /**
         * An action to toggle the online state of the item.
         */
        fun toggleOnlineState(action: () -> Unit) = SlotContextAction(
            displayName = null,
            shortcut = FitEditorKeyShortcuts.ToggleItemOnline,
            action = action
        )


        /**
         * An action to toggle the overload state of the item.
         */
        fun toggleOverloadState(action: () -> Unit) = SlotContextAction(
            displayName = null,
            shortcut = FitEditorKeyShortcuts.ToggleItemOverheated,
            action = action
        )


        /**
         * An action to move the slot one row up.
         */
        fun moveSlotUp(
            enabled: Boolean,
            action: () -> Unit
        ) = SlotContextAction(
            displayName = "Move up",
            enabled = enabled,
            icon = { Icons.MoveUp() },
            shortcut = FitEditorKeyShortcuts.MoveSlotUpKeyShortcut,
            action = action
        )


        /**
         * An action to move the slot one row down.
         */
        fun moveSlotDown(
            enabled: Boolean,
            action: () -> Unit
        ) = SlotContextAction(
            displayName = "Move down",
            enabled = enabled,
            icon = { Icons.MoveDown() },
            shortcut = FitEditorKeyShortcuts.MoveSlotDownKeyShortcut,
            action = action
        )


        /**
         * An action to cut the item to the clipboard.
         */
        fun cutToClipboard(
            clipboardManager: ClipboardManager,
            clearAction: () -> Unit,
            clipboardText: () -> String,
        ) = SlotContextAction(
            displayName = "Cut",
            icon = { Icons.Cut() },
            shortcut = FitEditorKeyShortcuts.CutItemToClipboard,
            action = {
                clipboardManager.setText(clipboardText())
                clearAction()
            }
        )


        /**
         * An action to copy the item the clipboard.
         */
        fun copyToClipboard(
            clipboardManager: ClipboardManager,
            clipboardText: () -> String
        ) = SlotContextAction(
            displayName = "Copy",
            icon = { Icons.Copy() },
            shortcut = FitEditorKeyShortcuts.CopyItemToClipboard,
            action = {
                clipboardManager.setText(clipboardText())
            }
        )


        /**
         * An action to paste into the slot from the clipboard.
         */
        fun <T> pasteFromClipboard(
            clipboardManager: ClipboardManager,
            itemFromClipboardText: context(EveData) (String) -> T?,
            pasteItem: (T) -> Unit,
        ) = SlotContextAction(
            displayName = "Paste",
            icon = { Icons.Paste() },
            shortcut = FitEditorKeyShortcuts.PasteItemFromClipboard,
            action = {
                val clipboardContent = clipboardManager.getText() ?: return@SlotContextAction
                val item = itemFromClipboardText(TheorycrafterContext.eveData, clipboardContent.text)
                if (item != null)
                    pasteItem(item)
            }
        )


        /**
         * An action to paste into the slot from the clipboard, where obtaining the item is a suspending function.
         */
        fun <T> pasteFromClipboard(
            coroutineScope: CoroutineScope,
            clipboardManager: ClipboardManager,
            itemFromClipboardText: suspend EveData.(String) -> T?,
            pasteItem: (T) -> Unit,
        ) = SlotContextAction(
            displayName = "Paste",
            icon = { Icons.Paste() },
            shortcut = FitEditorKeyShortcuts.PasteItemFromClipboard,
            action = {
                coroutineScope.launch {
                    val clipboardContent = clipboardManager.getText() ?: return@launch
                    val item = itemFromClipboardText(TheorycrafterContext.eveData, clipboardContent.text)
                    if (item != null)
                        pasteItem(item)
                }
            }
        )


        /**
         * A context action to add the item to the cargohold.
         */
        fun addToCargo(
            undoRedoQueue: FitEditorUndoRedoQueue,
            fit: Fit,
            itemType: EveItemType,
            amount: Int = 1
        ) = SlotContextAction(
            displayName = "Add to Cargo",
            icon = { Icons.AddToCargo() },
            shortcut = FitEditorKeyShortcuts.AddToCargohold,
            action = {
                val cargoItems = fit.cargohold.contents
                val existingItemSlotIndex = cargoItems.indexOfLast { it.type == itemType }
                val existingItem = if (existingItemSlotIndex == -1) null else cargoItems[existingItemSlotIndex]
                val action = if (existingItem == null)
                    addCargoItemAction(fit, itemType, amount)
                else
                    setCargoAmountAction(fit, existingItemSlotIndex, existingItem.amount + amount)
                if (action != null) {
                    undoRedoQueue.performAndAppend(action)
                }
            }
        )


        /**
         * The [SlotContextAction] for mutating an item type.
         */
        fun mutateItem(
            itemType: EveItemType,
            openMutationEditWindow: () -> Unit,
        ): SlotContextAction? {
            if (itemType.mutation != null)
                return null

            return SlotContextAction(
                displayName = "Mutate",
                enabled = TheorycrafterContext.eveData.mutaplasmidsMutating(itemType).isNotEmpty(),
                icon = { Icons.EditMutation() },
                shortcut = FitEditorKeyShortcuts.Mutate,
                action = openMutationEditWindow
            )
        }


        /**
         * The [SlotContextAction] for editing an existing mutation.
         */
        fun editMutation(
            itemType: EveItemType,
            openMutationEditWindow: () -> Unit,
        ): SlotContextAction? {
            if (itemType.mutation == null)
                return null

            return SlotContextAction(
                displayName = "Edit Mutation",
                icon = { Icons.EditMutation() },
                shortcut = FitEditorKeyShortcuts.Mutate,
                action = openMutationEditWindow
            )
        }


        /**
         * The [SlotContextAction] for reverting an item to its base type.
         */
        fun revertToBase(
            itemType: EveItemType,
            action: () -> Unit,
        ): SlotContextAction? {
            if (itemType.mutation == null)
                return null

            return SlotContextAction(
                displayName = "Revert to Base",
                icon = { Icons.RevertMutation() },
                shortcut = FitEditorKeyShortcuts.RevertToBase,
                action = action
            )
        }


        /**
         * The [SlotContextAction] for showing the item's info.
         */
        fun showInfo(
            windowManager: TheorycrafterWindowManager,
            item: EveItem<*>,
        ) = SlotContextAction(
            displayName = when(val type = item.type) {
                is ModuleType -> if (type.isRig) "Rig" else "Module"
                is DroneType -> "Drone"
                is BoosterType -> "Booster"
                is ImplantType -> "Implant"
                is ChargeType -> "Charge"
                is SubsystemType -> "Subsystem"
                else -> "Item"
            } + " Info",
            icon = { Icons.EveItemInfo() },
            shortcut = FitEditorKeyShortcuts.ItemInfo,
            action = { windowManager.showItemInfoWindow(item) }
        )


        /**
         * The [SlotContextAction] for editing the item's price (override).
         */
        fun editItemPriceOverride(
            itemType: EveItemType,
            enabled: Boolean,
            showPriceOverrideDialog: (EveItemType) -> Unit,
        ) = SlotContextAction(
            displayName = "Edit Price Override",
            icon = { Icons.Prices() },
            enabled = enabled,
            shortcut = null,
            action = { showPriceOverrideDialog(itemType) }
        )


    }


}


/**
 * The shortcut to open the associated fit in a new window.
 */
private val OpenFitInNewWindowKeyShortcut = FitEditorKeyShortcuts.OpenRemoteEffectSource


/**
 * The slot context action to show the associated fit of a composition ship in a new window.
 */
fun SlotContextAction.Companion.openFit(fitOpener: FitOpener, fitHandle: FitHandle?) =
    SlotContextAction(
        displayName = "Open Fit in Window",
        icon = { Icons.OpenInNew() },
        enabled = fitHandle != null,
        shortcuts = OpenFitInNewWindowKeyShortcut,
        action = {
            fitOpener.openFitInSecondaryWindow(fitHandle!!)
        }
    )


/**
 * The [SlotContextAction] for pasting an item that is possibly a dynamic item that needs to be retrieved via ESI.
 */
@Composable
fun <T: Any> SlotContextAction.Companion.rememberPastePossiblyDynamicItem(
    dynamicItemFromClipboardText: EveData.(String) -> Deferred<T?>?,
    localItemFromClipboardText: EveData.(String) -> T?,
    pasteItem: (T) -> Unit,
): SlotContextAction {
    val clipboardManager = LocalClipboardManager.current
    val coroutineScope = rememberCoroutineScope()

    var showRetrievingRemoteInfoDialog by remember { mutableStateOf(false) }
    val cancelRetrievingRemoteInfoChannel = remember { Channel<Unit>() }
    if (showRetrievingRemoteInfoDialog) {
        ActionInProgressDialog(
            text = "Retrieving item info",
            onStopAction = {
                coroutineScope.launch {
                    cancelRetrievingRemoteInfoChannel.send(Unit)
                }
            }
        )
    }

    val dialogs = LocalStandardDialogs.current

    return remember(localItemFromClipboardText, dynamicItemFromClipboardText, pasteItem, clipboardManager, coroutineScope) {
        pasteFromClipboard(
            coroutineScope = coroutineScope,
            clipboardManager = clipboardManager,
            itemFromClipboardText = { text ->
                val deferredRemoteModule = dynamicItemFromClipboardText(text)
                if (deferredRemoteModule != null) {
                    val showDialogJob = coroutineScope.async {
                        delay(200)  // No need to show the dialog if the response comes in quickly
                        showRetrievingRemoteInfoDialog = true
                        try {
                            cancelRetrievingRemoteInfoChannel.receive()
                        } finally {
                            showRetrievingRemoteInfoDialog = false
                        }
                    }

                    return@pasteFromClipboard runCatching {
                        select {
                            showDialogJob.onAwait { null }
                            deferredRemoteModule.onAwait { it }
                        }
                    }.also {
                        showDialogJob.cancel()
                        deferredRemoteModule.cancel()
                    }.getOrElse {
                        dialogs.showErrorDialog("Error fetching item:\n${it.message ?: ""}")
                        null
                    }
                }

                localItemFromClipboardText(text)
            },
            pasteItem = pasteItem,
        )
    }
}


/**
 * Returns a [SlotContextAction] for editing an item's price override.
 */
@Composable
fun SlotContextAction.Companion.rememberEditPriceOverride(
    itemType: EveItemType,
): SlotContextAction {
    var showEditPriceDialog by remember { mutableStateOf(false) }
    val prices = TheorycrafterContext.eveItemPrices

    if (showEditPriceDialog && (prices != null)) {
        EditPriceOverrideDialog(
            itemType = itemType,
            initialPrice = prices[itemType],
            onChangePrice = { _, newPrice ->
                prices.setOverride(itemType, newPrice)
            },
            onRemoveOverride = {
                prices.removeOverride(itemType)
            },
            onDismiss = { showEditPriceDialog = false }
        )
    }

    return remember(itemType, prices) {
        editItemPriceOverride(
            itemType = itemType,
            enabled = prices != null,
            showPriceOverrideDialog = {
                if (prices != null)
                    showEditPriceDialog = true
            }
        )
    }
}


/**
 * Returns a [Modifier] that invokes the context actions when their key shortcuts are pressed.
 */
fun List<SlotContextAction>.toModifier() =
    this.mapNotNull {
        if (it.shortcuts.isEmpty())
            null
        else
            Modifier.onKeyShortcut(it.shortcuts, enabled = it.enabled) { it.action() }
    }
    .fold(Modifier, Modifier::then)


/**
 * The contents of a cell displaying the icon for the given eve item.
 */
@Composable
fun TypeIconCellContent(item: EveItem<*>?) {
    if (item != null) {
        Box(
            Modifier.wrapContentSize(unbounded = true)  // This lets the icon extend beyond the box
        ) {
            Icons.EveItemType(
                itemType = item.type,
                modifier = Modifier
                    .size(TheorycrafterTheme.sizes.eveTypeIconSmall)
                    .thenIf(item.type.isIconRendering) {
                        // Non-rendering icons have a natural empty padding in the image itself,
                        // so we need to add artifical padding around renderings, to make them visually the same size
                        padding(3.dp)
                    }
                    .alpha(LocalContentAlpha.current)
            )
        }
    }
}


/**
 * The dragged item slot.
 */
@Composable
fun GridScope.DraggedItemSlotRepresentation(text: String) {
    draggedRow(
        rowIndex = 0,
        modifier = Modifier
            .fillMaxWidth()
            .background(TheorycrafterTheme.colors.draggedSlotBackground())
            .padding(SLOT_ROW_PADDING)
            .height(TheorycrafterTheme.sizes.fitEditorSlotRowHeight)  // Make the height independent on editing
    ) {
        emptyCell(GridCols.STATE_ICON)
        emptyCell(GridCols.TYPE_ICON)
        cell(GridCols.NAME, colSpan = GridCols.LAST - GridCols.NAME, contentAlignment = Alignment.CenterStart) {
            Text(text)
        }
    }
}


/**
 * A menu item corresponding to the given [SlotContextAction].
 */
@Composable
fun MenuItem(
    contextAction: SlotContextAction,
    reserveSpaceForKeyShortcut: Boolean,
    closeContextMenu: () -> Unit
) {
    val text = contextAction.displayName ?: return
    MenuItem(
        text = text,
        enabled = contextAction.enabled,
        icon = contextAction.icon ?: EmptyIcon,
        displayedKeyShortcut = contextAction.shortcuts.firstOrNull(),
        reserveSpaceForKeyShortcut = reserveSpaceForKeyShortcut,
        onCloseMenu = closeContextMenu,
        action = contextAction.action
    )
}


/**
 * A modifier for responding to carousel shortcuts.
 */
fun <T> Modifier.carouselShortcuts(
    carousel: Carousel<T>,
    itemType: T,
    onItemTypeSelected: (T) -> Unit
): Modifier = carouselShortcuts(
    prev = carousel::prev,
    next = carousel::next,
    itemType = itemType,
    onItemTypeSelected = onItemTypeSelected
)


/**
 * A modifier for responding to carousel shortcuts.
 */
fun <T> Modifier.carouselShortcuts(
    prev: (T) -> T,
    next: (T) -> T,
    itemType: T,
    onItemTypeSelected: (T) -> Unit,
) = this
    .onKeyShortcut(FitEditorKeyShortcuts.CarouselPrev) {
        onItemTypeSelected(prev(itemType))
    }
    .onKeyShortcut(FitEditorKeyShortcuts.CarouselNext) {
        onItemTypeSelected(next(itemType))
    }


/**
 * Controls whether the carousel animation should run.
 */
private var shouldRunCarouselAnimation by mutableStateOf(false)


/**
 * An undo-redo action that sets the carousel animation to run.
 */
private val TriggerCarouselAnimationAction = object: FitEditorUndoRedoAction {

    context(FitEditorUndoRedoContext)
    override suspend fun perform() {
        shouldRunCarouselAnimation = true
    }

    context(FitEditorUndoRedoContext)
    override suspend fun revert() {
        shouldRunCarouselAnimation = true
    }

}


/**
 * If [trigger] is true, adds to the given action another action that triggers the carousel animation to run.
 */
fun FitEditorUndoRedoAction.withCarouselAnimation(trigger: Boolean): FitEditorUndoRedoAction {
    return if (trigger)
        undoRedoTogether(
            TriggerCarouselAnimationAction,
            this
        )
    else
        this
}


/**
 * Animates the given content when the item changes to the previous or next one in the given carousel.
 */
@Composable
fun <T> CarouselSlotContent(
    carousel: Carousel<T>,
    targetState: T,
    modifier: Modifier = Modifier,
    isValid: @Composable (T) -> Boolean = { true },
    invalidityTooltipContent: (@Composable () -> Unit)? = null,
    text: (T) -> String,
    extraContent: (@Composable RowScope.(T) -> Unit)? = null,
) {
    // Don't even try the animation when the carousel itself changes by keying AnimatedContent on the carousel.
    // Not only do we not need an animation in this case, but it would also crash the program, because when the target
    // state changes such that the carousel also changes (for example, by replacing an implant via the "Empty Implant
    // Slot"), itemNameTransitionSpec will try to look up the "current" state in the new carousel, will not find it, and
    // throw an exception.
    key(carousel) {
        val transitionSpec = remember(carousel) {
            itemNameTransitionSpec(carousel)
        }
        AnimatedContent(
            targetState = targetState,
            transitionSpec = transitionSpec,
            modifier = modifier,
        ) {
            VerticallyCenteredRow {
                val isItemValid = isValid(it)
                SingleLineText(
                    text = remember(text, it) { text(it) },
                    color = TheorycrafterTheme.colors.invalidContent(valid = isItemValid),
                    modifier = Modifier
                        .weight(1f, fill = false)  // Give priority to the extra content when not enough horizontal space
                        .thenIf(!isItemValid && (invalidityTooltipContent != null)) {
                            tooltip { invalidityTooltipContent!!.invoke() }
                        },
                )

                extraContent?.invoke(this, it)
            }

            // When the transition completes running, set shouldRunCarouselAnimation to false
            if (transition.isRunning) {
                DisposableEffect(Unit) {
                    onDispose {
                        shouldRunCarouselAnimation = false
                    }
                }
            }
        }
    }
}


/**
 * A [CarouselSlotContent] for [EveItemType]s, where the item is valid if the fit's skill set fulfills all its skill
 * requirements.
 *
 * Note that [T] itself is not [EveItemType], but instead we take a function that returns one from it because some
 * carousels (such as for drones) need to pass more information through the [AnimatedContent], and so for them [T] is
 * not actually an [EveItemType].
 */
@Composable
fun <T> CarouselSlotContent(
    carousel: Carousel<T>,
    carouselItem: T,
    eveItemType: (T) -> EveItemType?,
    modifier: Modifier = Modifier,
    text: (T) -> String,
    extraContent: (@Composable RowScope.(T) -> Unit)? = null,
) {
    val skillSet = LocalFit.current.character.skillSet
    CarouselSlotContent(
        carousel = carousel,
        targetState = carouselItem,
        modifier = modifier,
        isValid = {
            remember (it, skillSet) {
                derivedStateOf {
                    val itemType = eveItemType(it)
                    (itemType == null) || skillSet.fulfillsAllRequirements(itemType)
                }
            }.value
        },
        invalidityTooltipContent = {
            val itemType = eveItemType(carouselItem)!!  // `null` item type is always valid
            val unfulfilledReqs = skillSet.unfulfilledRequirements(itemType)
            UnfulfilledSkillRequirementsTooltipContent(unfulfilledReqs)
        },
        text = text,
        extraContent = extraContent
    )
}


/**
 * A [CarouselSlotContent] for [EveItemType]s, where the item is valid if the fit's skill set fulfills all its skill
 * requirements.
 */
@Composable
fun <T: EveItemType?> CarouselSlotContent(
    carousel: Carousel<T>,
    itemType: T,
    modifier: Modifier = Modifier,
    text: (T) -> String,
    extraContent: (@Composable RowScope.(T) -> Unit)? = null,
) {
    CarouselSlotContent(
        carousel = carousel,
        carouselItem = itemType,
        eveItemType = { it },
        modifier = modifier,
        text = text,
        extraContent = extraContent
    )
}


/**
 * The content of the tooltip showing the list of unfulfilled skill requirements.
 */
@Composable
private fun UnfulfilledSkillRequirementsTooltipContent(requirements: List<SkillRequirement>) {
    Column(
        verticalArrangement = Arrangement.spacedBy(TheorycrafterTheme.spacing.xxxsmall)
    ) {
        val eveData = TheorycrafterContext.eveData
        Text(
            text = "Requires",
            fontWeight = FontWeight.Bold,
            modifier = Modifier.padding(bottom = TheorycrafterTheme.spacing.xxsmall)
        )
        for (req in requirements) {
            Text("${eveData.skillType(req.skillId).name} level ${req.level}")
        }
    }
}


/**
 * A [ContentTransform] with no animation.
 */
private val NonAnimatedContentTransform: ContentTransform = (
        fadeIn(SnapSpec()) togetherWith fadeOut(SnapSpec()))


/**
 * Returns the transition spec to use when switching between items in the carousel.
 */
private fun <T> itemNameTransitionSpec(
    carousel: Carousel<T>,
) = fun AnimatedContentTransitionScope<T>.(): ContentTransform {
    if (!shouldRunCarouselAnimation)
        return NonAnimatedContentTransform

    // For some reason, this function is also getting called several times
    // when the initial and target states are the same
    if (initialState == targetState)
        return NonAnimatedContentTransform

    val (slideInInitialOffset, slideOutTargetOffset) = when (targetState) {
        carousel.next(initialState) -> // Selecting the next item
            Pair({ width: Int -> width/4 }, { width: Int -> -width })
        carousel.prev(initialState) ->  // Selecting the previous item
            Pair({ width: Int -> -width }, { width: Int -> width/4 })
        else ->
            return NonAnimatedContentTransform
    }

    val durationMillis = 120
    val slideAnimationSpec = tween<IntOffset>(durationMillis = durationMillis, easing = FastOutLinearInEasing)
    val fadeAnimationSpec = tween<Float>(durationMillis = durationMillis, easing = LinearEasing)
    return (
            slideInHorizontally(
                animationSpec = slideAnimationSpec,
                initialOffsetX = slideInInitialOffset
            ) + fadeIn(animationSpec = fadeAnimationSpec)
                    togetherWith
                    slideOutHorizontally(
                        animationSpec = slideAnimationSpec,
                        targetOffsetX = slideOutTargetOffset
                    ) + fadeOut(animationSpec = fadeAnimationSpec)
            ) using
            SizeTransform(clip = true)
}


/**
 * The grid column indices.
 */
object GridCols {
    const val STATE_ICON = 0
    const val TYPE_ICON = 1
    const val NAME = 2
    const val POWER = 3
    const val CPU = 4
    const val RANGE = 5
    const val EFFECT = 6
    const val PRICE = 7
    const val LAST = PRICE
}


/**
 * The alignment in each column.
 */
private val ColumnAlignment = listOf(
    Alignment.Center,       // state icon
    Alignment.Center,       // type icon
    Alignment.CenterStart,  // name
    Alignment.CenterEnd,    // power
    Alignment.CenterEnd,    // cpu
    Alignment.CenterEnd,    // range
    Alignment.CenterEnd,    // effect
    Alignment.CenterEnd,    // price
)


/**
 * The grid header row.
 */
@Composable
private fun HeaderRow(modifier: Modifier = Modifier) {
    SimpleGridHeaderRow(
        modifier = modifier
            .padding(HORIZONTAL_SLOT_ROW_PADDING),
        columnWidths = TheorycrafterTheme.sizes.fitEditorColumnWidths,
        defaultCellContentAlignment = ColumnAlignment::get
    ) {
        EmptyCell(index = GridCols.STATE_ICON)
        EmptyCell(index = GridCols.TYPE_ICON)
        EmptyCell(index = GridCols.NAME)
        TextCell(index = GridCols.POWER, "Power")
        TextCell(index = GridCols.CPU, "CPU")
        TextCell(index = GridCols.RANGE, "Range")
        TextCell(index = GridCols.EFFECT, "Effect")
        AnimatedVisibility(visible = TheorycrafterContext.settings.prices.showInFitEditor) {
            TextCell(index = GridCols.PRICE, "Price")
        }
    }
}


/**
 * The row of the title of each section.
 */
@Composable
fun GridScope.SectionTitleRow(
    rowIndex: Int,
    isFirst: Boolean = false,
    text: AnnotatedString,
    extraContent: (@Composable RowScope.() -> Unit)? = null
) {
    row(
        rowIndex = rowIndex,
        modifier = Modifier
            .fillMaxWidth()
            .then(if (!isFirst) Modifier.padding(top = TheorycrafterTheme.spacing.medium) else Modifier)
            .padding(SLOT_ROW_PADDING)
            .padding(bottom = TheorycrafterTheme.spacing.xxxsmall)
    ) {
        cell(
            cellIndex = 0,
            colSpan = GridCols.LAST + 1,
            contentAlignment = Alignment.CenterStart
        ) {
            Row(
                modifier = Modifier.height(IntrinsicSize.Min),
                horizontalArrangement = Arrangement.spacedBy(TheorycrafterTheme.spacing.medium)
            ) {
                Text(
                    text = text,
                    style = TheorycrafterTheme.textStyles.fitEditorSectionTitle,
                    maxLines = 1,
                    overflow = TextOverflow.Visible,
                    softWrap = false,
                )

                extraContent?.invoke(this)
            }
        }
    }
}


/**
 * Appends extra information (provided by [block]) to the builder of a section title.
 */
inline fun AnnotatedString.Builder.appendSectionTitleExtraInfo(block: AnnotatedString.Builder.() -> Unit) {
    withStyle(TheorycrafterTheme.textStyles.fitEditorSectionTitleExtraInfo) {
        append("  ")
        block()
    }
}


/**
 * Returns text saying the given item is invalid in the currently active tournament.
 */
inline fun <T: EveItemType?> itemIllegalityInTournamentReason(
    itemType: T,
    isValid: (item: T) -> Boolean
): String? = if (!isValid(itemType)) "${itemType?.name ?: "This"} is illegal in the active tournament" else null


/**
 * Returns a modifier indicating the validity of a slot row.
 */
@Composable
fun Modifier.slotRowValidity(invalidityReason: String?) =
    if (invalidityReason == null)
        this
    else
        this
            .background(TheorycrafterTheme.colors.base().errorBackground)
            .tooltip(invalidityReason)


/**
 * Adds styling to the given [String] to indication whether the resource usage is valid.
 */
fun String.withValidUsageStyle(isValid: Boolean, errorColor: Color) =
    AnnotatedString(
        text = this,
        spanStyle = SpanStyle(if (isValid) Color.Unspecified else errorColor)
    )


/**
 * Returns the text for the usage of a resource and whether the usage is within bounds.
 */
@Composable
fun <T: Number> resourceUseText(
    resource: Fit.Resource<T>,
    valueToText: (T, withUnits: Boolean) -> String,
): Pair<String, Boolean> {
    val total = resource.total
    val used = resource.used
    val remaining = resource.minus(total, used)
    return if (used.toDouble() == 0.0)
        valueToText(total, true) to true
    else
        "${valueToText(remaining, false)}/${valueToText(total, true)}" to (remaining.toDouble() >= 0.0)
}


/**
 * Returns the available amount of the given resource after subtracting the resource need of the currently fitted item.
 */
fun <T: Number> Fit.Resource<T>.availableWithout(currentItemNeed: T?): T {
    val usedValue = used
    val usedWithoutItem = if (currentItemNeed == null)
        usedValue
    else
        minus(usedValue, currentItemNeed)

    return minus(total, usedWithoutItem)
}


/**
 * The widget for displaying the resource need (e.g. PG, CPU) of an item in its suggested items row.
 */
@Composable
fun <T: Number> ItemResourceNeedInSuggestedItemsRow(
    resource: Fit.Resource<T>,
    resourceNeed: T?,
    currentItemNeed: T?,
    valueToText: (T) -> String,
    width: Dp = Dp.Unspecified,
) {
    if (resourceNeed == null)
        return

    val availableWithoutCurrentModule = resource.availableWithout(currentItemNeed)
    val isOver = resource.minus(availableWithoutCurrentModule, resourceNeed).toDouble() < 0
    Text(
        text = valueToText(resourceNeed),
        textAlign = TextAlign.End,
        style = TheorycrafterTheme.textStyles.fitEditorAutosuggestFooter,
        color = TheorycrafterTheme.colors.invalidContent(valid = !isOver),
        modifier = Modifier.width(width)
    )
}


/**
 * The widget for displaying the available resource (e.g. PG, CPU) of the fit in the autosuggest footer.
 */
@Composable
fun <T: Number> FitResourceAvailableInAutoSuggestFooter(
    resource: Fit.Resource<T>,
    currentItemNeed: T?,
    valueToText: (T) -> String,
) {
    val availableWithoutCurrentModule = resource.availableWithout(currentItemNeed)
    Text(
        text = valueToText(availableWithoutCurrentModule),
        textAlign = TextAlign.End,
        modifier = Modifier.widthIn(min = TheorycrafterTheme.sizes.fitEditorSuggestedItemsResourceUseWidth)
    )
}


/**
 * The container for the footer in fit editor suggested items dropdown.
 */
@Composable
fun FitEditorSuggestedItemsFooter(
    content: @Composable RowScope.() -> Unit
) {
    VerticallyCenteredRow(
        modifier = Modifier
            .fillMaxWidth()
            .background(TheorycrafterTheme.colors.smallFooterBackground())
            .padding(
                horizontal = TheorycrafterTheme.spacing.horizontalEdgeMargin,
                vertical = TheorycrafterTheme.spacing.xxsmall
            ),
        horizontalArrangement = Arrangement.End
    ) {
        ProvideTextStyle(TheorycrafterTheme.textStyles.fitEditorAutosuggestFooter) {
            content()
        }
    }
}


/**
 * Manages the staleness flag for slot actions.
 */
class SlotActionContext {


    /**
     * Whether the actions object is stale.
     */
    var stale = false
        private set


    /**
     * Marks the actions as stale.
     */
    fun markStale() {
        stale = true
    }


    /**
     * Returns a [FitEditorUndoRedoAction] that marks the actions as stale when performed.
     */
    fun markStaleAction(): FitEditorUndoRedoAction = object: FitEditorUndoRedoAction {

        context(FitEditorUndoRedoContext)
        override suspend fun perform() = markStale()

        context(FitEditorUndoRedoContext)
        override suspend fun revert() {}

    }


    /**
     * Marks the actions as fresh.
     */
    fun reset() {
        stale = false
    }


}


/**
 * Remembers the actions with the given keys, together with a staleness flag which gets reset on each recomposition. The
 * actions should check the staleness flag before performing anything, and set the flag via
 * [SlotActionContext.markStale] when performing an action that makes further actions illegal until a recomposition
 * occurs.
 *
 * This is needed because it's possible for several user events (e.g. key events) to be received without a recomposition
 * occurring between them. A naive implementation of a set of actions for a slot can then find itself being asked to
 * perform an action that is no longer legal, because the UI state hasn't been updated yet.
 *
 * For example, a "remove" action on a drone slot can be called twice in a row without a recomposition in the middle.
 * If the action is implemented naively, it can try to remove a drone that is no longer fitted.
 */
@Composable
fun <T> rememberSlotActions(vararg keys: Any?, actions: SlotActionContext.() -> T): T {
    val context = remember { SlotActionContext() }
    SideEffect {
        context.reset()  // Whenever a (re)composition occurs, reset stale to false
    }

    return remember(*keys) {
        actions(context)
    }
}


/**
 * The order in which we're displaying the module racks.
 */
private val MODULE_RACK_ORDER = listOf(
    ModuleSlotType.HIGH,
    ModuleSlotType.MEDIUM,
    ModuleSlotType.LOW,
    ModuleSlotType.RIG
)


/**
 * Provides a temporary fitting scope with a fit similar to the edited one in the aspects required to obtain the
 * information we need. Currently, this is used to compute things such as module PG/CPU needs before they are actually
 * added to the edited fit.
 */
@Stable
class TempFittingScopeProvider(
    private val editedFit: Fit,
    private val fittingScope: TempFittingScope
) {


    /**
     * Returns the temporary fitting scope. Users should always call this function to obtain the scope and not store
     * it between possible fit changes.
     */
    fun getTempFittingScope(): TempFittingScope {
        return fittingScope
    }


    /**
     * Updates the temp fitting scope.
     */
    @Composable
    fun update() {
        // Replicate the subsystems because they can affect fitting.
        // For example, "Legion Offensive - Liquid Crystal Magnifiers" gives a bonus to fitting medium lasers.
        val subsystemByKind = editedFit.requiredSubsystemByKind
        if (subsystemByKind != null) {
            LaunchedEffect(*subsystemByKind.values.toTypedArray()) {
                with(fittingScope) {
                    val tempFitSubsystems = tempFit.subsystemByKind!!
                    modify {
                        for (subsystemKind in SubsystemType.Kind.entries) {
                            val fitSubsystemType = subsystemByKind[subsystemKind].type
                            val tempFitSubsystemType = tempFitSubsystems[subsystemKind]?.type
                            if (fitSubsystemType != tempFitSubsystemType)
                                tempFit.setSubsystem(fitSubsystemType)
                        }
                    }
                }
            }
        }

        // Replicate the implants, because they can affect fitting.
        // For example, the WU-100x implants reduce the CPU need of turrets.
        val fitImplants = editedFit.implants.slots.toList()
        val fitImplantsEnabledStates = fitImplants.map { it?.enabled }
        LaunchedEffect(*fitImplants.toTypedArray(), *fitImplantsEnabledStates.toTypedArray()) {
            with(fittingScope) {
                val tempFitImplantSlots = tempFit.implants.slots.toList()
                modify {
                    for (slotIndex in fitImplants.indices) {
                        val fitImplant = fitImplants[slotIndex]
                        val fitImplantType = fitImplant?.type
                        var tempFitImplant = tempFitImplantSlots[slotIndex]
                        val tempFitImplantType = tempFitImplant?.type
                        if (fitImplantType != tempFitImplantType) {
                            if (tempFitImplantType != null) {
                                tempFit.removeImplant(slotIndex)
                                tempFitImplant = null
                            }
                            if (fitImplantType != null) {
                                tempFitImplant = tempFit.fitImplant(fitImplantType)
                            }
                        }
                        tempFitImplant?.setEnabled(fitImplantsEnabledStates[slotIndex]!!)
                    }
                }
            }
        }

        // Replicate the rigs, because they can affect fitting.
        // For example, the "Medium Liquid Cooled Electronics" rigs reduce the CPU need of modules which require the
        // electronics upgrades skill (e.g. Signal Amplifiers).
        val fitRigs = editedFit.modules.slotsInRack(ModuleSlotType.RIG).toList()
        val fitRigsEnabledStates = fitRigs.map { it?.enabled }
        LaunchedEffect(*fitRigs.toTypedArray(), *fitRigsEnabledStates.toTypedArray()) {
            with(fittingScope) {
                val tempFitRigs = tempFit.modules.slotsInRack(ModuleSlotType.RIG).toList()
                modify {
                    for (slotIndex in fitRigs.indices) {
                        val fitRig = fitRigs[slotIndex]
                        val fitRigType = fitRig?.type
                        var tempFitRig = tempFitRigs[slotIndex]
                        val tempFitRigType = tempFitRig?.type
                        if (fitRigType != tempFitRigType) {
                            if (tempFitRigType != null) {
                                tempFit.removeModule(ModuleSlotType.RIG, slotIndex)
                                tempFitRig = null
                            }
                            if (fitRigType != null) {
                                tempFitRig = tempFit.fitModule(fitRigType, slotIndex)
                            }
                        }
                        tempFitRig?.setEnabled(fitRigsEnabledStates[slotIndex]!!)
                    }
                }
            }
        }
    }


}


/**
 * Remembers and updates a [TempFittingScopeProvider].
 */
@Composable
private fun rememberUpdatedTempFittingScopeProvider(fit: Fit): TempFittingScopeProvider {
    val tempFittingScope = TheorycrafterContext.fits.rememberTempFittingScope(fit, fit.ship.type)
    return remember(fit, tempFittingScope) {
        TempFittingScopeProvider(
            editedFit = fit,
            fittingScope = tempFittingScope
        )
    }.also {
        it.update()
    }
}


/**
 * The composition local of a [TempFittingScopeProvider], providing a temporary fit of the same ship as the currently
 * displayed one.
 */
val LocalTempFittingScopeProvider = compositionLocalOf<TempFittingScopeProvider> { error("Missing local TempFittingScope") }


/**
 * The composition local of the [Fit] we're displaying and editing in the fit editor.
 */
val LocalFit = compositionLocalOf<Fit> { error("Missing local Fit") }


/**
 * Returns a [Modifier] that invokes undo and redo on the given [UndoRedoQueue] when the corresponding key shortcuts
 * are pressed.
 */
private fun Modifier.undoRedoKeyShortcuts(undoRedoQueue: UndoRedoQueue<*>?): Modifier {
    if (undoRedoQueue == null)
        return this

    return this
        .onKeyShortcut(KeyShortcut.Undo) {
            runBlocking {
                undoRedoQueue.undo()
            }
        }
        .onKeyShortcut(KeyShortcut.Redo) {
            runBlocking {
                undoRedoQueue.redo()
            }
        }
}


/**
 * Creates the [RackSlotGroupingActions], per module rack.
 */
@Composable
private fun rememberRackSlotGroupingActions(
    fit: Fit,
    rackIndex: Int,
): RackSlotGroupingActions = remember(fit, rackIndex) {

    val slotType = MODULE_RACK_ORDER[rackIndex]

    object: RackSlotGroupingActions() {

        context(FitEditorUndoRedoContext)
        private fun assembleGroup(group: MultiModuleSlot) {
            moduleSlotGroupsState[rackIndex] =
                assembleGroup(moduleSlotGroupsState[rackIndex], group)
            selectionModel?.onGroupAssembled(group)
        }

        context(FitEditorUndoRedoContext)
        private fun disbandGroup(group: MultiModuleSlot) {
            moduleSlotGroupsState[rackIndex] =
                disbandGroup(fit, slotType, moduleSlotGroupsState[rackIndex], group)
            selectionModel?.onGroupDisbanded(group)
        }

        context(FitEditorUndoRedoContext)
        private fun addSlot(group: MultiModuleSlot, slotIndex: Int): MultiModuleSlot {
            val (updatedGroups, updatedGroup) =
                addSlot(fit, slotType, moduleSlotGroupsState[rackIndex], group, slotIndex)
            moduleSlotGroupsState[rackIndex] = updatedGroups
            selectionModel?.onSlotAddedToGroup(updatedGroup, slotIndex)
            return updatedGroup
        }

        context(FitEditorUndoRedoContext)
        private fun removeSlot(group: MultiModuleSlot, slotIndex: Int): MultiModuleSlot {
            val (updatedGroups, updatedGroup) =
                removeSlot(fit, slotType, moduleSlotGroupsState[rackIndex], group, slotIndex)
            moduleSlotGroupsState[rackIndex] = updatedGroups
            selectionModel?.onSlotRemovedFromGroup(updatedGroup, slotIndex)
            return updatedGroup
        }

        override fun assembleGroupAction(group: MultiModuleSlot) = object: FitEditorUndoRedoAction {

            context(FitEditorUndoRedoContext)
            override suspend fun perform() = assembleGroup(group)

            context(FitEditorUndoRedoContext)
            override suspend fun revert() = disbandGroup(group)

        }

        override fun disbandGroupAction(group: MultiModuleSlot) = object: FitEditorUndoRedoAction {

            context(FitEditorUndoRedoContext)
            override suspend fun perform() = disbandGroup(group)

            context(FitEditorUndoRedoContext)
            override suspend fun revert() = assembleGroup(group)

        }

        override fun addSlotAction(group: MultiModuleSlot, slotIndex: Int) = object: FitEditorUndoRedoAction {

            // The group itself changes when a slot is added/removed, so we must use the new one
            var updatedGroup = group

            context(FitEditorUndoRedoContext)
            override suspend fun perform() {
                updatedGroup = addSlot(updatedGroup, slotIndex)
            }

            context(FitEditorUndoRedoContext)
            override suspend fun revert() {
                updatedGroup = removeSlot(updatedGroup, slotIndex)
            }

        }

        override fun removeSlotAction(group: MultiModuleSlot, slotIndex: Int) = object: FitEditorUndoRedoAction {

            // The group itself changes when a slot is added/removed, so we must use the new one
            var updatedGroup = group

            context(FitEditorUndoRedoContext)
            override suspend fun perform() {
                updatedGroup = removeSlot(updatedGroup, slotIndex)
            }

            context(FitEditorUndoRedoContext)
            override suspend fun revert() {
                updatedGroup = addSlot(updatedGroup, slotIndex)
            }

        }

    }

}


/**
 * The state holder for the module slot groups.
 *
 * The indices of the racks match the order in [MODULE_RACK_ORDER].
 */
@Stable
class ModuleSlotGroupsState(


    /**
     * The fit whose slot groups this instance is holding.
     */
    private val fit: Fit,


    /**
     * The saved fit state.
     */
    private val savedFitState: FitEditorFitSavedState


) {


    /**
     * Whether weapons should be grouped.
     */
    var groupWeapons: Boolean by mutableStateOf(savedFitState.groupWeapons)
        .onSet { groupWeapons ->
            savedFitState.groupWeapons = groupWeapons
            MODULE_RACK_ORDER.withIndex().forEach { (index, slotType) ->
                slotGroupsByRackIndex[index] = moduleGrouping(fit, slotType, groupWeapons)
            }
        }


    /**
     * The list of slot groups in each rack.
     */
    private val slotGroupsByRackIndex: SnapshotStateList<List<ModuleSlotGroup>> =
        MODULE_RACK_ORDER.map {
            moduleGrouping(fit, it, groupWeapons)
        }.toMutableStateList()


    /**
     * Returns the module slot groups in the rack at the given index.
     */
    operator fun get(rackIndex: Int) = slotGroupsByRackIndex[rackIndex]


    /**
     * Sets the module slot groups in the rack at the given index.
     */
    operator fun set(rackIndex: Int, groups: List<ModuleSlotGroup>) {
        slotGroupsByRackIndex[rackIndex] = groups
    }


    /**
     * Returns all the module slot groups.
     */
    fun all(): List<ModuleSlotGroup> = slotGroupsByRackIndex.flatten()


}


/**
 * Returns a remembered [ModuleSlotGroupsState].
 */
@Composable
private fun rememberSlotGroupsState(fit: Fit, savedState: FitEditorFitSavedState): ModuleSlotGroupsState {
    val state: ModuleSlotGroupsState = remember(fit) {
        ModuleSlotGroupsState(fit, savedState)
    }

    // Update the slot groups when the ship's slot layout changes
    for (rackIndex in MODULE_RACK_ORDER.indices) {
        val slotType = MODULE_RACK_ORDER[rackIndex]
        val slotGroups = state[rackIndex]

        val displayedSlotCount = fit.modules.relevantSlotCount(slotType)
        val currentSlotCount = slotGroups.maxOfOrNull { it.slotIndices.max() + 1 } ?: 0
        if (displayedSlotCount > currentSlotCount) {
            // Add the overflow slots
            state[rackIndex] +=
                List(displayedSlotCount - currentSlotCount) {
                    SingleModuleSlot(fit, slotType, currentSlotCount + it)
                }
        }
        else {
            // Remove the extra (empty) slot groups
            state[rackIndex] = slotGroups.filter { slotGroup ->
                (slotGroup !is SingleModuleSlot) || (slotGroup.slotIndex < displayedSlotCount)
            }
        }
    }

    return state
}


/**
 * The local [ModuleSlotGroupsState].
 */
val LocalModuleSlotGroupsState = compositionLocalOf<ModuleSlotGroupsState> {
    error("LocalModuleSlotGroupsState not provided")
}


/**
 * The information about a module slot group we capture in an undo-redo action, as we can't capture the
 * [ModuleSlotGroup] itself.
 */
private sealed interface SavedModuleSlotGroup {

    class Single(val slotIndex: Int): SavedModuleSlotGroup
    class Multi(val slotIndices: List<Int>): SavedModuleSlotGroup

}


/**
 * Returns a [SavedModuleSlotGroup] for the given [SavedModuleSlotGroup].
 */
private fun ModuleSlotGroup.toSavedSlotGroup() = when (this) {
    is SingleModuleSlot -> SavedModuleSlotGroup.Single(slotIndex)
    is MultiModuleSlot -> SavedModuleSlotGroup.Multi(slotIndices.toList())
}


/**
 * Re-creates a [ModuleSlotGroup] from the given [SavedModuleSlotGroup].
 */
private fun SavedModuleSlotGroup.toModuleSlotGroup(fit: Fit, slotType: ModuleSlotType) = when (this) {
    is SavedModuleSlotGroup.Single -> SingleModuleSlot(fit, slotType, slotIndex)
    is SavedModuleSlotGroup.Multi -> MultiModuleSlot(fit, slotType, slotIndices)
}


/**
 * Returns a [FitEditorUndoRedoAction] that toggles grouping the weapons in the fit.
 */
fun toggleGroupWeaponsAction(slotGroupsState: ModuleSlotGroupsState): FitEditorUndoRedoAction {
    return if (slotGroupsState.groupWeapons) {
        ungroupWeaponsAction(
            groupsByRackIndex = MODULE_RACK_ORDER.indices.map { rackIndex ->
                slotGroupsState[rackIndex].map { slotGroup ->
                    slotGroup.toSavedSlotGroup()
                }
            }
        )
    }
    else {
        GroupWeaponsAction
    }
}


/**
 * Returns a [FitEditorUndoRedoAction] to ungroup weapons.
 */
private fun ungroupWeaponsAction(groupsByRackIndex: List<List<SavedModuleSlotGroup>>): FitEditorUndoRedoAction {
    return object: FitEditorUndoRedoAction {

        context(FitEditorUndoRedoContext)
        override suspend fun perform() {
            moduleSlotGroupsState.groupWeapons = false
        }

        context(FitEditorUndoRedoContext)
        override suspend fun revert() {
            moduleSlotGroupsState.groupWeapons = true
            // Restore the groups
            for ((rackIndex, groups) in groupsByRackIndex.withIndex()) {
                val slotType = MODULE_RACK_ORDER[rackIndex]
                moduleSlotGroupsState[rackIndex] = groups.map { savedSlotGroup ->
                    savedSlotGroup.toModuleSlotGroup(fit, slotType)
                }
            }
        }

    }
}


/**
 * A [FitEditorUndoRedoAction] to group weapons.
 *
 * Since we don't need to restore the module groups here (there's only one "ungrouping" of modules), and there's no
 * other state to capture, this can be just an object.
 */
val GroupWeaponsAction = object: FitEditorUndoRedoAction {

    context(FitEditorUndoRedoContext)
    override suspend fun perform() {
        moduleSlotGroupsState.groupWeapons = true
    }

    context(FitEditorUndoRedoContext)
    override suspend fun revert() {
        moduleSlotGroupsState.groupWeapons = false
    }

}


/**
 * The item price cell.
 */
@Composable
fun GridScope.GridRowScope.PriceCell(itemType: EveItemType, amount: Int = 1) {
    AnimatedVisibility(
        visible = TheorycrafterContext.settings.prices.showInFitEditor
    ) {
        cell(cellIndex = GridCols.PRICE) {
            ItemPrice(itemType, amount)
        }
    }
}


/**
 * A widget showing the given item's price.
 */
@Composable
fun ItemPrice(
    itemType: EveItemType,
    amount: Int = 1,
    textAlign: TextAlign? = null,
    modifier: Modifier = Modifier
) {
    val prices = TheorycrafterContext.eveItemPrices
    Price(
        priceInfo = prices?.priceInfoOf(itemType)?.times(amount)?.takeIf { !it.priceIsUnknown },
        textAlign = textAlign,
        modifier = modifier
    )
}


/**
 * A widget showing the price of an item, possibly colorized.
 */
@Composable
fun Price(
    priceInfo: ItemsPriceInfo?,
    textAlign: TextAlign? = null,
    modifier: Modifier = Modifier
) {
    if (priceInfo != null) {
        SingleLineText(
            text = priceInfo.toDisplayString(),
            textAlign = textAlign,
            modifier = modifier
                .thenIf(priceInfo.priceIsOverride) {
                    tooltip("Displayed price is override")
                },
            color = if (TheorycrafterContext.settings.prices.colorize)
                priceColor(priceInfo.price)
            else
                Color.Unspecified,
        )
    } else {
        Spacer(modifier)
    }
}


/**
 * An empty price cell.
 */
@Composable
fun GridScope.GridRowScope.EmptyPriceCell() {
    AnimatedVisibility(visible = TheorycrafterContext.settings.prices.showInFitEditor) {
        emptyCell(GridCols.PRICE)
    }
}


/**
 * The color of the price.
 */
@Composable
fun priceColor(price: Double): Color {
    val cheap = TheorycrafterTheme.colors.cheap()
    val expensive = TheorycrafterTheme.colors.expensive()

    // 100k is cheapest, 10b is most expensive color
    val fraction = ((log10(price) - 5) / 5.0)
        .toFloat()
        .coerceIn(0f, 1f)

    return lerp(cheap, expensive, fraction)
}


/**
 * The grid displaying the fit.
 */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FitGrid(
    fit: Fit,
    fitHandle: FitHandle?,
    startEditingFitName: () -> Unit,
    selectionModel: SlotSelectionModel,
    moduleSlotGroupsState: ModuleSlotGroupsState,
    showEditTagsDialog: () -> Unit,
    showPackForBattleDialog: () -> Unit,
    showFitOptimizerDialog: () -> Unit,
) {
    val scrollState = rememberScrollState()
    ContentWithScrollbar(state = scrollState) {
        val bringHeaderIntoViewRequester = remember { BringIntoViewRequester() }
        val undoRedoQueue = LocalFitEditorUndoRedoQueue.current
        val fitContextMenuState = remember { DropdownMenuState() }

        ScrollShadow(scrollState, top = true, bottom = true)
        SimpleGrid(
            columnWidths = TheorycrafterTheme.sizes.fitEditorColumnWidths,
            rowSelectionModel = selectionModel,
            defaultCellContentAlignment = ColumnAlignment::get,
            modifier = Modifier
                .verticalScroll(scrollState)
                .testTag(TestTags.FitEditor.KeyEventsReceiver)
                .onOpenContextMenu {
                    fitContextMenuState.status = DropdownMenuState.Status.Open(it)
                }
                .padding(
                    top = TheorycrafterTheme.spacing.xxsmall,
                    // The extra space at the bottom should be enough to clear the info icon
                    bottom = TheorycrafterTheme.spacing.larger + 60.dp
                )
                .moveFitEditorSelectionWithKeys(
                    selectionModel = selectionModel,
                    scrollState = scrollState
                )
                .bringIntoViewRequester(bringHeaderIntoViewRequester)
                .tacticalModeKeyShortcuts(fit, undoRedoQueue)
                .undoRedoKeyShortcuts((undoRedoQueue as? FitEditorUndoRedoQueueImpl)?.delegate)
                .onKeyShortcut(FitEditorKeyShortcuts.ToggleGroupWeapons) {
                    undoRedoQueue.performAndAppend(
                        toggleGroupWeaponsAction(moduleSlotGroupsState)
                    )
                }
        ) {
            var rowCount = 0
            val rowCounts = mutableListOf(rowCount)

            fun updateRowCounts(rowCountInSection: Int) {
                rowCount += rowCountInSection
                rowCounts.add(rowCountInSection)
            }

            TacticalModeSection(
                firstRowIndex = rowCount,
                isFirst = true,
                fit = fit
            ).also {
                updateRowCounts(it)
            }

            SubsystemsSection(
                firstRowIndex = rowCount,
                isFirst = rowCount == 0,
                fit = fit
            ).also {
                updateRowCounts(it)
            }

            val moduleLegalityByRack by moduleLegalityStateByRack(fit = fit)
            for ((rackIndex, slotType) in MODULE_RACK_ORDER.withIndex()) {
                ModuleSlotRack(
                    firstRowIndex = rowCount,
                    isFirst = rowCount == 0,
                    fit = fit,
                    slotType = slotType,
                    slotGroups = moduleSlotGroupsState[rackIndex],
                    illegalityInTournamentReason = moduleLegalityByRack[slotType],
                    rackSlotGroupingActions = rememberRackSlotGroupingActions(
                        fit = fit,
                        rackIndex = rackIndex,
                    )
                ).also {
                    updateRowCounts(it)
                }
            }

            DroneSection(
                firstRowIndex = rowCount,
                fit = fit
            ).also {
                updateRowCounts(it)
            }

            ImplantSection(
                firstRowIndex = rowCount,
                fit = fit
            ).also {
                updateRowCounts(it)
            }

            BoosterSection(
                firstRowIndex = rowCount,
                fit = fit
            ).also {
                updateRowCounts(it)
            }

            CargoholdSection(
                firstRowIndex = rowCount,
                fit = fit
            ).also {
                updateRowCounts(it)
            }

            CommandEffectsSection(
                firstRowIndex = rowCount,
                fit = fit
            ).also {
                updateRowCounts(it)
            }

            HostileEffectsSection(
                firstRowIndex = rowCount,
                fit = fit
            ).also {
                updateRowCounts(it)
            }

            FriendlyEffectsSection(
                firstRowIndex = rowCount,
                fit = fit
            ).also {
                updateRowCounts(it)
            }

            EnvironmentEffectsSection(
                firstRowIndex = rowCount,
                fit = fit
            ).also {
                updateRowCounts(it)
            }

            LaunchedEffect(rowCounts) {
                selectionModel.onSlotLayoutChanged()
            }

            if (fitHandle != null) {
                FitActionsContextMenu(
                    state = fitContextMenuState,
                    fit = fit,
                    fitHandle = fitHandle,
                    startEditingFitName = startEditingFitName,
                    showEditTagsDialog = showEditTagsDialog,
                    showPackForBattleDialog = showPackForBattleDialog,
                    showFitOptimizerDialog = showFitOptimizerDialog,
                )
            }
        }
    }
}


/**
 * An [UndoRedoAction] that edits a fit.
 *
 * The purpose of this class is to expose the fit-editing action separately from the call to
 * [FitsContext.modifyAndSave] so that several such actions can be combined in one call to
 * [FitsContext.modifyAndSave]
 */
abstract class FitEditingAction: FitEditorUndoRedoAction {


    context(FitEditorUndoRedoContext)
    override suspend fun perform() {
        TheorycrafterContext.fits.modifyAndSave {
            performEdit()
        }
    }


    context(FitEditorUndoRedoContext)
    override suspend fun revert() {
        TheorycrafterContext.fits.modifyAndSave {
            revertEdit()
        }
    }


    /**
     * Performs the action in the given scope.
     *
     * This function is only needed to work around the single-receiver limitation of Kotlin.
     */
    context(FitEditorUndoRedoContext)
    fun performEditIn(scope: FittingEngine.ModificationScope) {
        with(scope) {
            performEdit()
        }
    }


    /**
     * Reverts the action in the given scope.
     *
     * This function is only needed to work around the single-receiver limitation of Kotlin.
     */
    context(FitEditorUndoRedoContext)
    fun revertEditIn(scope: FittingEngine.ModificationScope) {
        with(scope) {
            revertEdit()
        }
    }


    /**
     * Performs the fit-editing action.
     */
    context(FitEditorUndoRedoContext)
    abstract fun FittingEngine.ModificationScope.performEdit()


    /**
     * Reverts the fit-editing action.
     */
    context(FitEditorUndoRedoContext)
    abstract fun FittingEngine.ModificationScope.revertEdit()


}


/**
 * Returns a [FitEditingAction] composed of several other actions.
 */
fun compositeFitEditingAction(actions: List<FitEditingAction>): FitEditingAction {
    return object: FitEditingAction() {

        context(FitEditorUndoRedoContext)
        override fun FittingEngine.ModificationScope.performEdit() {
            actions.forEach { it.performEditIn(this) }
        }

        context(FitEditorUndoRedoContext)
        override fun FittingEngine.ModificationScope.revertEdit() {
            actions.asReversed().forEach { it.revertEditIn(this) }
        }
    }
}


/**
 * The composition local [FitEditorUndoRedoQueue] in the fit editor.
 */
val LocalFitEditorUndoRedoQueue = compositionLocalOf<FitEditorUndoRedoQueue> {
    error("Missing local FitEditorUndoRedoQueue")
}


/**
 * The interface for performing revertable actions in the fit editor.
 *
 * We use this, rather than a regular [UndoRedoQueue] directly, in order to wrap the undo-redo queue and add
 * functionality to it:
 * 1. Save & restore the selection when the actions are performed and reverted.
 * 2. Allow actions to throw [FitEditorUndoRedoActionFailed] and show the error in a dialog instead of crashing.
 *
 * We do this not only for the visual effect of having the selection follow the changes, but also because the
 * [SlotSelectionModel] is fragile and can break if the fit is modified while the selection is at an "unnatural"
 * ("natural" would be the row from which the user could/did perform the action) place for the change being performed.
 *
 * Some examples:
 * - In the last slot in a rack add a module with a charge, move the selection to the charge and undo the action.
 *   If we didn't restore the selection, it would've remained at the row corresponding to the charge, which is
 *   probably the header of the next section now.
 * - Fit a multi-slot group of turrets, move the selection to the very end and undo the action. If we didn't restore
 *   the selection, it would now be an invalid index.
 */
interface FitEditorUndoRedoQueue {


    /**
     * Performs the given action and appends it to the queue.
     */
    fun performAndAppend(
        action: FitEditorUndoRedoAction,
        restoreSelection: Boolean = true,
    )


    /**
     * Replaces the last action in the queue, or appends a new one.
     */
    fun performAndMergeOrAppend(merge: (lastAction: FitEditorUndoRedoAction?) -> Pair<FitEditorUndoRedoAction, Boolean>)


    /**
     * Performs a series of steps and appends it to the queue as a single action.
     */
    fun performAndAppendComposite(
        restoreSelection: Boolean = true,
        block: suspend UndoRedoQueue.CompositeUndoRedoScope<FitEditorUndoRedoContext>.() -> Unit
    )


    /**
     * Appends the given action to the queue without performing it.
     */
    fun append(action: FitEditorUndoRedoAction, restoreSelection: Boolean = true)


}


/**
 * The context passed to fit editor undo-redo actions.
 */
@Immutable
data class FitEditorUndoRedoContext(
    val fit: Fit,
    val selectionModel: SlotSelectionModel?,
    val moduleSlotGroupsState: ModuleSlotGroupsState,
    val showError: (String) -> Unit,
)


/**
 * Returns a remembered [FitEditorUndoRedoContext].
 */
@Composable
private fun rememberUndoRedoQueueContext(
    fit: Fit,
    selectionModel: SlotSelectionModel,
    moduleSlotGroupsState: ModuleSlotGroupsState,
    showError: (String) -> Unit,
): FitEditorUndoRedoContext {
    return remember(selectionModel, moduleSlotGroupsState, showError) {
        FitEditorUndoRedoContext(fit, selectionModel, moduleSlotGroupsState, showError)
    }
}


/**
 * An [UndoRedoAction] for the fit editor.
 */
typealias FitEditorUndoRedoAction = UndoRedoAction<FitEditorUndoRedoContext>


/**
 * The implementation of a [FitEditorUndoRedoQueue].
 */
private class FitEditorUndoRedoQueueImpl(
    private val selectedIndex: () -> Int?,  // Be careful not to capture this in an undo-redo action
    val delegate: UndoRedoQueue<FitEditorUndoRedoContext>,
): FitEditorUndoRedoQueue {


    /**
     * Returns the currently selected index. This is safer to call than [selectedIndex] directly to avoid capturing the
     * function in an undo-redo action lambda.
     */
    private fun currentlySelectedIndex(): Int? = selectedIndex()


    /**
     * Returns a [FitEditorUndoRedoAction] that remembers the currently selected index and restores it whenever
     * performed or reverted.
     */
    private fun restoreSelectionAction(): FitEditorUndoRedoAction {
        val selectedIndex = currentlySelectedIndex()

        return object: FitEditorUndoRedoAction {

            context(FitEditorUndoRedoContext)
            override suspend fun perform() {
                selectedIndex?.let { selectionModel?.selectIndex(it) }
            }

            context(FitEditorUndoRedoContext)
            override suspend fun revert() {
                selectedIndex?.let { selectionModel?.selectIndex(it) }
            }
        }
    }


    override fun performAndAppend(
        action: FitEditorUndoRedoAction,
        restoreSelection: Boolean,
    ) {
        performAndAppendCompositeRestoringSelection(restoreSelection) {
            performAndAddStep(action)
        }
    }


    override fun performAndMergeOrAppend(merge: (lastAction: FitEditorUndoRedoAction?) -> Pair<FitEditorUndoRedoAction, Boolean>) {
        runBlocking {
            delegate.performAndMergeOrAppend(merge)
        }
    }


    override fun performAndAppendComposite(
        restoreSelection: Boolean,
        block: suspend UndoRedoQueue.CompositeUndoRedoScope<FitEditorUndoRedoContext>.() -> Unit
    ) {
        performAndAppendCompositeRestoringSelection(restoreSelection, block)
    }


    /**
     * Performs a composite action while correctly restoring the selection.
     */
    private fun performAndAppendCompositeRestoringSelection(
        restoreSelection: Boolean,
        block: suspend UndoRedoQueue.CompositeUndoRedoScope<FitEditorUndoRedoContext>.() -> Unit
    ) {
        runBlocking {
            delegate.performAndAppendComposite {
                if (restoreSelection)
                    performAndAddStep(restoreSelectionAction())

                with(exceptionCatchingCompositeUndoRedoScope(this)) {
                    block()
                }
            }
        }
    }


    override fun append(action: FitEditorUndoRedoAction, restoreSelection: Boolean) {
        delegate.append(
            if (restoreSelection) {
                undoRedoTogether(
                    restoreSelectionAction(),
                    action
                )
            }
            else
                action
        )
    }


    /**
     * Returns an [UndoRedoQueue.CompositeUndoRedoScope] that wraps incoming actions in a `try .. catch` that shows an
     * error when an [FitEditorUndoRedoActionFailed] exception is thrown.
     */
    private fun exceptionCatchingCompositeUndoRedoScope(
        delegateScope: UndoRedoQueue.CompositeUndoRedoScope<FitEditorUndoRedoContext>
    ): UndoRedoQueue.CompositeUndoRedoScope<FitEditorUndoRedoContext> {
        return object: UndoRedoQueue.CompositeUndoRedoScope<FitEditorUndoRedoContext> {
            override suspend fun performAndAddStep(action: FitEditorUndoRedoAction) {
                delegateScope.performAndAddStep(object: FitEditorUndoRedoAction {

                    context(FitEditorUndoRedoContext)
                    override suspend fun perform() {
                        try {
                            action.perform()
                        } catch (e: FitEditorUndoRedoActionFailed) {
                            showError(e.errorMessage)
                        }
                    }

                    context(FitEditorUndoRedoContext)
                    override suspend fun revert() {
                        try {
                            action.revert()
                        } catch (e: FitEditorUndoRedoActionFailed) {
                            showError(e.errorMessage)
                        }
                    }

                })
            }
        }
    }


}


/**
 * An exception that can be thrown by undo-redo actions in the context of [FitEditorUndoRedoQueue] if the action fails.
 * The [message] will be displayed to the user.
 */
private class FitEditorUndoRedoActionFailed(val errorMessage: String): Exception(errorMessage)


/**
 * Moves the slot selection when Up/Down/PgUp/PgDown/Home/End keys are pressed.
 */
@Composable
private fun Modifier.moveFitEditorSelectionWithKeys(
    selectionModel: SlotSelectionModel,
    scrollState: ScrollState
): Modifier = with(FitEditorKeyShortcuts) {
    val coroutineScope = rememberCoroutineScope()

    fun scrollToTopIfAlreadyAtFirstItem(action: () -> Boolean){
        val alreadyAtFirstItem = !action()
        if (alreadyAtFirstItem && !scrollState.isScrollInProgress) {
            coroutineScope.launch {
                scrollState.animateScrollTo(0)
            }
        }
    }

    fun scrollToBottomIfAlreadyAtLastItem(action: () -> Boolean) {
        val alreadyAtLastItem = !action()
        if (alreadyAtLastItem && !scrollState.isScrollInProgress) {
            coroutineScope.launch {
                scrollState.animateScrollTo(scrollState.maxValue)
            }
        }
    }

    this@moveFitEditorSelectionWithKeys
        .onKeyShortcut(SelectPrevRow) {
            scrollToTopIfAlreadyAtFirstItem(selectionModel::selectPrevious)
        }
        .onKeyShortcut(SelectNextRow) {
            scrollToBottomIfAlreadyAtLastItem(selectionModel::selectNext)
        }
        .onKeyShortcut(SelectionPrevSection) {
            scrollToTopIfAlreadyAtFirstItem(selectionModel::selectPreviousSection)
        }
        .onKeyShortcut(SelectionNextSection) {
            scrollToBottomIfAlreadyAtLastItem(selectionModel::selectNextSection)
        }
        .onKeyShortcut(SelectionFirst) {
            scrollToTopIfAlreadyAtFirstItem(selectionModel::selectFirst)
        }
        .onKeyShortcut(SelectionLast) {
            scrollToBottomIfAlreadyAtLastItem(selectionModel::selectLast)
        }
}


/**
 * The composition local [SlotSelectionModel] of the fit editor.
 */
val LocalSlotSelectionModel = compositionLocalOf<SlotSelectionModel>{ error("Missing selection model") }


/**
 * The selection model for the fit editor table.
 */
class SlotSelectionModel(


    /**
     * The displayed fit.
     */
    private val fit: Fit,


    /**
     * The slot groups for each module rack.
     */
    private val moduleSlotGroups: ModuleSlotGroupsState


): SingleItemSelectionModel() {


    /**
     * Defines the selection within a single section.
     */
    private interface Section<S: Any> {


        /**
         * Whether this section is visible.
         */
        fun isVisible(): Boolean


        /**
         * Whether this section has a header.
         */
        fun hasHeader(): Boolean


        /**
         * The number of rows in the section, excluding the header.
         */
        fun rowCount(): Int


        /**
         * Returns the selection object corresponding to the given local row.
         */
        fun localSelectionAtRow(rowIndex: Int): S


        /**
         * Returns the local row corresponding to the given selection object.
         */
        fun rowOfSelection(selection: S): Int


        /**
         * Returns the first selection in the section.
         */
        fun firstLocalSelection(): S? = rowCount().let { rowCount ->
            if (isVisible() && (rowCount > 0))
                localSelectionAtRow(0)
            else
                null
        }


        /**
         * Returns the last selection in the section.
         */
        fun lastLocalSelection(): S? = rowCount().let { rowCount ->
            if (isVisible() && (rowCount > 0))
                localSelectionAtRow(rowCount-1)
            else
                null
        }


        /**
         * Returns the selection following the given one within the section; `null` if it's the last one.
         */
        fun nextLocalSelection(selection: S): S? = rowOfSelection(selection).let { rowIndex ->
            if (rowIndex < rowCount()-1)
                localSelectionAtRow(rowIndex+1)
            else
                null
        }


        /**
         * Returns the selection previous to the given one within the section; `null` if it's the first one.
         */
        fun prevLocalSelection(selection: S): S? = rowOfSelection(selection).let { rowIndex ->
            if (rowIndex > 0)
                localSelectionAtRow(rowIndex-1)
            else
                null
        }


    }


    /**
     * A selection in a module rack.
     */
    private inner class ModuleRackSection(


        /**
         * The index of the rack.
         */
        val rackIndex: Int


    ): Section<ModuleRackSection.Selection> {

        /**
         * The slot type of this section.
         */
        val slotType: ModuleSlotType
            get() = MODULE_RACK_ORDER[rackIndex]


        /**
         * The module slot groups currently in this section.
         */
        private val slotGroupsInSection: List<ModuleSlotGroup>
            get() = moduleSlotGroups[rackIndex]


        override fun isVisible() = slotGroupsInSection.isNotEmpty()


        override fun hasHeader() = true


        override fun rowCount() = slotGroupsInSection.size + slotGroupsInSection.count { it.hasChargeSlot() }


        override fun localSelectionAtRow(rowIndex: Int): Selection {
            var curRowIndex = 0
            var slotGroupIndex = 0
            var isChargeSlot = false
            while (curRowIndex < rowIndex) {
                val slotGroup = slotGroupsInSection[slotGroupIndex]
                if (slotGroup.hasChargeSlot() && !isChargeSlot)
                    isChargeSlot = true
                else {
                    isChargeSlot = false
                    slotGroupIndex++
                }

                curRowIndex++
            }

            return Selection(slotGroupsInSection[slotGroupIndex], isChargeSlot)
        }


        override fun rowOfSelection(selection: Selection): Int {
            var rowIndex = 0
            var slotGroupIndex = 0
            while (slotGroupsInSection[slotGroupIndex] != selection.slotGroup) {
                rowIndex += if (slotGroupsInSection[slotGroupIndex].hasChargeSlot()) 2 else 1
                slotGroupIndex += 1
            }

            if (selection.isCharge)
                rowIndex += 1
            return rowIndex
        }


        override fun firstLocalSelection() = slotGroupsInSection.firstOrNull()?.let { Selection(it, false) }


        override fun lastLocalSelection() = slotGroupsInSection.lastOrNull()?.let { Selection(it, it.hasChargeSlot()) }


        override fun nextLocalSelection(selection: Selection): Selection? {
            if (!selection.isCharge && selection.slotGroup.hasChargeSlot())
                return Selection(selection.slotGroup, isCharge = true)

            val index = slotGroupsInSection.indexOf(selection.slotGroup)
            if (index == slotGroupsInSection.lastIndex)
                return null

            return Selection(slotGroupsInSection[index+1], isCharge = false)
        }


        override fun prevLocalSelection(selection: Selection): Selection? {
            if (selection.isCharge)
                return Selection(selection.slotGroup, isCharge = false)

            val index = slotGroupsInSection.indexOf(selection.slotGroup)
            if (index == 0)
                return null

            val prevSlotGroup = slotGroupsInSection[index-1]
            return Selection(prevSlotGroup, isCharge = prevSlotGroup.hasChargeSlot())
        }


        /**
         * Returns the (global) indices of the module rows, given the (global) index of the first row in the section.
         *
         * Note that the last item will be the index of the first row outside the section.
         */
        fun moduleSlotRowIndices(firstRowIndex: Int): List<Int> {
            return buildList {
                var rowIndex = firstRowIndex
                for (slotGroup in slotGroupsInSection) {
                    add(rowIndex)
                    rowIndex += if (slotGroup.hasChargeSlot()) 2 else 1
                }
                add(rowIndex)
            }
        }


        override fun toString(): String {
            return MODULE_RACK_ORDER[rackIndex].toString()
        }


        /**
         * The selection object in a module rack.
         */
        inner class Selection(


            /**
             * The selected slot group.
             */
            val slotGroup: ModuleSlotGroup,


            /**
             * Whether the selected row is a charge.
             */
            val isCharge: Boolean


        ) {
            override fun toString(): String {
                return "$slotGroup, isCharge: $isCharge"
            }
        }


    }


    /**
     * The base class for sections that have a list of items followed by an "empty" slot where the user can select an
     * item to add.
     */
    private abstract inner class SectionWithEmptySlot : Section<Int> {

        override fun hasHeader() = true

        override fun localSelectionAtRow(rowIndex: Int) = rowIndex

        override fun rowOfSelection(selection: Int) = selection

        override fun rowCount() = itemCount() + 1

        abstract fun itemCount(): Int

    }


    /**
     * The drones section.
     */
    private inner class DroneSection : SectionWithEmptySlot() {

        override fun isVisible() = fit.drones.canFitDrones || fit.drones.all.isNotEmpty()

        override fun itemCount() = fit.drones.all.size

        override fun toString() = "Drones"

    }


    /**
     * The cargohold section.
     */
    private inner class CargoholdSection : SectionWithEmptySlot() {

        override fun isVisible() = fit.cargohold.capacity.total > 0

        override fun itemCount() = fit.cargohold.contents.size

        override fun toString() = "Cargohold"

    }


    /**
     * The implants section.
     */
    private inner class ImplantSection : SectionWithEmptySlot() {

        override fun isVisible() = true

        override fun itemCount() = fit.implants.fitted.size

        override fun toString() = "Implants"

    }


    /**
     * The boosters section.
     */
    private inner class BoosterSection : SectionWithEmptySlot() {

        override fun isVisible() =
            TheorycrafterContext.tournaments.activeRules.areBoostersLegal() || fit.boosters.fitted.isNotEmpty()

        override fun itemCount() = fit.boosters.fitted.size

        override fun toString() = "Boosters"

    }


    /**
     * The tactical mode section.
     */
    private val TacticalModeSection = object : Section<Unit> {

        override fun isVisible() = fit.ship.type.hasTacticalModes

        override fun hasHeader() = true

        override fun rowCount() = 1

        override fun localSelectionAtRow(rowIndex: Int) = Unit

        override fun rowOfSelection(selection: Unit) = 0

        override fun firstLocalSelection() = if (isVisible()) Unit else null

        override fun lastLocalSelection() = if (isVisible()) Unit else null

        override fun nextLocalSelection(selection: Unit) = null

        override fun prevLocalSelection(selection: Unit) = null

        override fun toString() = "Tactical Mode"

    }


    /**
     * The subsystems section.
     */
    private val SubsystemsSection = object : Section<SubsystemType.Kind> {

        private val subsystemByRow = SubsystemType.Kind.entries

        private val rowBySubsystem = subsystemByRow.associateWithIndex()

        override fun isVisible() = fit.ship.type.usesSubsystems

        override fun hasHeader() = true

        override fun rowCount() = SubsystemType.Kind.entries.size

        override fun localSelectionAtRow(rowIndex: Int) = subsystemByRow[rowIndex]

        override fun rowOfSelection(selection: SubsystemType.Kind) = rowBySubsystem[selection]!!

        override fun toString() = "Subsystems"

    }


    /**
     * The command effects section.
     */
    private inner class CommandEffectsSection: SectionWithEmptySlot() {

        override fun isVisible() = true

        override fun itemCount() =
            fit.commandEffects.sumOf {
                1 +
                    it.affectingModules.size +
                    it.affectingModules.count(Module::canLoadCharges)
            }

        override fun toString() = "Command Effects"

    }


    /**
     * The base class for a remote effects section.
     */
    private abstract inner class RemoteEffectsSection: SectionWithEmptySlot() {

        override fun isVisible() = true

        /**
         * Returns the current list of effects.
         */
        protected abstract fun effects(): List<RemoteEffect>

        override fun itemCount() =
            effects().sumOf {
                // The effect by aux fit is the one for standalone module/drone effects,
                // and we don't show a header for it
                (if (it.isByAuxiliaryFit) 0 else 1) +
                        it.affectingModules.size +
                        it.affectingModules.count(Module::canLoadCharges) +
                        it.affectingDrones.size
            }

    }


    /**
     * The hostile effects section.
     */
    private inner class HostileEffectsSection: RemoteEffectsSection() {

        override fun effects() = fit.hostileEffects

        override fun toString() = "Hostile Effects"

    }


    /**
     * The friendly effects section.
     */
    private inner class FriendlyEffectsSection: RemoteEffectsSection() {

        override fun effects() = fit.friendlyEffects

        override fun toString() = "Friendly Effects"

    }


    /**
     * The environment effects section.
     */
    private inner class EnvironmentEffectsSection: SectionWithEmptySlot() {

        override fun isVisible() =
            TheorycrafterContext.tournaments.activeRules.hasEnvironments() || fit.environments.isNotEmpty()

        override fun itemCount() = fit.environments.size

        override fun toString() = "Environments"

    }


    /**
     * The selection in this model.
     */
    private class Selection<S: Any>(


        /**
         * The selected section.
         */
        val section: Section<S>,


        /**
         * The object representing the selection in the section.
         */
        val localSelection: S


    ) {

        override fun toString(): String {
            return "$section: $localSelection"
        }

    }


    /**
     * The sections.
     */
    private val sections = listOf(
        TacticalModeSection,
        SubsystemsSection,
        ModuleRackSection(0),
        ModuleRackSection(1),
        ModuleRackSection(2),
        ModuleRackSection(3),
        DroneSection(),
        ImplantSection(),
        BoosterSection(),
        CargoholdSection(),
        CommandEffectsSection(),
        HostileEffectsSection(),
        FriendlyEffectsSection(),
        EnvironmentEffectsSection()
    )


    /**
     * Returns the local row index corresponding to the selection.
     */
    private fun <S: Any> Selection<S>.localRow() =
        section.rowOfSelection(localSelection)


    /**
     * Returns the selection within the section, at the given local row index.
     */
    private fun <S: Any> Section<S>.selectionAtRow(rowIndex: Int) =
        Selection(this, localSelectionAtRow(rowIndex))


    /**
     * Returns the selection for the first row in the section.
     */
    private fun <S: Any> Section<S>.firstSelection() =
        firstLocalSelection()?.let { Selection(this, it ) }


    /**
     * Returns the selection for the last row in the section.
     */
    private fun <S: Any> Section<S>.lastSelection() =
        lastLocalSelection()?.let { Selection(this, it ) }


    /**
     * Returns the previous selection within the section.
     */
    private fun <S: Any> Selection<S>.prevInSection() =
        section.prevLocalSelection(localSelection)?.let { Selection(section, it) }


    /**
     * Returns the following selection within the section.
     */
    private fun <S: Any> Selection<S>.nextInSection() =
        section.nextLocalSelection(localSelection)?.let { Selection(section, it) }


    /**
     * The current selection.
     */
    private var selection: Selection<*>? = null


    /**
     * Returns a sequence of the sections with the index of first row in each section.
     */
    private fun sectionsWithFirstRowIndex(excludeHeader: Boolean = false) = sequence {
        var rowIndex = 0

        for (section in sections) {
            if (!section.isVisible())
                continue

            val hasHeader = section.hasHeader()
            if (excludeHeader && hasHeader)
                rowIndex++
            yield(IndexedValue(rowIndex, section))

            val rowsSansHeader = section.rowCount()
            rowIndex += if (hasHeader && !excludeHeader) rowsSansHeader + 1 else rowsSansHeader
        }
    }


    /**
     * Returns the [Selection] corresponding to the given row; `null` if the given row is not selectable.
     */
    private fun selectionAtRow(index: Int): Selection<*>? {
        val sectionWithIndex = sectionsWithFirstRowIndex()
            .takeWhile { (firstRowIndex, _) -> index >= firstRowIndex }
            .lastOrNull() ?: return null

        val (firstRowIndex, section) = sectionWithIndex
        val firstRealRowIndex = if (section.hasHeader()) firstRowIndex + 1 else firstRowIndex
        if (index < firstRealRowIndex)
            return null // The header
        return section.selectionAtRow(index - firstRealRowIndex)
    }


    /**
     * Returns the index of the row corresponding to the given [Selection].
     */
    private fun rowOfSelection(selection: Selection<*>): Int {
        var rowIndex = 0

        for (section in sections) {
            if (!section.isVisible())
                continue

            if (section.hasHeader())
                ++rowIndex  // Skip the header

            if (section == selection.section)
                return rowIndex + selection.localRow()

            rowIndex += section.rowCount()
        }

        throw IllegalStateException("Unknown selection type or selection model internal error")
    }


    /**
     * Sets the selection to the given one, with a known row index.
     */
    private fun setSelection(rowIndex: Int, selection: Selection<*>) {
        this.selection = selection
        super.selectIndex(rowIndex)
    }


    /**
     * Sets the selection to the given one.
     */
    private fun setSelection(selection: Selection<*>) {
        setSelection(rowOfSelection(selection), selection)
    }


    override fun selectIndex(index: Int) {
        val selection = selectionAtRow(index) ?: return
        setSelection(index, selection)
    }


    override fun deselectIndex(index: Int) {
        if (index == selectedIndex)
            selection = null

        super.deselectIndex(index)
    }


    override fun clearSelection() {
        selection = null

        super.clearSelection()
    }


    /**
     * Selects the given selection if it's not `null` and its row is different from the currently selected one.
     * Returns whether the selection actually changed.
     */
    private fun selectIfNotNullAndChanged(selection: Selection<*>?): Boolean {
        if (selection == null)
            return false

        val rowIndex = rowOfSelection(selection)
        if (rowIndex == selectedIndex)
            return false

        setSelection(rowIndex, selection)
        return true
    }


    /**
     * Returns the [Selection] for the first selectable row in the grid.
     */
    private fun firstSelection(): Selection<*>?{
        for (section in sections){
            val firstSectionSelection = section.firstSelection()
            if (firstSectionSelection != null)
                return firstSectionSelection
        }

        return null
    }


    /**
     * Returns the [Selection] for the last row in the grid.
     */
    private fun lastSelection(): Selection<*>? {
        for (section in sections.asReversed()){
            val lastSectionSelection = section.lastSelection()
            if (lastSectionSelection != null)
                return lastSectionSelection
        }

        return null
    }


    /**
     * Returns the selection before the given one; `null` if none.
     */
    private fun previousSelection(selection: Selection<*>): Selection<*>? {
        selection.prevInSection()?.let { return it }

        val sectionIndex = sections.indexOf(selection.section)
        for (i in sectionIndex-1 downTo 0){
            val section = sections[i]
            if (!section.isVisible())
                continue
            section.lastSelection()?.let { return it }
        }

        return firstSelection()
    }


    /**
     * Returns the selection following the given one; `null` if none.
     */
    private fun nextSelection(selection: Selection<*>): Selection<*>? {
        selection.nextInSection()?.let { return it }

        val sectionIndex = sections.indexOf(selection.section)
        for (i in sectionIndex+1 .. sections.lastIndex){
            val section = sections[i]
            if (!section.isVisible())
                continue
            section.firstSelection()?.let { return it }
        }

        return lastSelection()
    }


    /**
     * Returns the selection before the current one; `null` if none.
     */
    private fun previousSelection(): Selection<*>? {
        val currentSelection = this.selection ?: return null
        return previousSelection(currentSelection)
    }


    /**
     * Returns the selection following the current one; `null` if none.
     */
    private fun nextSelection(): Selection<*>? {
        val currentSelection = this.selection ?: return null
        return nextSelection(currentSelection)
    }


    /**
     * Returns the selection in the section before the given one; `null` if none.
     */
    private fun previousSectionSelection(): Selection<*>? {
        val selection = this.selection ?: return null

        val sectionIndex = sections.indexOf(selection.section)
        for (i in sectionIndex-1 downTo 0){
            val section = sections[i]
            if (!section.isVisible())
                continue
            val lastSelectionInSection = section.lastSelection()
            if (lastSelectionInSection != null)
                return lastSelectionInSection
        }

        return firstSelection()
    }


    /**
     * Returns the selection in the section following the current one; `null` if none.
     */
    private fun nextSectionSelection(): Selection<*>? {
        val selection = this.selection ?: return null

        val sectionIndex = sections.indexOf(selection.section)
        for (i in sectionIndex+1 .. sections.lastIndex){
            val section = sections[i]
            if (!section.isVisible())
                continue
            val firstSelectionInSection = section.firstSelection()
            if (firstSelectionInSection != null)
                return firstSelectionInSection
        }

        return lastSelection()
    }


    override fun selectPrevious(): Boolean {
        return selectIfNotNullAndChanged(previousSelection())
    }


    override fun selectNext(): Boolean {
        return selectIfNotNullAndChanged(nextSelection())
    }


    override fun selectPreviousPage(itemsInPage: Int): Boolean {
        return selectPreviousSection()
    }


    override fun selectNextPage(itemsInPage: Int): Boolean {
        return selectNextSection()
    }


    override fun selectFirst(): Boolean {
        return selectIfNotNullAndChanged(firstSelection())
    }


    override fun selectLast(): Boolean {
        return selectIfNotNullAndChanged(lastSelection())
    }


    /**
     * Moves selection to the previous section.
     */
    fun selectPreviousSection(): Boolean {
        return selectIfNotNullAndChanged(previousSectionSelection())
    }


    /**
     * Moves selection to the next section.
     */
    fun selectNextSection(): Boolean {
        return selectIfNotNullAndChanged(nextSectionSelection())
    }


    /**
     * Returns the [ModuleRackSection] corresponding to the given slot type.
     */
    private fun moduleRackSection(slotType: ModuleSlotType): ModuleRackSection =
        sections.find { (it as? ModuleRackSection)?.slotType == slotType } as ModuleRackSection


    /**
     * Selects the slot displaying the given [ModuleSlotGroup].
     */
    private fun selectModuleSlot(slotGroup: ModuleSlotGroup){
        val section = moduleRackSection(slotGroup.slotType)
        setSelection(
            Selection(section, section.Selection(slotGroup, isCharge = false))
        )
    }


    /**
     * Selects the next "primary" (i.e. not a charge) row.
     */
    fun selectNextPrimaryRow() {
        var selection = nextSelection() ?: return

        // Skip charge rows
        val localSelection = selection.localSelection
        if ((localSelection is ModuleRackSection.Selection) && localSelection.isCharge)
            selection = nextSelection(selection) ?: return

        setSelection(selection)
    }


    /**
     * Invoked when a new slot group has been assembled.
     */
    fun onGroupAssembled(newGroup: MultiModuleSlot) {
        // We re-select it not only for the visual effect, but also because the group itself is a different instance
        // now, and we need to update the group the selection is referencing,
        selectModuleSlot(newGroup)
    }


    /**
     * Invoked when a slot group has been disbanded.
     */
    fun onGroupDisbanded(disbandedGroup: MultiModuleSlot) {
        val rackIndex = MODULE_RACK_ORDER.indexOf(disbandedGroup.slotType)
        val slotIndexToSelect = disbandedGroup.slotIndices.first()
        val newSelectedGroup =
            moduleSlotGroups[rackIndex].find { it.slotIndices.contains(slotIndexToSelect) }!!
        // We re-select it not only for the visual effect, but also because the group itself is a different instance
        // now, and we need to update the group the selection is referencing,
        selectModuleSlot(newSelectedGroup)
    }


    /**
     * Invoked when a new slot has been added to a slot group.
     */
    @Suppress("UNUSED_PARAMETER")
    fun onSlotAddedToGroup(group: MultiModuleSlot, slotIndex: Int) {
        // We re-select it not only for the visual effect, but also because the group itself is a different instance
        // now, and we need to update the group the selection is referencing,
        selectModuleSlot(group)
    }


    /**
     * Invoked when a slot has been removed from a slot group.
     */
    @Suppress("UNUSED_PARAMETER")
    fun onSlotRemovedFromGroup(group: MultiModuleSlot, slotIndex: Int) {
        // We re-select it not only for the visual effect, but also because the group itself is a different instance
        // now, and we need to update the group the selection is referencing,
        selectModuleSlot(group)
    }


    /**
     * Invoked when the slot layout changes. Note that this is called in addition (and after) the other methods
     * (e.g. [onGroupAssembled]).
     *
     * This is needed because sometimes the layout of the slots (not just module slots) changes without it being related
     * to module groups being assembled or disbanded. Some examples:
     * - When the module in an invalid slot (because the slots of a strategic cruiser changed) is removed, that slot
     *   disappears completely. If the selection was there, it will move to the title of the next section.
     * - When the drone in an invalid slot (because a strategic cruiser changed subsystems and no longer has a drone
     *   bay) is removed, the whole section goes away. The selection will drop into some slot in the following section.
     * - When the active tournament changes, sections may become (in)visible.
     */
    fun onSlotLayoutChanged() {
        val selection = this.selection ?: return
        val section = selection.section

        // The slot group no longer exists
        if (section is ModuleRackSection) {
            val localSelection = selection.localSelection as ModuleRackSection.Selection
            val groupInRack = moduleSlotGroups[section.rackIndex]
            if (localSelection.slotGroup !in groupInRack) {
                // Try selecting the last group in the same rack
                if (groupInRack.isNotEmpty())
                    selectModuleSlot(groupInRack.last())
                else
                    selectPreviousSection() || selectNextSection()
                return
            }
        }

        // A section has been removed
        if (!section.isVisible()) {
            selectPreviousSection() || selectNextSection()
            return
        }

        // Keep the same slot selected
        // This is useful when, for example, adding a booster to the cargo (from the booster slot).
        // The booster slot gets moved down, but its index in the section remains the same.
        setSelection(selection)
    }


    /**
     * Returns the section corresponding to the given module rack, and the index of the first row in it.
     */
    private fun moduleSectionWithFirstRowIndex(slotType: ModuleSlotType): IndexedValue<ModuleRackSection> {
        @Suppress("UNCHECKED_CAST")
        return sectionsWithFirstRowIndex(excludeHeader = true)
            .first { (_, section) ->  // Module racks are always present
                (section as? ModuleRackSection)?.slotType == slotType
            } as IndexedValue<ModuleRackSection>
    }


    /**
     * For each rack, a state whose value is a mapping from row indices to module slot indices for the given rack.
     *
     * The map will contain one extra entry, mapping the size of the rack to the index of the first row after the rack.
     */
    private val rowIndexToModuleSlotIndexByRack = valueByEnum<ModuleSlotType, State<Map<Int, Int>>> { slotType ->
        derivedStateOf {
            val (firstRowIndex, section) = moduleSectionWithFirstRowIndex(slotType)
            val moduleRowIndices = section.moduleSlotRowIndices(firstRowIndex)
            moduleRowIndices.associateWithIndex()
        }
    }


    /**
     * Returns a state whose value is a mapping from row indices to module slot indices for the given rack.
     *
     * The map will contain one extra entry, mapping the size of the rack to the index of the first row after the rack.
     *
     * Note that if the rack contains [MultiModuleSlot]s, the slot indices will not correspond to the actual slots in
     * the fit.
     */
    fun rowIndexToModuleSlotIndexState(slotType: ModuleSlotType): State<Map<Int, Int>> =
        rowIndexToModuleSlotIndexByRack[slotType]


    /**
     * Selects the row corresponding to the given module slot.
     */
    fun selectModuleSlot(slotType: ModuleSlotType, slotIndex: Int) {
        val (firstRowIndex, section) = moduleSectionWithFirstRowIndex(slotType)
        val moduleRowIndices = section.moduleSlotRowIndices(firstRowIndex)
        selectIndex(moduleRowIndices[slotIndex])
    }


    /**
     * Returns a state whose value is the range of rows displaying cargohold items; `null` if there is no cargohold
     * section.
     */
    val cargoholdSlotRowsState: State<IntRange?> = derivedStateOf {
        val (index, section) = sectionsWithFirstRowIndex(excludeHeader = true)
            .firstOrNull { (_, section) -> (section is CargoholdSection) } ?: return@derivedStateOf null
        index until index + section.rowCount()
    }


    /**
     * Selects the row corresponding to the given cargo item slot.
     */
    fun selectCargoSlot(slotIndex: Int) {
        val rows = cargoholdSlotRowsState.value ?: error("No cargo slots exist in this fit")
        selectIndex(rows.first + slotIndex)
    }


}


/**
 * Returns whether the given ship is legal in the given tournament (true if there is no tournament).
 */
fun TournamentRules?.isShipLegal(shipType: ShipType) =
    (this == null) || compositionRules.isShipLegal(shipType)


/**
 * Returns whether the given module is legal on the given ship in the given tournament (true if there is no
 * tournament).
 */
fun TournamentRules?.isModuleLegal(moduleType: ModuleType, shipType: ShipType, isFlagship: Boolean) =
    (this == null) || fittingRules.isModuleLegal(moduleType, shipType, isFlagship)


/**
 * Returns whether the given charge is legal for the given module in the given tournament (true if there is no
 * tournament).
 */
fun TournamentRules?.isChargeLegal(chargeType: ChargeType?, moduleType: ModuleType) =
    (this == null) || fittingRules.isChargeLegal(chargeType, moduleType)


/**
 * Returns whether the given drone is legal in the given tournament (true if there is no tournament).
 */
fun TournamentRules?.isDroneLegal(droneType: DroneType) = (this == null) || fittingRules.isDroneLegal(droneType)


/**
 * Returns whether the given implant is legal in the given tournament (true if there is no tournament).
 */
fun TournamentRules?.isImplantLegal(implantType: ImplantType) =
    (this == null) || fittingRules.isImplantLegal(implantType)


/**
 * Returns whether boosters can be used in the given tournament context (true if there is no tournament).
 */
fun TournamentRules?.areBoostersLegal() = (this == null) || fittingRules.areBoostersLegal


/**
 * Returns whether the given booster is legal in the given tournament (true if there is no tournament).
 */
fun TournamentRules?.isBoosterLegal(boosterType: BoosterType) =
    (this == null) || fittingRules.isBoosterLegal(boosterType)


/**
 * Returns whether environments can be used in the given tournament context (true if there is no tournament).
 */
fun TournamentRules?.hasEnvironments() = (this == null) || this.hasEnvironments


/**
 * Returns whether the given cargo item is legal in the given tournament (true if there is no tournament).
 */
fun TournamentRules?.isCargoItemLegal(cargoItem: EveItemType) =
    (this == null) || fittingRules.isCargoItemLegal(cargoItem)
