// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway

import com.intellij.ide.DataManager
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DefaultActionGroup
import com.intellij.openapi.actionSystem.Separator
import com.intellij.openapi.actionSystem.ToggleAction
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.util.EventDispatcher
import software.aws.toolkits.core.ClientConnectionSettings
import software.aws.toolkits.core.utils.error
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.jetbrains.core.AwsResourceCache
import software.aws.toolkits.jetbrains.services.caws.CawsResources
import software.aws.toolkits.jetbrains.settings.CawsSpaceTracker
import software.aws.toolkits.jetbrains.ui.ActionPopupComboLabel
import software.aws.toolkits.jetbrains.ui.ActionPopupComboLogic
import software.aws.toolkits.resources.message
import java.util.concurrent.CompletionStage
import javax.swing.JComponent
import javax.swing.event.ChangeEvent
import javax.swing.event.ChangeListener

private const val SPACE_MAX_LENGTH = 16

internal class CawsSpacePopupComboLabel(connectionSettings: ClientConnectionSettings<*>, refreshCallback: () -> Unit) : ActionPopupComboLabel(
    object : ActionPopupComboLogic {
        private val listeners by lazy {
            EventDispatcher.create(ChangeListener::class.java)
        }

        private var popupActions = loadActions()

        private fun loadActions(): CompletionStage<List<AnAction>> = AwsResourceCache.getInstance()
            .getResource(CawsResources.ALL_SPACES, connectionSettings)
            .handle { spaces, throwable ->
                val spaceTracker = CawsSpaceTracker.getInstance()

                if (throwable != null) {
                    spaceTracker.changeSpaceName(null)
                    val message = message("caws.spaces.error_loading")
                    getLogger<CawsSpacePopupComboLabel>().error(throwable) { message }
                    return@handle listOf(
                        object : DumbAwareAction(message) {
                            override fun update(e: AnActionEvent) {
                                e.presentation.isEnabled = false
                            }

                            override fun actionPerformed(p0: AnActionEvent) {}
                        }
                    )
                }

                val lastSpace = spaceTracker.lastSpaceName()
                // clear it out if selected space cannot be found
                if (spaces.isNullOrEmpty() || spaceTracker.lastSpaceName() !in spaces) {
                    spaceTracker.changeSpaceName(null)
                }

                // pick a space if nothing selected
                if (spaceTracker.lastSpaceName() == null) {
                    spaceTracker.changeSpaceName(spaces.firstOrNull())
                }

                // refresh and let next iteration handle actions
                if (lastSpace != spaceTracker.lastSpaceName()) {
                    refreshCallback()
                    return@handle listOf()
                }

                spaces.map {
                    object : ToggleAction(it), DumbAware {
                        override fun isSelected(e: AnActionEvent) = CawsSpaceTracker.getInstance().lastSpaceName() == it

                        override fun setSelected(e: AnActionEvent, selected: Boolean) {
                            if (selected) {
                                CawsSpaceTracker.getInstance().changeSpaceName(it)
                                listeners.multicaster.stateChanged(ChangeEvent(e))
                                refreshCallback()
                            }
                        }
                    }
                }
            }.thenApply {
                it + listOf<AnAction>(
                    Separator(),
                    object : DumbAwareAction(message("caws.spaces.refresh")) {
                        override fun actionPerformed(e: AnActionEvent) {
                            AwsResourceCache.getInstance().clear(CawsResources.ALL_SPACES, connectionSettings)
                            popupActions = loadActions()
                            refreshCallback()
                        }
                    }
                )
            }.also {
                it.thenRun {
                    listeners.multicaster.stateChanged(ChangeEvent(this))
                }
            }

        override fun addChangeListener(changeListener: ChangeListener) {
            listeners.addListener(changeListener)
        }

        override fun showPopup(sourceComponent: JComponent) {
            val actions = DefaultActionGroup(popupActions.toCompletableFuture().get())

            JBPopupFactory.getInstance().createActionGroupPopup(
                null,
                actions,
                DataManager.getInstance().getDataContext(sourceComponent),
                JBPopupFactory.ActionSelectionAid.SPEEDSEARCH,
                true
            ).showUnderneathOf(sourceComponent)
        }

        override fun displayValue(): String = if (!popupActions.toCompletableFuture().isDone) {
            message("loading_resource.loading")
        } else {
            CawsSpaceTracker.getInstance().lastSpaceName()?.let {
                if (it.length > SPACE_MAX_LENGTH) {
                    it.substring(0, SPACE_MAX_LENGTH) + "\u2026"
                } else {
                    it
                }
            } ?: message("caws.no_spaces")
        }

        override fun tooltip(): String? = if (popupActions.toCompletableFuture().isDone) {
            CawsSpaceTracker.getInstance().lastSpaceName()?.let {
                if (it.length > SPACE_MAX_LENGTH) {
                    it
                } else {
                    null
                }
            }
        } else {
            null
        }
    }
)


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway

import com.intellij.openapi.Disposable
import com.intellij.openapi.ui.ComboBox
import com.intellij.openapi.util.BuildNumber
import com.intellij.openapi.util.Disposer
import com.intellij.ui.ColoredListCellRenderer
import com.intellij.ui.SeparatorWithText
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.dsl.builder.BottomGap
import com.intellij.ui.dsl.builder.COLUMNS_LARGE
import com.intellij.ui.dsl.builder.Cell
import com.intellij.ui.dsl.builder.Panel
import com.intellij.ui.dsl.builder.Row
import com.intellij.ui.dsl.builder.bindItem
import com.intellij.ui.dsl.builder.bindSelected
import com.intellij.ui.dsl.builder.columns
import com.intellij.ui.dsl.builder.toNullableProperty
import com.jetbrains.gateway.ssh.IntelliJPlatformProduct
import software.amazon.awssdk.services.codecatalyst.model.InstanceType
import software.aws.toolkits.jetbrains.services.caws.CawsEndpoints
import software.aws.toolkits.jetbrains.services.caws.EnvironmentParameters
import software.aws.toolkits.jetbrains.services.caws.InactivityTimeout
import software.aws.toolkits.jetbrains.services.caws.isSupportedInFreeTier
import software.aws.toolkits.jetbrains.ui.AsyncComboBox
import software.aws.toolkits.resources.message
import java.awt.Component
import javax.swing.DefaultComboBoxModel
import javax.swing.JList
import kotlin.reflect.KMutableProperty0

sealed interface GatewayProductComboBoxItem {
    val item: GatewayProduct?
}

internal sealed interface SeparatorItem : GatewayProductComboBoxItem {
    val text: String

    override val item: GatewayProduct?
        get() = null
}

@JvmInline internal value class GatewayProductItem(override val item: GatewayProduct) : GatewayProductComboBoxItem

@JvmInline internal value class GenericTextItem(override val text: String) : SeparatorItem

@JvmInline internal value class ProductSeparatorItem(val productCode: String) : SeparatorItem {
    override val text: String
        get() = IntelliJPlatformProduct.fromProductCode(productCode)?.ideName ?: productCode
}

fun Row.ideVersionComboBox(disposable: Disposable, product: KMutableProperty0<GatewayProduct?>): Cell<ComboBox<GatewayProductComboBoxItem>> {
    val model = object : DefaultComboBoxModel<GatewayProductComboBoxItem>() {
        override fun setSelectedItem(anObject: Any?) {
            if (anObject !is GatewayProductItem) {
                return
            }
            super.setSelectedItem(anObject)
        }
    }

    val comboBox = AsyncComboBox(
        model,
        customRenderer = object : ColoredListCellRenderer<GatewayProductComboBoxItem>() {
            override fun getListCellRendererComponent(
                list: JList<out GatewayProductComboBoxItem>?,
                value: GatewayProductComboBoxItem?,
                index: Int,
                selected: Boolean,
                hasFocus: Boolean
            ): Component {
                if (value is SeparatorItem) {
                    return SeparatorWithText().apply {
                        caption = value.text
                        setCaptionCentered(value is GenericTextItem)
                    }
                }

                return super.getListCellRendererComponent(list, value, index, selected, hasFocus)
            }

            override fun customizeCellRenderer(
                list: JList<out GatewayProductComboBoxItem>,
                value: GatewayProductComboBoxItem?,
                index: Int,
                selected: Boolean,
                hasFocus: Boolean
            ) {
                val item = (value as? GatewayProductItem)?.item ?: return
                append(item.fullName)
                icon = IntelliJPlatformProduct.fromProductCode(item.productCode)?.icon
            }
        }
    )
    Disposer.register(disposable, comboBox)

    // hack so property bindings don't null out the provided initial value
    val initialProduct = product.get()?.also {
        model.addElement(GatewayProductItem(it))
        comboBox.selectedItem = it
    }

    comboBox.proposeModelUpdate { _ ->
        gatewayManifest().let { manifest ->
            val sortedProducts = manifest.images
                .sortedWith(
                    compareByDescending<GatewayProduct> { it.tags.firstOrNull() }
                        .thenBy { if (it.productCode == "IU") "" else it.productCode }
                        .thenByDescending {
                            BuildNumber.fromStringOrNull(it.buildNumber)
                        }
                )

            val currentProduct = if (initialProduct != null) {
                // try to match against something on the manifest, otherwise fall back to service response
                manifest.images.firstOrNull {
                    (it.productCode == initialProduct.productCode && it.buildNumber == initialProduct.buildNumber) ||
                        it.ecrImage == initialProduct.ecrImage
                } ?: initialProduct
            } else {
                sortedProducts.firstOrNull { it.productCode == "IU" } ?: sortedProducts.firstOrNull()
            }

            currentProduct?.let {
                if (!sortedProducts.contains(it)) {
                    model.addElement(GatewayProductItem(it))
                }
            }

            sortedProducts.forEachIndexed { index, gatewayProduct ->

                if (index == 0 || sortedProducts[index - 1].productCode != gatewayProduct.productCode) {
                    model.addElement(ProductSeparatorItem(gatewayProduct.productCode))
                }
                model.addElement(GatewayProductItem(gatewayProduct))
            }

            model.selectedItem = currentProduct?.let { GatewayProductItem(it) }
        }
    }

    return cell(comboBox)
        .bindItem({ product.get()?.let { GatewayProductItem(it) } }, { product.set(it?.item) })
        .columns(COLUMNS_LARGE)
        .errorOnApply(message("caws.ide_version_validation_text")) { it.selectedItem == null }
}

fun Panel.cawsEnvironmentSize(
    environmentParameters: EnvironmentParameters,
    instanceType: KMutableProperty0<InstanceType>,
    freeSubscriptionTier: Boolean
) {
    row {
        browserLink(message("caws.environment.view_pricing"), CawsEndpoints.ConsoleFactory.pricing())
    }

    row {
        label(message("caws.workspace.instance_size"))
    }

    buttonsGroup {
        val instanceRow = { label: String, type: InstanceType, text: String ->
            twoColumnsRow(
                {
                    radioButton(label)
                        .applyToComponent {
                            isSelected = instanceType.get() == type
                            isEnabled = if (freeSubscriptionTier) type.isSupportedInFreeTier() else true
                        }
                        .bindSelected(
                            { instanceType.get() == type },
                            { if (it) instanceType.set(type) }
                        )
                },
                {
                    comment(text, maxLineLength = -1)
                }
            )
        }

        lateinit var lastRow: Row
        environmentParameters.instanceTypes.forEach { (type, parameters) ->
            // TODO: Velox to provide API for this info
            val typeLabel = type.toString().substringAfter("dev.standard1.").capitalize()
            lastRow = instanceRow(typeLabel, type, "${parameters.vCpus} vCPUs, ${parameters.ram.value} GB RAM")
        }
        lastRow.bottomGap(BottomGap.MEDIUM)
    }
}

fun Row.cawsEnvironmentTimeout(timeout: KMutableProperty0<InactivityTimeout>) {
    label(message("caws.workspace.details.inactivity_timeout"))
    val values = InactivityTimeout.DEFAULT_VALUES.let {
        val currentTimeout = timeout.get()

        return@let if (!it.contains(currentTimeout)) {
            (it + currentTimeout).sortedArray()
        } else {
            it
        }
    }
    val model = DefaultComboBoxModel(values)

    comboBox(
        model,
        object : ColoredListCellRenderer<InactivityTimeout>() {
            override fun customizeCellRenderer(
                list: JList<out InactivityTimeout>,
                value: InactivityTimeout?,
                index: Int,
                selected: Boolean,
                hasFocus: Boolean
            ) {
                if (value == null) return

                append(value.displayText())

                if (value == InactivityTimeout.DEFAULT_TIMEOUT) {
                    append(" ")
                    append(message("general.default"), SimpleTextAttributes.GRAYED_ATTRIBUTES)
                }
            }
        }
    ).bindItem(timeout.toNullableProperty())
}


// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway

import com.intellij.ide.AppLifecycleListener
import com.intellij.notification.NotificationGroupManager
import com.intellij.notification.NotificationType
import com.intellij.openapi.application.ApplicationInfo
import com.intellij.openapi.application.ApplicationNamesInfo
import software.aws.toolkits.resources.message

class GatewayDeprecationNotice : AppLifecycleListener {
    override fun welcomeScreenDisplayed() {
        super.welcomeScreenDisplayed()
        if (ApplicationInfo.getInstance().build.baselineVersion >= MIN_VERSION) {
            return
        }
        val title = message("aws.toolkit_deprecation.title")
        val message = message(
            "aws.toolkit_deprecation.message.gateway",
            ApplicationNamesInfo.getInstance().fullProductName,
            ApplicationInfo.getInstance().fullVersion,
            MIN_VERSION_HUMAN
        )

        val notificationGroup = NotificationGroupManager.getInstance().getNotificationGroup("aws.toolkit_deprecation")
        notificationGroup.createNotification(title, message, NotificationType.WARNING).notify(null)
    }

    companion object {
        const val MIN_VERSION = 231
        const val MIN_VERSION_HUMAN = "2023.1"
    }
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway

import com.fasterxml.jackson.annotation.JsonProperty

fun gatewayManifest(): GatewayManifest = GatewayManifest(LATEST_ECR)

data class GatewayManifest(
    @JsonProperty("images")
    private val latestImages: List<GatewayProduct>
) {
    val images = latestImages
}

data class GatewayProduct(
    @JsonProperty("dockerImageName")
    val ecrImage: String,
    @JsonProperty("buildNumber")
    val buildNumber: String,
    @JsonProperty("productCode")
    val productCode: String,
    @JsonProperty("marketingName")
    val fullName: String,
    @JsonProperty("tags")
    val tags: List<String>
) {
    val apiType = when (productCode) {
        "IU" -> "IntelliJ"
        "PY" -> "PyCharm"
        "GO" -> "GoLand"
        else -> error("Unknown API type for product code $productCode")
    }

    companion object {
        private fun getProductFullName(productCode: String) = when (productCode) {
            "IU" -> "IntelliJ IDEA Ultimate"
            "PY" -> "PyCharm Professional Edition"
            "GO" -> "GoLand Latest Stable"
            else -> productCode
        }
        fun fromWorkspace(ws: Workspace) = ws.ide?.let { ide ->
            val (productCode, buildNumber) = ws.build ?: throw IllegalStateException("Could not parse runtime for build: ${ide.runtime()}")
            GatewayProduct(
                ecrImage = ide.runtime(),
                buildNumber = buildNumber,
                productCode = productCode,
                fullName = "${getProductFullName(productCode)}-Outdated version",
                tags = emptyList()
            )
        }
    }
}

val LATEST_ECR = listOf(
    latestJbProduct("IU", "IntelliJ IDEA Ultimate Latest Stable"),
    latestJbProduct("PY", "PyCharm Professional Edition Latest Stable"),
    latestJbProduct("GO", "GoLand Latest Stable"),
)

private fun latestJbProduct(productCode: String, fullName: String) = GatewayProduct(
    ecrImage = "public.ecr.aws/jetbrains/${productCode.lowercase()}:release",
    buildNumber = "999.999",
    productCode = productCode,
    fullName = fullName,
    tags = listOf("Release")
)


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway

import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.util.ExecUtil
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.warn

data class RemoteRef(
    val obj: String,
    val ref: String
)

object GitWrappers {
    private val LOG = getLogger<GitWrappers>()
    private const val TIMEOUT_MS = 10000
    const val USER_EMAIL_KEY = "user.email"
    const val USER_NAME_KEY = "user.name"

    fun getRemotes(repo: String): List<RemoteRef>? {
        val output = ExecUtil.execAndGetOutput(
            GeneralCommandLine("git")
                // final "--" to prevent users from passing additional flags through the dialog
                .withParameters("ls-remote", "--heads", "--tags", "--")
                .withParameters(repo),
            TIMEOUT_MS
        )

        if (output.exitCode != 0) {
            LOG.warn { output.stderr }
            return null
        }

        return output.stdoutLines.map {
            val (obj, ref) = it.split("\t", limit = 2)

            RemoteRef(obj, ref.substringAfter("refs/").substringAfter("heads/"))
        }
    }

    fun getConfig(): Map<String, String>? {
        val output = ExecUtil.execAndGetOutput(
            GeneralCommandLine("git")
                .withParameters("config", "--list"),
            TIMEOUT_MS
        )

        if (output.exitCode != 0) {
            LOG.warn { output.stderr }
            return null
        }

        return output.stdoutLines.associate {
            val (k, v) = it.split("=", limit = 2)

            k to v
        }
    }
}


// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway

import software.amazon.awssdk.services.codecatalyst.model.ListSourceRepositoriesItem

data class SourceRepository(
    val name: String
)

fun ListSourceRepositoriesItem.toSourceRepository() = SourceRepository(
    name = this.name()
)

data class BranchSummary(
    val name: String,
    val headCommitId: String
)


// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway

sealed class ToolkitInstallSettings {
    class UseArbitraryLocalPath(val localToolkitPath: String, val s3StagingBucket: String) : ToolkitInstallSettings()
    object UseSelf : ToolkitInstallSettings()
    object UseMarketPlace : ToolkitInstallSettings()
    object None : ToolkitInstallSettings()
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway

import com.jetbrains.gateway.ssh.IntelliJPlatformProduct
import software.amazon.awssdk.services.codecatalyst.model.DevEnvironmentStatus
import software.amazon.awssdk.services.codecatalyst.model.DevEnvironmentSummary
import software.amazon.awssdk.services.codecatalyst.model.GetDevEnvironmentResponse
import software.amazon.awssdk.services.codecatalyst.model.Ide
import software.amazon.awssdk.services.codecatalyst.model.InstanceType
import software.aws.toolkits.jetbrains.services.caws.CawsProject
import software.aws.toolkits.jetbrains.services.caws.InactivityTimeout
import java.time.Instant

data class WorkspaceIdentifier(val project: CawsProject, val id: String) {
    val friendlyString by lazy { "${project.space}/${project.project}/$id" }
}

internal const val JB_ECR_DOMAIN = "jetbrains"
data class Workspace(
    val alias: String?,
    val identifier: WorkspaceIdentifier,
    val status: DevEnvironmentStatus,
    val statusReason: String?,
    val instanceType: InstanceType,
    val inactivityTimeout: InactivityTimeout,
    val repo: String?,
    val branch: String?,
    val lastUpdated: Instant,
    val labels: List<String>,
    val ides: List<Ide>
) {
    val ide = ides.firstOrNull { it.runtime()?.contains(JB_ECR_DOMAIN) ?: false }
    val isCompatible = ide != null
    val build = ide?.let {
        // TODO: probably need to model the @sha:[...] case better
        val (productCode, buildNumber) = ide.runtime().substringAfter("$JB_ECR_DOMAIN/").split(':', limit = 2)

        productCode.substringBefore("@sha").toUpperCase() to buildNumber
    }

    val platformProduct = build?.let { IntelliJPlatformProduct.fromProductCode(it.first) }
}

fun GetDevEnvironmentResponse.toWorkspace(identifier: WorkspaceIdentifier) = Workspace(
    alias = this.alias(),
    identifier = identifier,
    status = this.status(),
    statusReason = this.statusReason(),
    instanceType = this.instanceType(),
    inactivityTimeout = InactivityTimeout.fromMinutes(this.inactivityTimeoutMinutes()),
    repo = this.repositories().firstOrNull()?.repositoryName(),
    branch = this.repositories().firstOrNull()?.branchName(),
    lastUpdated = this.lastUpdatedTime(),
    labels = emptyList(),
    ides = this.ides()
)

fun DevEnvironmentSummary.toWorkspace(identifier: WorkspaceIdentifier) = Workspace(
    alias = this.alias(),
    identifier = identifier,
    status = status(),
    statusReason = statusReason(),
    instanceType = this.instanceType(),
    inactivityTimeout = InactivityTimeout.fromMinutes(this.inactivityTimeoutMinutes()),
    repo = this.repositories().firstOrNull()?.repositoryName(),
    branch = this.repositories().firstOrNull()?.branchName(),
    lastUpdated = this.lastUpdatedTime(),
    labels = emptyList(),
    ides = this.ides(),
)

fun DevEnvironmentStatus.inProgress() = when (this) {
    DevEnvironmentStatus.STARTING, DevEnvironmentStatus.STOPPING, DevEnvironmentStatus.PENDING, DevEnvironmentStatus.DELETING -> true
    else -> false
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway

import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.openapi.progress.ProgressManager
import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient
import software.amazon.awssdk.services.codecatalyst.model.DevEnvironmentStatus
import software.amazon.awssdk.services.codecatalyst.model.GetDevEnvironmentResponse
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.info
import software.aws.toolkits.resources.message
import kotlin.system.measureTimeMillis

class WorkspaceActions(private val spaceName: String, private val projectName: String, private val envId: String, private val client: CodeCatalystClient) {
    fun getEnvironmentDetails() = client.getDevEnvironment {
        it.spaceName(spaceName)
        it.projectName(projectName)
        it.id(envId)
    }

    fun startEnvironment() {
        client.startDevEnvironment {
            it.spaceName(spaceName)
            it.projectName(projectName)
            it.id(envId)
        }
    }

    fun stopEnvironment() {
        client.stopDevEnvironment {
            it.spaceName(spaceName)
            it.projectName(projectName)
            it.id(envId)
        }
    }

    fun waitForConfigurableState() {
//        when (val status = getEnvironmentDetails().status()) {
//            WorkspaceStatus.PENDING, WorkspaceStatus.STARTING -> {
//                waitForTaskReady()
//                stopEnvironment()
//                waitForTaskStopped()
//            }
//            WorkspaceStatus.RUNNING -> {
//                stopEnvironment()
//                waitForTaskStopped()
//            }
//            WorkspaceStatus.STOPPED -> {
//            }
//            WorkspaceStatus.STOPPING -> {
//                waitForTaskStopped()
//            }
//            WorkspaceStatus.DELETING, WorkspaceStatus.DELETED -> throw IllegalStateException("Environment is deleted, unable to configure")
//            else -> throw IllegalStateException("Unknown state $status")
//        }
    }

    fun waitForTaskReady(indicator: ProgressIndicator) {
        val time = measureTimeMillis {
            val timeout = timeout().iterator()
            var response: GetDevEnvironmentResponse
            do {
                ProgressManager.checkCanceled()
                Thread.sleep(timeout.next())
                response = getEnvironmentDetails()
                indicator.text = message("caws.environment.status", response.status().name)
                LOG.info { "${response.id()} has status ${response.status()}" }
            } while (response.status() == DevEnvironmentStatus.STARTING || response.status() == DevEnvironmentStatus.PENDING)

            if (response.status() != DevEnvironmentStatus.RUNNING) {
                // TODO Localize and validate it gets shown to user correctly
                throw IllegalStateException("Environment did not start: ${response.status()}")
            }
        }

        LOG.info { "waitForTaskReady took ${time}ms" }
    }

    fun waitForTaskStopped(indicator: ProgressIndicator) {
        val time = measureTimeMillis {
            val timeout = timeout().iterator()
            var response: GetDevEnvironmentResponse
            do {
                ProgressManager.checkCanceled()
                Thread.sleep(timeout.next())
                response = getEnvironmentDetails()
                indicator.text = message("caws.environment.status", response.status().name)
            } while (response.status() == DevEnvironmentStatus.STOPPING)

            if (response.status() != DevEnvironmentStatus.STOPPED) {
                // TODO Localize and validate it gets shown to user correctly
                throw IllegalStateException("Environment did not stop: ${response.status()}")
            }
        }

        LOG.info { "waitForTaskStopped took ${time}ms" }
    }

    // returns [5000, 1000, 1000, 1000, ...]
    private fun timeout() = generateSequence<Long>(5000) { 1000 }

    companion object {
        private val LOG = getLogger<WorkspaceActions>()
    }
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection

import com.intellij.execution.OutputListener
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.process.ProcessEvent
import com.intellij.execution.process.ProcessHandler
import com.intellij.execution.process.ProcessOutput
import com.intellij.execution.process.ProcessOutputTypes
import com.intellij.execution.util.ExecUtil
import com.intellij.openapi.util.Key
import software.aws.toolkits.core.region.AwsRegion
import software.aws.toolkits.core.utils.debug
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.info
import software.aws.toolkits.core.utils.warn
import software.aws.toolkits.jetbrains.gateway.GitWrappers
import java.nio.file.Path
import java.time.Duration

abstract class AbstractSsmCommandExecutor(private val region: AwsRegion, protected val ssmTarget: String) {
    abstract fun startSsh(): StartSessionResponse

    /**
     * @param argv the command to be executed
     */
    abstract fun startSsm(exe: String, vararg args: String): StartSessionResponse

    fun portForward(localPort: Int, remotePort: Int): ProcessHandler = executeLongLivedSshCommandLine {
        it.localPortForward(localPort, remotePort)
    }

    fun executeLongLivedSshCommandLine(builder: (SshCommandLine) -> Unit): ProcessHandler {
        val sshCommand = newSshCommand().also {
            builder.invoke(it)
        }
        val id = sshCommand.hashCode()
        val loggingAdapter = object : OutputListener() {
            override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) {
                if (outputType == ProcessOutputTypes.STDOUT) {
                    LOG.debug { "$id  Output StdOut: ${event.text.trim()}" }
                } else {
                    LOG.info { "$id  Output StdErr:  ${event.text.trim()}" }
                }
            }

            override fun processTerminated(event: ProcessEvent) {
                LOG.debug { "$id  Exit Code: ${event.exitCode}" }
            }
        }
        val processHandler = sshCommand.executeInBackground(loggingAdapter)

        return processHandler
    }

    fun buildSshCommand(builder: (SshCommandLine) -> Unit): GeneralCommandLine =
        newSshCommand().also {
            builder.invoke(it)
        }.constructCommandLine()

    fun executeSshCommand(builder: (SshCommandLine) -> Unit): ProcessOutput =
        newSshCommand().also {
            builder.invoke(it)
        }.executeAndGetOutput()

    fun executeCommandNonInteractive(exe: String, vararg args: String, timeout: Duration? = null) = startSsm(exe, *args).let {
        SsmCommandExecutorOutput(
            it.sessionId,
            ExecUtil.execAndGetOutput(SsmCommandLineFactory(ssmTarget, it, region).rawCommand(), timeout?.toMillis()?.toInt() ?: -1)
        )
    }

    fun remoteDirectoryExistsUnsafe(path: String): Boolean = remoteDirectoryExists(path, null) ?: false

    fun remoteDirectoryExists(path: String, timeout: Duration?): Boolean? =
        executeCommandNonInteractive("sh", "-c", "test -d '$path' && echo true || echo false", timeout = timeout).let {
            val result = it.resultFromStdOut()
            when (result) {
                StdOutResult.TIMEOUT -> {
                    LOG.warn {
                        """
                            |remoteDirectoryExists on $path timed out. (session ${it.sessionId})"
                            |available stdout: ${it.fullStdout}
                            |available stderr: ${it.stderr}
                        """.trimMargin()
                    }

                    null
                }

                StdOutResult.SUCCESS -> true
                StdOutResult.FAILED -> false
                StdOutResult.UNKNOWN -> {
                    LOG.warn { "remoteDirectoryExists received unexpected output on stdout: ${it.stdout.trim()}" }
                    null
                }
            }
        }

    fun buildGitCloneCommand(remoteScriptPath: String, gitSettings: GitSettings.CloneGitSettings): GeneralCommandLine {
        val repoUri = gitSettings.repo
        val branch = gitSettings.branch

        val gitHost = buildString {
            append(repoUri.host)
            if (repoUri.port != -1) {
                append(":${repoUri.port}")
            }
        }

        val isSsh = repoUri.scheme == "ssh"
        val gitConfig = GitWrappers.getConfig()

        val command = buildString {
            if (gitConfig != null) {
                val gitConfigArguments = buildString {
                    val name = gitConfig[GitWrappers.USER_NAME_KEY]
                    val email = gitConfig[GitWrappers.USER_EMAIL_KEY]

                    if (name != null) {
                        append(" -n '$name'")
                    }

                    if (email != null) {
                        append(" -e '$email'")
                    }
                }

                if (gitConfigArguments.isNotBlank()) {
                    append("$remoteScriptPath/git-config-user-email.sh $gitConfigArguments &&")
                }
            }

            if (isSsh) {
                append("$remoteScriptPath/keyscan-git-host.sh $gitHost &&")
            }
            append("$remoteScriptPath/git-clone.sh $repoUri $branch")
        }

        return newSshCommand()
            .also { cmd ->
                if (isSsh) {
                    cmd.addSshOption("-A")
                    val agent = SshAgentService.agentInstance()
                    if (agent is SocketBasedSshAgent) {
                        cmd.withEnvironment(mapOf(SSH_AGENT_VAR to agent.socket))
                    }
                }
            }
            // force tty-allocation since providing a command turns it off
            .addSshOption("-t")
            .addToRemoteCommand(command)
            .constructCommandLine()
    }

    fun buildScpCommand(remotePath: String, recursive: Boolean, vararg paths: Path): GeneralCommandLine =
        SsmCommandLineFactory(ssmTarget, startSsh(), region).scpCommand(remotePath, recursive)
            .addLocalPaths(*paths).constructCommandLine()

    private fun newSshCommand() = SsmCommandLineFactory(ssmTarget, startSsh(), region).sshCommand()

    fun proxyCommand() = SsmCommandLineFactory(ssmTarget, startSsh(), region).generateProxyCommand()

    private companion object {
        private val LOG = getLogger<AbstractSsmCommandExecutor>()
    }
}

data class SsmCommandExecutorOutput(internal val sessionId: String, private val delegate: ProcessOutput) {
    val timeout: Boolean
        get() = delegate.isTimeout

    val exitCode by delegate::exitCode
    val fullStdout by delegate::stdout
    val stderr by delegate::stderr
    val stdout by lazy {
        fullStdout
            .substringAfter(SSM_START_STRING.format(sessionId))
            .substringBeforeLast(SSM_STOP_STRING.format(sessionId))
            .replace("\r\n", "\n")
            .trim()
    }

    companion object {
        // https://github.com/aws/session-manager-plugin/blob/57d7ac0605c007efed7019455279a6f77f79ab90/src/sessionmanagerplugin/session/session.go#L214
        private const val SSM_START_STRING = "Starting session with SessionId: %s"

        // https://github.com/aws/session-manager-plugin/blob/0788d4cf2f1a34dd84bfa3b8ddea62058ab1d329/src/datachannel/streaming.go#L786
        private const val SSM_STOP_STRING = "Exiting session with sessionId: %s"
    }
}

enum class StdOutResult {
    TIMEOUT,
    SUCCESS,
    FAILED,
    UNKNOWN
}

fun SsmCommandExecutorOutput.resultFromStdOut(): StdOutResult {
    if (timeout) {
        return StdOutResult.TIMEOUT
    } else {
        val output = stdout.trim()

        return if (output == "true") {
            StdOutResult.SUCCESS
        } else if (output == "false") {
            StdOutResult.FAILED
        } else {
            StdOutResult.UNKNOWN
        }
    }
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection

import com.intellij.execution.configurations.GeneralCommandLine
import java.net.URI
import java.net.URISyntaxException

const val SSH_AGENT_VAR = "SSH_AUTH_SOCK"

sealed class GitSettings {
    object NoRepo : GitSettings()

    data class CloneGitSettings(val repo: URI, val branch: String) : GitRepoSettings() {
        constructor(repo: String, branch: String) : this(normalizeRepoUrl(repo), branch)
        override val repoName = extractRepoName(repo)
    }

    data class CawsOwnedRepoSettings(override val repoName: String) : GitRepoSettings()

    sealed class GitRepoSettings : GitSettings() {
        abstract val repoName: String
    }
}

fun normalizeRepoUrl(repoUrl: String): URI {
    // https://git-scm.com/docs/git-clone#_git_urls
    val baseUri = try {
        URI(repoUrl)
    } catch (e: URISyntaxException) {
        // couldn't parse as-is, so tack one on to make parser happy
        normalizeSchemelessUri(repoUrl)
    }

    return if (baseUri.scheme == "file" || baseUri.parseServerAuthority().authority != null) {
        baseUri
    } else {
        // no scheme implies ssh. inject this to make our lives much easier
        normalizeSchemelessUri(repoUrl)
    }.normalize().parseServerAuthority()
}

fun extractRepoName(repoUrl: URI): String {
    // honestly probably just take the component after the last slash like [com.intellij.dvcs.repo.ClonePathProvider]
    // but we need to do some parsing anyways to figure out the authority and scheme

    val path = if (isNotOnlyGitPath(repoUrl.path)) {
        repoUrl.path
    } else {
        guessPathFromUri(repoUrl)
    }

    return path
        .trimEnd('/')
        .removeSuffix(".git")
        .trim('/')
        .substringAfterLast('/')
}

fun buildAgentPrimeCommand(repoUri: URI): GeneralCommandLine? {
    val commandLine = GeneralCommandLine("ssh")
        .withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.CONSOLE)
        .withParameters("-o", "AddKeysToAgent=yes", "-T")

    if (repoUri.scheme != "ssh") {
        return null
    }

    val agent = SshAgentService.agentInstance()
    if (agent is SocketBasedSshAgent) {
        commandLine.withEnvironment(SSH_AGENT_VAR, agent.socket)
    }

    val host = buildString {
        repoUri.userInfo?.let { append("$it@") }
        append(repoUri.host)
    }
    commandLine.addParameter(host)

    if (repoUri.port != -1) {
        commandLine.addParameters("-p", repoUri.port.toString())
    }

    return commandLine
}

private fun isNotOnlyGitPath(s: String?): Boolean =
    !s.isNullOrBlank() && s != "/" && s != "/.git" && s != "/.git/"

private fun guessPathFromUri(uri: URI): String =
    when {
        isNotOnlyGitPath(uri.host) -> uri.host
        else -> throw RuntimeException("could not guess a name from $uri")
    }

private fun normalizeSchemelessUri(repoUrl: String): URI {
    // convert SCP-like syntax to protocol-like
    // [user@]host.xz:path/to/repo.git/
    val authority = repoUrl.substringBefore(':', repoUrl)
    val path = repoUrl.substringAfter(':', "")

    return URI("ssh://$authority/$path")
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection

import com.intellij.execution.process.ProcessHandler
import com.intellij.openapi.application.ApplicationManager
import com.intellij.remoteDev.hostStatus.UnattendedHostConstants
import com.intellij.remoteDev.hostStatus.UnattendedHostStatus
import com.intellij.util.net.NetUtils
import com.intellij.util.text.nullize
import com.jetbrains.rd.util.lifetime.Lifetime
import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient
import software.amazon.awssdk.services.codecatalyst.model.CodeCatalystException
import software.amazon.awssdk.services.codecatalyst.model.RepositoryInput
import software.aws.toolkits.core.utils.debug
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.tryOrNull
import software.aws.toolkits.core.utils.warn
import software.aws.toolkits.jetbrains.AwsToolkit
import software.aws.toolkits.jetbrains.core.credentials.sono.lazilyGetUserId
import software.aws.toolkits.jetbrains.gateway.BranchCloneType
import software.aws.toolkits.jetbrains.gateway.CawsSettings
import software.aws.toolkits.jetbrains.gateway.CawsWizardCloneType
import software.aws.toolkits.jetbrains.gateway.Workspace
import software.aws.toolkits.jetbrains.gateway.WorkspaceIdentifier
import software.aws.toolkits.jetbrains.gateway.toWorkspace
import software.aws.toolkits.telemetry.CodecatalystTelemetry
import java.net.URI
import java.time.Duration
import software.aws.toolkits.telemetry.Result as TelemetryResult

private const val PROJECT_PATH = "/projects"
const val IDE_BACKEND_DIR = "/aws/mde/ide-runtimes/jetbrains/runtime/"
val GET_IDE_BACKEND_VERSION_COMMAND = "cat $IDE_BACKEND_DIR/build.txt"
private const val REMOTE_SERVER_CMD = "$IDE_BACKEND_DIR/bin/remote-dev-server.sh"

class IdeBackendActions(
    private val remoteScriptPath: String,
    projectName: String?,
    private val remoteCommandExecutor: AbstractSsmCommandExecutor
) {
    // use projectPath if it exists, otherwise fallback to the default root
    private val projectPath by lazy {
        projectName ?: return@lazy PROJECT_PATH

        val path = "$PROJECT_PATH/$projectName".trimEnd('/')
        if (remoteCommandExecutor.remoteDirectoryExistsUnsafe(path)) {
            path
        } else {
            PROJECT_PATH
        }
    }

    fun startBackend(): ProcessHandler = remoteCommandExecutor.executeLongLivedSshCommandLine {
        val cmd = buildString {
            val token = if (ApplicationManager.getApplication().isUnitTestMode) {
                System.getenv("CWM_HOST_STATUS_OVER_HTTP_TOKEN")
            } else if (AwsToolkit.isDeveloperMode()) {
                System.getProperty("user.name")
            } else {
                null
            }

            token?.let { append("CWM_HOST_STATUS_OVER_HTTP_TOKEN=$it ") }

            append("$remoteScriptPath/start-ide.sh $REMOTE_SERVER_CMD $projectPath")
        }

        it.addToRemoteCommand(cmd)
    }

    fun remoteScriptsExist() = remoteCommandExecutor.remoteDirectoryExistsUnsafe(remoteScriptPath)

    fun getStatus(timeout: Duration): IdeBackendStatus {
        val time: Long
        val start = System.currentTimeMillis()
        val output = try {
            remoteCommandExecutor.executeCommandNonInteractive(REMOTE_SERVER_CMD, "status", projectPath, timeout = timeout)
        } catch (e: CodeCatalystException) {
            e.message?.let {
                if (it.contains("not found") || it.contains("not running")) {
                    // TODO: if we get this enough times we should cancel
                    LOG.warn { "Dev Environment is in a terminal state: $it" }
                    return IdeBackendStatus.HostNotAlive
                }
            }

            CodecatalystTelemetry.devEnvironmentWorkflowStatistic(
                project = null,
                userId = lazilyGetUserId(),
                result = TelemetryResult.Failed,
                duration = System.currentTimeMillis() - start.toDouble(),
                codecatalystDevEnvironmentWorkflowStep = "getStatus",
                codecatalystDevEnvironmentWorkflowError = e.javaClass.simpleName
            )

            throw e
        } finally {
            time = System.currentTimeMillis() - start
        }

        LOG.debug { "IDE backend status: $output" }

        if (output.exitCode != 0) {
            // ECS Exec doesn't actually support exit codes yet https://github.com/aws/amazon-ecs-agent/issues/2846
            LOG.warn { "Command executor got non-zero exit code: ${output.exitCode}" }
            CodecatalystTelemetry.devEnvironmentWorkflowStatistic(
                project = null,
                userId = lazilyGetUserId(),
                result = TelemetryResult.Failed,
                duration = time.toDouble(),
                codecatalystDevEnvironmentWorkflowStep = "getStatus",
                codecatalystDevEnvironmentWorkflowError = "timeout"
            )

            return IdeBackendStatus.HostNotAlive
        }

        CodecatalystTelemetry.devEnvironmentWorkflowStatistic(
            project = null,
            userId = lazilyGetUserId(),
            result = TelemetryResult.Succeeded,
            duration = time.toDouble(),
            codecatalystDevEnvironmentWorkflowStep = "getStatus"
        )

        val statusPrefix = UnattendedHostConstants.STATUS_PREFIX
        val statusStart = output.stdout.lastIndexOf(statusPrefix)

        if (statusStart < 0) {
            if (output.stdout.contains("IDE has not been initialized yet")) {
                return IdeBackendStatus.HostAlive(null)
            }
            return IdeBackendStatus.HostNotAlive
        }

        val status = tryOrNull {
            UnattendedHostStatus.fromJson(
                output.stdout.subSequence(statusStart + statusPrefix.length, output.stdout.lastIndexOf('}') + 1).toString()
            )
            // we get a JSON parse error if the output looks like this:
            // STATUS:
            // {
            //  "appPid": 737,
            //  "appVersion": "IU-223.6646.115",
            //  "runtimeVersion": "17.0.4.1b646.8",
            //  "unattendedMode": false,
            //  "backendUnresponsive": false,
            //  "modalDialogIsOpened": false,
            //  "idePath": "/aws/mde/ide-runtimes/jetbrains/runtime"
            // }
            // ###FailureReason###
            // java.lang.IllegalStateException: Failed to initialize project: /projects/repo2
            // Exception in thread "AWT-EventQueue-0" ###FailureReasonEnd###
            //  <rest of stacktrace>...
            // 	Suppressed: kotlinx.coroutines.DiagnosticCoroutineContextException: [no parent and no name, ModalityState.NON_MODAL, StandaloneCoroutine{Cancelling}@6dfed258, EDT]
            //                                                                                ^ error due to finding this }
            // rather than be more robust, just treat it as a failure and let the backend restart auto-retry
        } ?: return IdeBackendStatus.HostAlive(null)

        val projectIdx = status.projects?.indexOfFirst { it.projectPath == projectPath }
        if (projectIdx == null || projectIdx < 0) {
            return IdeBackendStatus.HostAlive(status)
        }

        return IdeBackendStatus.BackendRunning(status, projectIdx)
    }

    fun getGatewayConnectLink(status: IdeBackendStatus): Pair<Long, URI>? {
        val backendStatus = (status as? IdeBackendStatus.BackendRunning) ?: return null
        // TODO: should we do what JetBrains does instead?
        // ps aux | egrep ${status.idePath} + '.*com.intellij.idea.Main [c]wmHostNoLobby' + $projectPath
        val pid = backendStatus.hostStatus.appPid
        val joinLink = backendStatus.projectStatus.joinLink

        return pid to URI(joinLink)
    }

    fun getGatewayConnectLink(timeout: Duration): Pair<Long, URI>? = getGatewayConnectLink(getStatus(timeout))

    fun forwardBackendToLocalhost(connectLink: URI, lifetime: Lifetime): URI {
        val localPort = NetUtils.findAvailableSocketPort()

        val processHandler = remoteCommandExecutor.portForward(localPort, connectLink.port)
        lifetime.onTermination {
            processHandler.destroyProcess()
        }

        return URI(
            connectLink.scheme,
            connectLink.userInfo,
            "localhost",
            localPort,
            connectLink.path,
            connectLink.query,
            connectLink.fragment
        )
    }

    companion object {
        private val LOG = getLogger<IdeBackendActions>()

        fun createWorkspace(cawsClient: CodeCatalystClient, settings: CawsSettings): Workspace {
            val productType = settings.productType ?: throw IllegalStateException("IDE runtime was not provided")
            val project = settings.project ?: throw IllegalStateException("Project was not provided")

            val workspace = cawsClient.createDevEnvironment {
                it.spaceName(project.space)
                it.projectName(project.project)

                if (settings.cloneType == CawsWizardCloneType.CAWS) {
                    it.repositories(
                        RepositoryInput.builder()
                            .repositoryName(settings.linkedRepoName)
                            .apply {
                                when (settings.branchCloneType) {
                                    BranchCloneType.NEW_FROM_EXISTING -> branchName(settings.createBranchName)
                                    BranchCloneType.EXISTING -> branchName(settings.linkedRepoBranch?.name)
                                }
                            }
                            .build()
                    )
                } else {
                    // IMO the API should just let this be null instead of forcing an empty list
                    it.repositories(emptyList())
                }
                it.instanceType(settings.instanceType)
                it.persistentStorage({ storage ->
                    storage.sizeInGiB(settings.persistentStorage)
                })
                it.inactivityTimeoutMinutes(settings.inactivityTimeout.asMinutes())
                it.alias(settings.alias.nullize(true))
                it.ides({ ide ->
                    ide.name(productType.apiType)
                    ide.runtime(productType.ecrImage)
                })
            }

            val metadata = cawsClient.getDevEnvironment {
                it.spaceName(project.space)
                it.projectName(project.project)
                it.id(workspace.id())
            }

            return metadata.toWorkspace(WorkspaceIdentifier(project, workspace.id()))
        }
    }
}


// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection

import com.intellij.remoteDev.hostStatus.UnattendedHostStatus

sealed class IdeBackendStatus {
    class BackendRunning(val hostStatus: UnattendedHostStatus, private val projectIdx: Int) : IdeBackendStatus() {
        val projectStatus
            get() = hostStatus.projects?.get(projectIdx) ?: throw RuntimeException("Project status index is invalid")
    }
    class HostAlive(val status: UnattendedHostStatus?) : IdeBackendStatus()
    object HostNotAlive : IdeBackendStatus()
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection

import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.configurations.ParamsGroup
import com.intellij.openapi.util.SystemInfo
import org.jetbrains.annotations.TestOnly
import java.nio.file.Path

open class ScpCommandLine(
    remoteHost: String,
    remotePath: String,
    recursive: Boolean = false,
    port: Int? = null
) {
    private val sshOptions = ParamsGroup(SSH_OPTIONS)
    private val localPaths = ParamsGroup(LOCAL_PATHS)
    private val additionalEnvironment = mutableMapOf<String, String>()

    // required because this needs to be last
    private val remoteTarget = ParamsGroup(REMOTE_TARGET)
    protected val additionalOptions = ParamsGroup(ADDITIONAL_OPTIONS)

    init {
        if (recursive) {
            addSshOption("-r")
        }

        // TODO: requires openssh7.6+, but al2 only ships 7.4 for fips. probably need to manually run ssh-keyscan
        // ideally should be accept-new
        addSshOption("-o", "StrictHostKeyChecking=no")
        addSshOption("-v")

        port?.let {
            // unlike SSH, port is capitalized
            addSshOption("-P", it.toString())
        }

        remoteTarget.addParameter("$remoteHost:$remotePath")
    }

    fun addSshOption(vararg option: String): ScpCommandLine = apply {
        sshOptions.addParameters(*option)
    }

    fun addLocalPaths(vararg path: String): ScpCommandLine = apply {
        localPaths.addParameters(*path)
    }

    fun addLocalPaths(vararg path: Path): ScpCommandLine = apply {
        addLocalPaths(*path.map { it.toAbsolutePath().toString() }.toTypedArray())
    }

    fun withEnvironment(map: Map<String, String>) = apply {
        additionalEnvironment.putAll(map)
    }

    @TestOnly
    fun knownHostsLocation(location: Path) = apply {
        addSshOption("-o", "UserKnownHostsFile=${location.toAbsolutePath()}")
    }

    fun constructCommandLine(): GeneralCommandLine {
        val commandLine = GeneralCommandLine(resolveExecutable())
            .withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.CONSOLE)

        commandLine.parametersList.apply {
            addParamsGroup(sshOptions)
            addParamsGroup(additionalOptions)
            addParamsGroup(localPaths)
            // needs to be last
            addParamsGroup(remoteTarget)
        }

        commandLine.withEnvironment(additionalEnvironment)

        return commandLine
    }

    private fun resolveExecutable(): String {
        if (!SystemInfo.isWindows) {
            return "scp"
        }

        // TODO: make sure "Get-WindowsCapability -Online | ? Name -like 'OpenSSH.Client*'" returns "Installed"
        return "${System.getenv("SystemRoot")}\\System32\\OpenSSH\\scp.exe"
    }

    companion object {
        private const val SSH_OPTIONS = "sshOptions"
        private const val LOCAL_PATHS = "localPaths"
        private const val REMOTE_TARGET = "remoteTarget"
        private const val ADDITIONAL_OPTIONS = "sshAdditionalOptions"
    }
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection

sealed class SshAgent
sealed class SocketBasedSshAgent(
    open val socket: String
) : SshAgent()

object WindowsServiceSshAgent : SshAgent()
data class ExistingSshAgent(override val socket: String) : SocketBasedSshAgent(socket)
data class ProcessBasedSshAgent(
    val pid: Int,
    override val socket: String
) : SocketBasedSshAgent(socket) {

    companion object {
        private val socketRegex = "$SSH_AGENT_VAR=(.*?);".toRegex()
        private val pidRegex = "pid (\\d*?);".toRegex()

        fun fromStdout(stdout: String): ProcessBasedSshAgent =
            ProcessBasedSshAgent(
                pid = pidRegex.find(stdout)?.destructured?.component1()?.toIntOrNull()
                    ?: throw RuntimeException("could not get extract pid from ssh-agent output"),
                socket = socketRegex.find(stdout)?.destructured?.component1()
                    ?: throw RuntimeException("could not get extract socket path from ssh-agent output")
            )
    }
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection

import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.process.OSProcessUtil
import com.intellij.execution.util.ExecUtil
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.service
import com.intellij.openapi.util.ClearableLazyValue
import com.intellij.openapi.util.SystemInfo

class SshAgentService : Disposable {
    private val agentInstance = ClearableLazyValue.createAtomic { startSshAgentIfRequired() }

    fun agent(): SshAgent = agentInstance.value

    override fun dispose() {
        if (agentInstance.isCached) {
            val agent = agentInstance.value
            agentInstance.drop()
            if (agent is ProcessBasedSshAgent) {
                OSProcessUtil.killProcess(agent.pid)
            }
        }
    }

    companion object {
        fun agentInstance() = service<SshAgentService>().agent()

        // visible for tests
        internal fun startSshAgentIfRequired(existingAgent: String? = System.getenv(SSH_AGENT_VAR)): SshAgent {
            if (existingAgent != null) {
                return ExistingSshAgent(existingAgent)
            }

            if (SystemInfo.isWindows) {
                // TODO: what about WSL2?
                // TODO: probably need to guard against old windows versions
                // if windows is new enough, this will always work
                ExecUtil.execAndGetOutput(
                    GeneralCommandLine("powershell.exe")
                        .withParameters(
                            "-Command",
                            """
                        if ((get-service -name ssh-agent).StartType -eq 'Disabled') { set-service -name ssh-agent -startuptype manual }; if (${'$'}LASTEXITCODE -eq 0) { start-service -name ssh-agent }
                            """.trimIndent()
                        )
                )

                // windows ssh will automatically pick up the correct service
                // TODO: if windows ssh, force GIT_SSH env to windows
                return WindowsServiceSshAgent
            }

            val agentOutput =
                ExecUtil.execAndGetOutput(
                    GeneralCommandLine("ssh-agent")
                        // force bourne sh-style envvar output
                        .withParameters("-s")
                ).stdout

            return ProcessBasedSshAgent.fromStdout(agentOutput)
        }
    }
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection

import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.configurations.ParamsGroup
import com.intellij.execution.process.KillableProcessHandler
import com.intellij.execution.process.ProcessHandler
import com.intellij.execution.process.ProcessListener
import com.intellij.execution.process.ProcessOutput
import com.intellij.execution.util.ExecUtil
import com.intellij.openapi.util.SystemInfo
import com.intellij.util.io.BaseOutputReader
import org.jetbrains.annotations.TestOnly
import java.nio.file.Path

open class SshCommandLine(private val target: String, port: Int? = null) {
    private val sshOptions = ParamsGroup(SSH_OPTIONS)
    private val remoteCommand = ParamsGroup(REMOTE_COMMAND)
    private val additionalEnvironment = mutableMapOf<String, String>()

    init {
        // TODO: requires openssh7.6+, but al2 only ships 7.4 for fips. probably need to manually run ssh-keyscan
        // ideally should be accept-new
        addSshOption("-o", "StrictHostKeyChecking=no")
        addSshOption("-v")
        port?.let {
            addSshOption("-p", it.toString())
        }
    }

    fun addSshOption(vararg option: String): SshCommandLine = apply {
        sshOptions.addParameters(*option)
    }

    fun addToRemoteCommand(vararg cmd: String): SshCommandLine = apply {
        remoteCommand.addParameters(*cmd)
    }

    fun localPortForward(localPort: Int, remotePort: Int, noShell: Boolean = true): SshCommandLine =
        localPortForward(localPort, "localhost", remotePort, noShell)

    fun localPortForward(localPort: Int, destination: String, remotePort: Int, noShell: Boolean): SshCommandLine = apply {
        if (noShell) {
            addSshOption("-N")
        }
        // local forwarding
        addSshOption("-L")
        addSshOption("$localPort:$destination:$remotePort")
    }

    fun withEnvironment(map: Map<String, String>) = apply {
        additionalEnvironment.putAll(map)
    }

    @TestOnly
    fun knownHostsLocation(location: Path) = apply {
        addSshOption("-o", "UserKnownHostsFile=${location.toAbsolutePath()}")
    }

    fun constructCommandLine(): GeneralCommandLine {
        val commandLine = GeneralCommandLine(resolveSshExecutable())
            .withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.CONSOLE)

        commandLine.parametersList.apply {
            add(target)
            addParamsGroup(sshOptions)
            addParamsGroup(remoteCommand)
        }

        commandLine.withEnvironment(additionalEnvironment)

        return commandLine
    }

    fun executeInBackground(listener: ProcessListener? = null): ProcessHandler {
        val processHandler = object : KillableProcessHandler(
            constructCommandLine()
                .also {
                    // background process; don't connect standard input
                    it.addParameters("-n")
                }
        ) {
            override fun readerOptions(): BaseOutputReader.Options = BaseOutputReader.Options.forMostlySilentProcess()
        }

        listener?.let { processHandler.addProcessListener(it) }

        processHandler.startNotify()

        return processHandler
    }

    fun executeAndGetOutput(): ProcessOutput = ExecUtil.execAndGetOutput(constructCommandLine())

    private fun resolveSshExecutable(): String {
        if (!SystemInfo.isWindows) {
            return "ssh"
        }

        // TODO: make sure "Get-WindowsCapability -Online | ? Name -like 'OpenSSH.Client*'" returns "Installed"
        return "${System.getenv("SystemRoot")}\\System32\\OpenSSH\\ssh.exe"
    }

    companion object {
        private const val SSH_OPTIONS = "sshOptions"
        private const val REMOTE_COMMAND = "sshCommand"
        private const val ADDITIONAL_OPTIONS = "sshAdditionalOptions"
    }
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection

import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.openapi.util.SystemInfo
import software.aws.toolkits.core.region.AwsRegion
import software.aws.toolkits.jetbrains.AwsToolkit
import software.aws.toolkits.jetbrains.core.tools.ToolManager
import software.aws.toolkits.jetbrains.services.ssm.SsmPlugin

// Modeling fields of [software.amazon.awssdk.services.ssm.model.StartSessionResponse] without pulling in the entire SDK service package
data class StartSessionResponse(
    val sessionId: String,
    val streamUrl: String,
    val tokenValue: String
) {
    override fun toString() = "StartSessionResponse(sessionId='$sessionId', streamUrl='***', tokenValue='***')"
}

class SsmCommandLineFactory(
    private val ssmTarget: String,
    private val sessionParameters: StartSessionResponse,
    private val region: AwsRegion,
    private val overrideSsmPlugin: String? = null,
    private val overrideWindowsWrapper: String? = null
) {
    fun sshCommand(): SshCommandLine {
        val command = SshCommandLine(ssmTarget)
        val proxyCommand = generateProxyCommand()
        command.addSshOption("-o", "ProxyCommand=${proxyCommand.commandString}")
        command.addSshOption("-o", "ServerAliveInterval=60")
        command.withEnvironment(proxyCommand.environment)

        return command
    }

    fun scpCommand(remotePath: String, recursive: Boolean = false): ScpCommandLine {
        val command = ScpCommandLine(ssmTarget, remotePath, recursive)
        val proxyCommand = generateProxyCommand()
        command.addSshOption("-o", "ProxyCommand=${proxyCommand.commandString}")
        command.withEnvironment(proxyCommand.environment)

        return command
    }

    fun rawCommand(): GeneralCommandLine = generateProxyCommand().let {
        GeneralCommandLine(it.exePath)
            .withEnvironment(it.environment)
            .apply {
                if (it.args != null) {
                    withParameters(it.args)
                }
            }
    }

    inner class ProxyCommand(
        val exePath: String,
        val ssmPayload: String? = null,
        val environment: Map<String, String> = emptyMap()
    ) {
        val commandString by lazy {
            buildString {
                append(exePath)
                if (ssmPayload != null) {
                    append(""" '$ssmPayload' ${region.id} StartSession""")
                }
            }
        }

        val args = ssmPayload?.let { listOf(it, region.id, "StartSession") }
    }

    fun generateProxyCommand(): ProxyCommand {
        val ssmPluginJson = """
            {
            "streamUrl":"${sessionParameters.streamUrl}",
            "tokenValue":"${sessionParameters.tokenValue}",
            "sessionId":"${sessionParameters.sessionId}"
            }
            """.replace("\\s".toRegex(), "") // Remove any whitespace to avoid issues on Windows

        val ssmPath = overrideSsmPlugin
            ?: ToolManager.getInstance().getOrInstallTool(SsmPlugin).path.toAbsolutePath().toString()

        return if (SystemInfo.isWindows) {
            ProxyCommand(
                exePath = overrideWindowsWrapper
                    ?: AwsToolkit.pluginPath().resolve("gateway-resources").resolve("caws-proxy-command.bat").toAbsolutePath().toString(),
                environment = mapOf(
                    "sessionManagerExe" to ssmPath,
                    "sessionManagerJson" to '"' + ssmPluginJson.replace("\"", "\\\"") + '"',
                    "region" to region.id
                )
            )
        } else {
            ProxyCommand(
                exePath = ssmPath,
                ssmPayload = ssmPluginJson
            )
        }
    }
}


// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection

import com.intellij.openapi.application.ApplicationManager
import com.jetbrains.gateway.api.GatewayConnectionHandle
import com.jetbrains.gateway.thinClientLink.ThinClientHandle
import java.lang.ref.WeakReference

class ThinClientTrackerService {
    private val handles = mutableMapOf<String, Pair<WeakReference<GatewayConnectionHandle>, WeakReference<ThinClientHandle>>>()

    fun associate(envId: String, connector: () -> Pair<GatewayConnectionHandle, ThinClientHandle>): ThinClientHandle =
        synchronized(handles) {
            connector().let { (gateway, thinClient) ->
                handles[envId] = WeakReference(gateway) to WeakReference(thinClient)
                return@let thinClient
            }
        }

    fun terminateIfRunning(envId: String) = synchronized(handles) {
        handles[envId]?.also {
            val (gateway, thinClient) = it
            gateway.get()?.terminate()
            thinClient.get()?.close()
        }
    }

    fun closeThinClient(envId: String) = synchronized(handles) {
        handles[envId]?.second?.get()?.close()
    }

    companion object {
        fun getInstance() = ApplicationManager.getApplication().getService(ThinClientTrackerService::class.java)
    }
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection.caws

import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient
import software.amazon.awssdk.services.codecatalyst.model.DevEnvironmentSessionType
import software.amazon.awssdk.services.codecatalyst.model.StartDevEnvironmentSessionRequest
import software.aws.toolkits.core.region.AwsRegion
import software.aws.toolkits.jetbrains.gateway.connection.AbstractSsmCommandExecutor
import software.aws.toolkits.jetbrains.gateway.connection.StartSessionResponse

class CawsCommandExecutor(
    private val cawsClient: CodeCatalystClient,
    ssmTarget: String,
    private val spaceName: String,
    private val projectName: String
) : AbstractSsmCommandExecutor(REGION, ssmTarget) {
    override fun startSsh(): StartSessionResponse =
        startSession {
            it.sessionConfiguration { session ->
                session.sessionType(DevEnvironmentSessionType.SSH)
            }
        }

    override fun startSsm(exe: String, vararg args: String): StartSessionResponse =
        startSession {
            it.sessionConfiguration { session ->
                session.sessionType(DevEnvironmentSessionType.SSM)
                session.executeCommandSessionConfiguration { command ->
                    command.command(exe)
                    command.arguments(*args)
                }
            }
        }

    private fun startSession(mutator: (StartDevEnvironmentSessionRequest.Builder) -> Unit): StartSessionResponse {
        val session = cawsClient.startDevEnvironmentSession {
            it.spaceName(spaceName)
            it.projectName(projectName)
            it.id(ssmTarget)

            mutator(it)
        }

        return StartSessionResponse(
            sessionId = session.id(),
            streamUrl = session.accessDetails().streamUrl(),
            tokenValue = session.accessDetails().tokenValue()
        )
    }

    companion object {
        // TODO: devWorkspace APIs are only in us-west-2 at the moment
        private val REGION = AwsRegion("us-west-2", "us-west-2", "aws")
    }
}


// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection.caws

import com.intellij.ssh.PromiscuousSshHostKeyVerifier
import com.intellij.ssh.config.SshConnectionConfig
import com.intellij.ssh.config.SshConnectionConfigService
import com.intellij.ssh.config.SshProxyConfig
import software.aws.toolkits.jetbrains.core.awsClient
import software.aws.toolkits.jetbrains.core.credentials.sono.SonoCredentialManager

class CawsSshConnectionConfigModifier : SshConnectionConfigService.Modifier {
    override fun modify(initialHost: String, connectionConfig: SshConnectionConfig): SshConnectionConfig {
        if (!initialHost.startsWith(HOST_PREFIX)) {
            return connectionConfig
        }

        val (space, project, envId) = initialHost.substringAfter(HOST_PREFIX).split('/')
        val executor = CawsCommandExecutor(
            SonoCredentialManager.getInstance(null).getSettingsAndPromptAuth().awsClient(),
            ssmTarget = envId,
            spaceName = space,
            projectName = project
        )

        return connectionConfig.copy(
            proxyConfig = SshProxyConfig.Command(executor.proxyCommand().commandString),
            hostKeyVerifier = PromiscuousSshHostKeyVerifier
        )
    }

    companion object {
        const val HOST_PREFIX = "aws.codecatalyst:"
    }
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection.workflow

import com.intellij.execution.configurations.GeneralCommandLine
import software.aws.toolkits.jetbrains.gateway.connection.GitSettings
import software.aws.toolkits.jetbrains.gateway.connection.caws.CawsCommandExecutor
import software.aws.toolkits.jetbrains.utils.execution.steps.Context
import software.aws.toolkits.resources.message

class CloneCode(
    private val remoteScriptPath: String,
    private val gitSettings: GitSettings.CloneGitSettings,
    private val remoteCommandExecutor: CawsCommandExecutor,
) : PtyCliBasedStep() {
    override val stepName: String = message("gateway.connection.workflow.git_clone")

    override fun constructCommandLine(context: Context): GeneralCommandLine = remoteCommandExecutor.buildGitCloneCommand(remoteScriptPath, gitSettings)
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection.workflow

import com.intellij.execution.configurations.GeneralCommandLine
import software.aws.toolkits.jetbrains.AwsToolkit
import software.aws.toolkits.jetbrains.gateway.connection.caws.CawsCommandExecutor
import software.aws.toolkits.jetbrains.utils.execution.steps.Context
import software.aws.toolkits.resources.message
import java.util.Base64
import kotlin.io.path.readBytes

class CopyScripts(
    private val remoteScriptPath: String,
    private val remoteCommandExecutor: CawsCommandExecutor,
) : PtyCliBasedStep() {
    override val stepName: String = message("gateway.connection.workflow.copy_scripts")

    override fun constructCommandLine(context: Context): GeneralCommandLine =
        remoteCommandExecutor.buildSshCommand {
            it.addSshOption("-e", "none")
            val b64 = Base64.getEncoder().encodeToString(SCRIPTS.readBytes())
            it.addToRemoteCommand(
                "set -x; mkdir -p \"$remoteScriptPath\" && cd \"$remoteScriptPath\" && (echo -n '$b64' | base64 -d | tar xvvzf -)"
            )
        }

    private companion object {
        private val SCRIPTS = AwsToolkit.pluginPath().resolve("gateway-resources").resolve("scripts.tar.gz")
    }
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection.workflow

import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.ide.plugins.PluginManager
import com.intellij.openapi.util.text.StringUtil
import com.intellij.util.io.Compressor
import com.intellij.util.io.DigestUtil
import com.intellij.util.io.HttpRequests
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.presigner.S3Presigner
import software.aws.toolkits.core.ConnectionSettings
import software.aws.toolkits.core.utils.exists
import software.aws.toolkits.jetbrains.core.awsClient
import software.aws.toolkits.jetbrains.core.credentials.CredentialManager
import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider
import software.aws.toolkits.jetbrains.gateway.GatewayProduct
import software.aws.toolkits.jetbrains.gateway.ToolkitInstallSettings
import software.aws.toolkits.jetbrains.gateway.connection.AbstractSsmCommandExecutor
import software.aws.toolkits.jetbrains.gateway.connection.GET_IDE_BACKEND_VERSION_COMMAND
import software.aws.toolkits.jetbrains.utils.execution.steps.CliBasedStep
import software.aws.toolkits.jetbrains.utils.execution.steps.Context
import software.aws.toolkits.jetbrains.utils.execution.steps.Step
import software.aws.toolkits.jetbrains.utils.execution.steps.StepWorkflow
import software.aws.toolkits.resources.message
import java.io.File
import java.nio.file.Path
import java.time.Duration
import java.util.UUID

abstract class InstallPluginBackend(
    protected val commandExecutor: AbstractSsmCommandExecutor,
    protected val remoteScriptPath: String,
    protected val idePath: String
) : CliBasedStep() {
    override val stepName: String = message("gateway.connection.workflow.install_toolkit")

    override fun constructCommandLine(context: Context): GeneralCommandLine? {
        val url = buildDownloadUrl() ?: return null
        val cmd = """$remoteScriptPath/install-plugin.sh "${idePath.trimEnd('/')}" "$url""""
        return commandExecutor.buildSshCommand {
            it.addToRemoteCommand(cmd)
        }
    }

    protected abstract fun buildDownloadUrl(): String?

    class InstallMarketplacePluginBackend(
        private val ideProduct: GatewayProduct?,
        commandExecutor: AbstractSsmCommandExecutor,
        remoteScriptPath: String,
        idePath: String,
        private val marketplaceUrl: String = "https://plugins.jetbrains.com"
    ) : InstallPluginBackend(commandExecutor, remoteScriptPath, idePath) {
        override fun buildDownloadUrl(): String? {
            val baseUrl = "$marketplaceUrl/pluginManager?action=download&id=aws.toolkit&build="

            if (ideProduct != null) {
                val url = "$baseUrl${ideProduct.productCode}-${ideProduct.buildNumber}"
                val responseCode = HttpRequests.head(url).throwStatusCodeException(false).tryConnect()
                return if (responseCode in 200..299) {
                    url
                } else {
                    null
                }
            }

            return "$baseUrl$($GET_IDE_BACKEND_VERSION_COMMAND)"
        }
    }

    class InstallLocalPluginBackend(
        private val installSettings: ToolkitInstallSettings.UseArbitraryLocalPath,
        commandExecutor: AbstractSsmCommandExecutor,
        remoteScriptPath: String,
        idePath: String
    ) : InstallPluginBackend(commandExecutor, remoteScriptPath, idePath) {
        override fun buildDownloadUrl(): String? {
            val credId = CredentialManager.getInstance().getCredentialIdentifierById("profile:default") ?: error("Default profile not available")
            val region = AwsRegionProvider.getInstance().defaultRegion()
            val connectionSettings = ConnectionSettings(CredentialManager.getInstance().getAwsCredentialProvider(credId, region), region)
            val toolkitPath = Path.of(installSettings.localToolkitPath)
            if (!toolkitPath.exists()) {
                return null
            }

            val messageDigest = DigestUtil.md5()
            DigestUtil.updateContentHash(messageDigest, toolkitPath)
            val toolkitHash = StringUtil.toHexString(messageDigest.digest())

            val s3Client = connectionSettings.awsClient<S3Client>()
            val s3StagingBucket = installSettings.s3StagingBucket
            val s3Key = toolkitPath.fileName.toString()

            try {
                // Will throw if the ETag doesn't match, so it will re-upload
                s3Client.headObject {
                    it.bucket(s3StagingBucket)
                    it.key(s3Key)
                    it.ifMatch(toolkitHash)
                }
            } catch (e: Exception) {
                s3Client.putObject(
                    {
                        it.bucket(s3StagingBucket)
                        it.key(s3Key)
                    },
                    toolkitPath
                )
            }

            val s3Presigner = S3Presigner.builder().credentialsProvider(connectionSettings.credentials).region(Region.of(connectionSettings.region.id)).build()
            val presignGetObject = s3Presigner.presignGetObject {
                it.signatureDuration(Duration.ofMinutes(10))
                it.getObjectRequest { req ->
                    req.bucket(s3StagingBucket)
                    req.key(s3Key)
                }
            }

            return presignGetObject.url().toString()
        }
    }
}

fun installBundledPluginBackend(
    commandExecutor: AbstractSsmCommandExecutor,
    remoteScriptPath: String,
    idePath: String
): Step {
    val remotePluginPath = "/tmp/${UUID.randomUUID()}.zip"

    return object : StepWorkflow(
        ZipAndCopyBundledPlugin(remotePluginPath, commandExecutor),
        InstallBundledPluginBackend(remotePluginPath, commandExecutor, remoteScriptPath, idePath)
    ) {
        override val stepName = "Install pre-GA development build (this will take a while)"
    }
}

private class ZipAndCopyBundledPlugin(
    private val remotePluginPath: String,
    private val commandExecutor: AbstractSsmCommandExecutor
) : CliBasedStep() {
    override val stepName: String = "Zip and copy bundled plugin"
    override fun constructCommandLine(context: Context): GeneralCommandLine? {
        val pluginPath = PluginManager.getPluginByClass(InstallBundledPluginBackend::class.java)?.pluginPath
            ?: throw RuntimeException("Could not determine AWS Toolkit plugin path")
        val zipPath = File.createTempFile("toolkit", "zip")
        val zip = Compressor.Zip(zipPath)
        zip.use {
            it.addDirectory(pluginPath.fileName.toString(), pluginPath)
        }

        return commandExecutor.buildScpCommand(remotePluginPath, false, zipPath.toPath())
    }
}

private class InstallBundledPluginBackend(
    private val remotePluginPath: String,
    commandExecutor: AbstractSsmCommandExecutor,
    remoteScriptPath: String,
    idePath: String
) : InstallPluginBackend(commandExecutor, remoteScriptPath, idePath) {
    override fun buildDownloadUrl(): String? = "file://$remotePluginPath"
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection.workflow

import com.intellij.execution.configurations.GeneralCommandLine
import software.aws.toolkits.jetbrains.gateway.connection.GitSettings
import software.aws.toolkits.jetbrains.gateway.connection.buildAgentPrimeCommand
import software.aws.toolkits.jetbrains.utils.execution.steps.Context
import software.aws.toolkits.jetbrains.utils.execution.steps.StepEmitter
import software.aws.toolkits.resources.message

class PrimeSshAgent(
    private val gitSettings: GitSettings.CloneGitSettings,
) : PtyCliBasedStep() {
    override val stepName: String = message("gateway.connection.workflow.prime_ssh_agent")

    override fun constructCommandLine(context: Context): GeneralCommandLine? = buildAgentPrimeCommand(gitSettings.repo)

    override fun handleErrorResult(exitCode: Int, output: String, stepEmitter: StepEmitter) {
        if (exitCode != 255) return

        return super.handleErrorResult(exitCode, output, stepEmitter)
    }
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection.workflow

import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.configurations.PtyCommandLine
import com.intellij.execution.process.KillableProcessHandler
import com.intellij.execution.process.ProcessHandler
import com.intellij.util.io.BaseDataReader
import com.intellij.util.io.BaseOutputReader
import software.aws.toolkits.jetbrains.utils.execution.steps.CliBasedStep
import software.aws.toolkits.jetbrains.utils.execution.steps.Context

abstract class PtyCliBasedStep : CliBasedStep() {
    override fun createProcessHandler(commandLine: GeneralCommandLine, context: Context): ProcessHandler {
        val cmd = PtyCommandLine(commandLine)
        cmd.withConsoleMode(false)
        return object : KillableProcessHandler(cmd) {
            override fun readerOptions(): BaseOutputReader.Options {
                return object : BaseOutputReader.Options() {
                    override fun policy(): BaseDataReader.SleepingPolicy {
                        return BaseDataReader.SleepingPolicy.BLOCKING
                    }

                    override fun splitToLines(): Boolean {
                        return false
                    }
                }
            }
        }
    }
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection.workflow

import com.jetbrains.gateway.api.GatewayConnectionHandle
import com.jetbrains.gateway.thinClientLink.LinkedClientManager
import com.jetbrains.rd.util.lifetime.LifetimeDefinition
import com.jetbrains.rd.util.lifetime.onTermination
import com.jetbrains.rd.util.reactive.adviseEternal
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.info
import software.aws.toolkits.jetbrains.core.credentials.sono.lazilyGetUserId
import software.aws.toolkits.jetbrains.gateway.connection.IdeBackendActions
import software.aws.toolkits.jetbrains.gateway.connection.ThinClientTrackerService
import software.aws.toolkits.jetbrains.gateway.connection.caws.CawsCommandExecutor
import software.aws.toolkits.jetbrains.utils.execution.steps.Context
import software.aws.toolkits.jetbrains.utils.execution.steps.Step
import software.aws.toolkits.jetbrains.utils.execution.steps.StepEmitter
import software.aws.toolkits.jetbrains.utils.spinUntilValue
import software.aws.toolkits.resources.message
import software.aws.toolkits.telemetry.CodecatalystTelemetry
import java.net.URI
import java.net.URLEncoder
import java.time.Duration
import java.util.concurrent.TimeoutException
import software.aws.toolkits.telemetry.Result as TelemetryResult

class StartBackend(
    private val gatewayHandle: GatewayConnectionHandle,
    private val remoteScriptPath: String,
    private val remoteProjectName: String?,
    private val executor: CawsCommandExecutor,
    private val lifetime: LifetimeDefinition,
    private val envId: String,
    private val isSmallInstance: Boolean
) : Step() {
    override val stepName: String = message("gateway.connection.workflow.start_ide")

    private val ideActions = IdeBackendActions(remoteScriptPath, remoteProjectName, executor)

    override fun execute(context: Context, stepEmitter: StepEmitter, ignoreCancellation: Boolean) {
        val (_, remoteLink) = startBackend(context, stepEmitter)
        val localLink = ideActions.forwardBackendToLocalhost(remoteLink, lifetime)
        context.throwIfCancelled()

        LOG.info { "Starting thin client with link: $localLink" }
        val clientHandle = ThinClientTrackerService.getInstance().associate(envId) {
            val start = System.currentTimeMillis()
            val thinClientHandle = try {
                LinkedClientManager.getInstance()
                    .startNewClient(lifetime, localLink, URLEncoder.encode(message("caws.workspace.backend.title"), Charsets.UTF_8)) {
                        CodecatalystTelemetry.devEnvironmentWorkflowStatistic(
                            project = null,
                            userId = lazilyGetUserId(),
                            result = TelemetryResult.Succeeded,
                            duration = System.currentTimeMillis() - start.toDouble(),
                            codecatalystDevEnvironmentWorkflowStep = "startThinClient",
                        )
                    }
            } catch (e: Throwable) {
                CodecatalystTelemetry.devEnvironmentWorkflowStatistic(
                    project = null,
                    userId = lazilyGetUserId(),
                    result = TelemetryResult.Failed,
                    duration = System.currentTimeMillis() - start.toDouble(),
                    codecatalystDevEnvironmentWorkflowStep = "startThinClient",
                    codecatalystDevEnvironmentWorkflowError = e.javaClass.simpleName
                )

                throw e
            }

            gatewayHandle to thinClientHandle
        }

        clientHandle.clientClosed.adviseEternal {
            // when client exits gateway handle should be closed to cleanup
            gatewayHandle.terminate()
            lifetime.terminate()
        }
    }

    private fun startBackend(context: Context, stepEmitter: StepEmitter): Pair<Long, URI> =
        spinUntilValue(Duration.ofMinutes(4)) {
            tryStartBackend(context, stepEmitter)
        }

    private fun tryStartBackend(context: Context, stepEmitter: StepEmitter): Pair<Long, URI>? {
        // check if backend is already running
        val initialConnectLink = ideActions.getGatewayConnectLink(gatewayLinkTimeout)
        if (initialConnectLink != null) {
            stepEmitter.emitMessageLine("Reusing existing backend instance at: $initialConnectLink", isError = false)
        }
        if (isSmallInstance) {
            runBlocking {
                delay(5000)
            }
        }

        val remoteLink = initialConnectLink ?: let {
            val backend = ideActions.startBackend()
            stepEmitter.attachProcess(backend)
            lifetime.onTermination {
                backend.destroyProcess()
            }

            if (isSmallInstance) {
                runBlocking {
                    delay(5000)
                }
            }

            val start = System.currentTimeMillis()

            val duration = Duration.ofMinutes(3)
            return@let try {
                spinUntilValue(duration = duration, interval = Duration.ofSeconds(5)) {
                    if (backend.isProcessTerminated) {
                        val message = when (backend.exitCode) {
                            7 -> message("caws.backend.error.expired")
                            else -> message("caws.backend.error.unknown", backend.exitCode.toString())
                        }

                        error(message)
                    }
                    context.throwIfCancelled()
                    ideActions.getGatewayConnectLink(gatewayLinkTimeout)
                }
            } catch (e: TimeoutException) {
                throw IllegalStateException("Backend did not start within $duration")
            } finally {
                val error = if (backend.isProcessTerminated) {
                    "exitCode: ${backend.exitCode}"
                } else {
                    null
                }

                CodecatalystTelemetry.devEnvironmentWorkflowStatistic(
                    project = null,
                    userId = lazilyGetUserId(),
                    result = if (error != null) TelemetryResult.Failed else TelemetryResult.Succeeded,
                    duration = System.currentTimeMillis() - start.toDouble(),
                    codecatalystDevEnvironmentWorkflowStep = "runIde",
                    codecatalystDevEnvironmentWorkflowError = error
                )
            }
        }

        context.throwIfCancelled()
        return remoteLink
    }

    companion object {
        private val LOG = getLogger<StartBackend>()
        private val gatewayLinkTimeout = Duration.ofSeconds(60)
    }
}


// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection.workflow

import com.intellij.execution.process.ProcessHandler
import com.intellij.execution.ui.ConsoleView
import com.intellij.execution.ui.ConsoleViewContentType
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.project.DefaultProjectFactory
import com.intellij.openapi.util.Disposer
import com.intellij.terminal.TerminalExecutionConsole
import com.intellij.util.ui.components.BorderLayoutPanel
import software.aws.toolkits.jetbrains.utils.execution.steps.StepEmitter
import software.aws.toolkits.jetbrains.utils.execution.steps.WorkflowEmitter
import software.aws.toolkits.resources.message
import javax.swing.JTabbedPane

class TabbedWorkflowEmitter(private val tabComponent: JTabbedPane, private val disposable: Disposable) : WorkflowEmitter {
    override fun createStepEmitter(): StepEmitter = NoOpEmitter()

    private fun createTabEmitter(tabName: String, hidden: Boolean): StepEmitter {
        if (hidden) return NoOpEmitter()

        val panel = BorderLayoutPanel()
        runInEdt(ModalityState.any()) {
            tabComponent.addTab(tabName, panel)
            tabComponent.selectedIndex = tabComponent.tabCount - 1
        }

        return TabStepEmitter(panel)
    }

    private inner class NoOpEmitter : StepEmitter {
        override fun createChildEmitter(stepName: String, hidden: Boolean): StepEmitter = createTabEmitter(stepName, hidden)
    }

    private inner class TabStepEmitter(val contentPanel: BorderLayoutPanel) : StepEmitter {
        private var content: ConsoleView? = null

        private fun ensureContent(handler: ProcessHandler?): ConsoleView? {
            if (ApplicationManager.getApplication().isUnitTestMode) return null

            // return existing content if it exists, otherwise create an appropriate one for the context
            content?.let { return it }

            // TextConsoleBuilderFactory is nicer but seems to have issues when drawn while not visible
            return TerminalExecutionConsole(DefaultProjectFactory.getInstance().defaultProject, 132, 24, handler)
                .withConvertLfToCrlfForNonPtyProcess(true)
                .also {
                    content = it
                    Disposer.register(disposable, it)
                    runInEdt(ModalityState.any()) {
                        contentPanel.addToCenter(it.component)
                        contentPanel.revalidate()
                        contentPanel.repaint()
                    }
                }
        }

        override fun createChildEmitter(stepName: String, hidden: Boolean): StepEmitter = createTabEmitter(stepName, hidden)

        override fun attachProcess(handler: ProcessHandler) {
            val content = ensureContent(handler)

            runInEdt(ModalityState.any()) {
                content?.attachToProcess(handler)
            }
        }

        private fun doEmitMessage(message: String, contentType: ConsoleViewContentType, shouldEmitToTerminal: Boolean = false) {
            val content = ensureContent(null)
            if (content is TerminalExecutionConsole && !shouldEmitToTerminal) {
                // terminal console output is automatically handled by the process handler
                return
            }

            runInEdt(ModalityState.any()) {
                content?.print(message, contentType)
            }
        }

        override fun emitMessage(message: String, isError: Boolean) {
            val type = if (isError) {
                ConsoleViewContentType.ERROR_OUTPUT
            } else {
                ConsoleViewContentType.NORMAL_OUTPUT
            }

            doEmitMessage(message, type)
        }

        override fun stepSkipped() {
            doEmitMessage(message("gateway.connection.workflow.step_skipped"), ConsoleViewContentType.SYSTEM_OUTPUT, shouldEmitToTerminal = true)
        }

        override fun stepFinishExceptionally(e: Throwable) {
            val errorOutputType = ConsoleViewContentType.ERROR_OUTPUT
            doEmitMessage(message("gateway.connection.workflow.step_failed"), errorOutputType, shouldEmitToTerminal = true)
            doEmitMessage(e.stackTraceToString(), errorOutputType, shouldEmitToTerminal = true)
        }

        override fun stepFinishSuccessfully() {
            doEmitMessage(message("gateway.connection.workflow.step_successful"), ConsoleViewContentType.SYSTEM_OUTPUT, shouldEmitToTerminal = true)
        }
    }
}


// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection.workflow.v2

import com.jetbrains.gateway.ssh.HighLevelHostAccessor
import com.jetbrains.gateway.ssh.HostDeployContext
import com.jetbrains.gateway.ssh.HostDeployInputs

class HostContext(
    override var config: Unit?,
    override var deployData: HostDeployInputs?,
    override val hostAccessor: HighLevelHostAccessor?,
    override val remoteProjectPath: String?
) : HostDeployContext<Unit>


// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.gateway.connection.workflow.v2

import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.openapi.rd.util.startBackgroundAsync
import com.intellij.openapi.rd.util.startUnderModalProgressAsync
import com.intellij.remote.RemoteCredentialsHolder
import com.jetbrains.gateway.ssh.DeployFlowUtil
import com.jetbrains.gateway.ssh.HighLevelHostAccessor
import com.jetbrains.gateway.ssh.HostDeployInputs
import com.jetbrains.gateway.ssh.deploy.DeployTargetInfo
import com.jetbrains.gateway.ssh.deploy.LoggingHostCommandExecutorWrapper
import com.jetbrains.gateway.ssh.deploy.executeCommand
import com.jetbrains.gateway.ssh.deploy.impl.SshCommandExecutor
import com.jetbrains.rd.util.lifetime.Lifetime
import software.aws.toolkits.jetbrains.gateway.WorkspaceIdentifier
import software.aws.toolkits.jetbrains.gateway.connection.caws.CawsSshConnectionConfigModifier
import software.aws.toolkits.jetbrains.services.caws.CawsConstants
import software.aws.toolkits.jetbrains.utils.execution.steps.Context
import software.aws.toolkits.jetbrains.utils.execution.steps.Step
import software.aws.toolkits.jetbrains.utils.execution.steps.StepEmitter
import software.aws.toolkits.resources.message

class StartBackendV2(
    private val lifetime: Lifetime,
    private val indicator: ProgressIndicator,
    private val identifier: WorkspaceIdentifier,
    private val remoteProjectName: String? = null
) : Step() {
    override val stepName: String = message("gateway.connection.workflow.start_ide")

    override fun execute(context: Context, stepEmitter: StepEmitter, ignoreCancellation: Boolean) {
        val creds = RemoteCredentialsHolder().apply {
            setHost("${CawsSshConnectionConfigModifier.HOST_PREFIX}${identifier.friendlyString}")
        }
        val executor = SshCommandExecutor(creds, allowDialogs = true)

        lifetime.startBackgroundAsync {
            lifetime.startUnderModalProgressAsync(message("caws.connecting.in_progress"), true) {
                val projectPath = if (remoteProjectName == null) {
                    CawsConstants.CAWS_ENV_PROJECT_DIR
                } else {
                    CawsConstants.CAWS_ENV_PROJECT_DIR + "/$remoteProjectName"
                }
                val hostinfo = HostDeployInputs.WithHostInfo(LoggingHostCommandExecutorWrapper(executor))
                val hostContext = HostContext(null, hostinfo, HighLevelHostAccessor.create(executor), projectPath)
                val accessor = DeployFlowUtil.checkHostAndDeployWorker(indicator, hostContext)
                    ?: return@startUnderModalProgressAsync null

                // force-hide meaningless 'upgrade' prompt for user
                hostinfo.loggingCommandExecutor.executeCommand(
                    lifetime,
                    "sudo",
                    "sh",
                    "-c",
                    "mkdir -p /etc/xdg/JetBrains/RemoteDev/ && echo 'true' > /etc/xdg/JetBrains/RemoteDev/disableManualDeployment"
                )

                val deployData = hostinfo.upgrade(accessor)
                    .upgrade(
                        projectPath,
                        DeployTargetInfo.NoDeploy(CawsConstants.CAWS_ENV_IDE_BACKEND_DIR, null)
                    )

                DeployFlowUtil.fullDeployCycle(lifetime, deployData)
            }
        }
    }
}