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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

import com.intellij.ide.DataManager
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.ui.ClickListener
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.speedSearch.SpeedSearchUtil
import com.intellij.util.ui.UIUtil
import java.awt.Component
import java.awt.Cursor
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JComponent

class ActionLinkColoredSearchComponent(private val text: String, onClick: (AnActionEvent) -> Unit) : SimpleColoredComponent(), WorkspaceSpeedSearchProvider {
    private var lastSpeedSearchSource: JComponent? = null

    init {
        isOpaque = false

        UIUtil.setCursor(this, Cursor.getPredefinedCursor(Cursor.HAND_CURSOR))
        append(text, SimpleTextAttributes.LINK_PLAIN_ATTRIBUTES)
        object : ClickListener() {
            override fun onClick(event: MouseEvent, clickCount: Int): Boolean {
                val context = DataManager.getInstance().getDataContext(event.component)
                onClick(AnActionEvent.createFromInputEvent(event, text, null, context))
                return true
            }

            override fun installOn(c: Component) {
                super.installOn(c)
                c.addMouseListener(object : MouseAdapter() {
                    override fun mouseEntered(e: MouseEvent?) {
                        e?.source ?: return
                        val iterator = iterator()
                        iterator.forEach { _ ->
                            // iterator iterates over the fragments, but the text attributes are on the iterator and not the iteration
                            val attr = iterator.textAttributes
                            iterator.textAttributes = attr.derive(SimpleTextAttributes.LINK_ATTRIBUTES.style, attr.fgColor, attr.bgColor, attr.waveColor)
                        }
                        repaint()
                    }

                    override fun mouseExited(e: MouseEvent?) {
                        redraw()
                    }
                })
            }
        }.installOn(this)
    }

    fun redraw() {
        this.clear()
        append(text, SimpleTextAttributes.LINK_PLAIN_ATTRIBUTES)
        lastSpeedSearchSource?.let { SpeedSearchUtil.applySpeedSearchHighlighting(it, this, true, false) }
    }

    override fun highlight(speedSearchEnabledComponent: JComponent) {
        lastSpeedSearchSource = speedSearchEnabledComponent
        redraw()
    }

    override fun getElementText(): String = text
}


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

import com.intellij.ui.JBColor
import com.intellij.util.ui.JBInsets
import java.awt.Component
import java.awt.Graphics
import javax.swing.border.EmptyBorder

/**
 * Border that draws a line at the bottom of the panel, ignoring all insets. Meaning it will render past the components inside the panel.
 */
class BottomLineBorder(private val color: JBColor, insets: JBInsets) : EmptyBorder(insets) {
    override fun paintBorder(c: Component?, g: Graphics, x: Int, y: Int, width: Int, height: Int) {
        g.color = color
        g.drawLine(x, y + height - 1, x + width, y + height - 1)
    }
}


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

import com.intellij.ide.DataManager
import com.intellij.ide.browsers.BrowserLauncher
import com.intellij.openapi.actionSystem.DataProvider
import com.intellij.openapi.progress.ProcessCanceledException
import com.intellij.openapi.rd.createNestedDisposable
import com.intellij.openapi.rd.util.launchOnUi
import com.intellij.openapi.util.Disposer
import com.jetbrains.rd.util.lifetime.Lifetime
import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient
import software.aws.toolkits.core.ClientConnectionSettings
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.warn
import software.aws.toolkits.jetbrains.core.AwsClientManager
import software.aws.toolkits.jetbrains.core.AwsResourceCache
import software.aws.toolkits.jetbrains.gateway.CawsLoadingPanel
import software.aws.toolkits.jetbrains.gateway.welcomescreen.WorkspaceDataRetriever.Companion.createWorkspaceDataRetriever
import software.aws.toolkits.jetbrains.services.caws.CawsEndpoints
import software.aws.toolkits.jetbrains.services.caws.CawsResources
import software.aws.toolkits.jetbrains.settings.CawsSpaceTracker
import software.aws.toolkits.resources.message
import java.awt.Component
import javax.swing.JComponent

class ExistingWorkspaces(
    private val setContentCallback: (Component) -> Unit,
    lifetime: Lifetime
) : CawsLoadingPanel(lifetime, setContentCallback), DataProvider {
    private val disposable = lifetime.createNestedDisposable()
    override val title = message("code.aws.workspaces.short")
    override val showSpaceSelector = true

    override fun getComponent() = super.getComponent().also {
        DataManager.registerDataProvider(it, this@ExistingWorkspaces)
    }

    override fun getContent(connectionSettings: ClientConnectionSettings<*>): JComponent {
        val spaces = try {
            AwsResourceCache.getInstance().getResourceNow(CawsResources.ALL_SPACES, connectionSettings)
        } catch (e: Exception) {
            return buildLoadError(e)
        }

        val space = CawsSpaceTracker.getInstance().lastSpaceName()
        if (space == null || space !in spaces) {
            return InfoPanel()
                .addLine(message("caws.workspace.details.select_org"))
                .addAction(message("general.get_started")) {
                    BrowserLauncher.instance.browse(CawsEndpoints.ConsoleFactory.baseUrl())
                }
        }

        return try {
            val client = AwsClientManager.getInstance().getClient<CodeCatalystClient>(connectionSettings)
            val workspaces = createWorkspaceDataRetriever(client, space)
            Disposer.register(disposable, workspaces)

            if (workspaces.workspaces().isNotEmpty() || workspaces.codeRepos().isNotEmpty()) {
                WorkspaceListPanel(workspaces, client, setContentCallback, { startLoading() }, lifetime)
            } else {
                InfoPanel()
                    .addLine(message("caws.information_panel"))
                    .addLine(message("caws.information.panel"))
                    .addAction(message("general.refresh")) { lifetime.launchOnUi { startLoading() } }
                    .addDefaultActionButton(message("caws.workspace.new")) { noRepoWizard(setContentCallback) }
            }
        } catch (e: Exception) {
            if (e is ProcessCanceledException) {
                throw e
            }

            LOG.warn(e) { "Failed to list Dev Environments" }

            buildLoadError(e)
        }
    }

    override fun getData(dataId: String): Any? = when {
        else -> null
    }

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


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

import software.aws.toolkits.jetbrains.gateway.Workspace
import software.aws.toolkits.jetbrains.services.caws.CawsProject

class FilteringWorkspaceList(private val delegate: WorkspaceList, private val predicate: (Workspace) -> Boolean) : WorkspaceList by delegate {
    private val listeners = mutableListOf<Runnable>()
    private var data = emptyMap<CawsProject, List<Workspace>>()

    init {
        delegate.addChangeListener { filterList() }

        filterList()
    }

    private fun filterList() {
        data = delegate.workspaces().entries.mapNotNull { (project, workspaces) ->
            project to workspaces.filter(predicate).toList()
        }.toMap()
        listeners.forEach { it.run() }
    }

    override fun workspaces(): Map<CawsProject, List<Workspace>> = data

    override fun addChangeListener(listener: Runnable) {
        listeners.add(listener)
    }
}


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

import com.intellij.ui.RoundedLineBorder
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.speedSearch.SpeedSearchUtil
import com.intellij.util.ui.JBInsets
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.Component
import java.awt.Insets
import javax.swing.BorderFactory
import javax.swing.JComponent

class Label(private val text: String = "", private val providesSpeedSearch: Boolean = true) : SimpleColoredComponent(), WorkspaceSpeedSearchProvider {
    init {
        append(text)
        isOpaque = false
        ipad = JBInsets.create(0, 0)
        myBorder = BorderFactory.createEmptyBorder()
        border = object : RoundedLineBorder(JBUI.CurrentTheme.Button.buttonOutlineColorStart(false), 15) {
            private val borderInsets = JBUI.insets(1, 6, 1, 6)
            override fun getBorderInsets(c: Component?): Insets = borderInsets
            override fun getBorderInsets(c: Component?, insets: Insets?): Insets = borderInsets
        }
        font = JBUI.Fonts.smallFont()
        foreground = UIUtil.getInactiveTextColor()
    }

    override fun highlight(speedSearchEnabledComponent: JComponent) {
        if (!providesSpeedSearch) {
            return
        }

        clear()
        append(text)
        SpeedSearchUtil.applySpeedSearchHighlighting(speedSearchEnabledComponent, this, true, false)
    }

    override fun getElementText(): String? =
        if (providesSpeedSearch) {
            text
        } 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.welcomescreen

import com.intellij.openapi.ui.popup.Balloon
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager
import com.intellij.ui.awt.RelativePoint
import com.intellij.ui.components.panels.NonOpaquePanel
import com.intellij.util.ui.JBInsets
import com.intellij.util.ui.UIUtil
import software.aws.toolkits.jetbrains.gateway.welcomescreen.LabelPanel.Companion.LABEL_GAP
import java.awt.Dimension
import java.awt.Point
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JComponent

class LabelOverflow(private val labelPanel: LabelPanel) : NonOpaquePanel(), WorkspaceSpeedSearchProvider {
    private companion object {
        const val LABEL_OVERFLOW_POPUP_WIDTH = 300
    }

    private var highlightComponent: JComponent? = null
    override fun highlight(speedSearchEnabledComponent: JComponent) {
        println("highlight $speedSearchEnabledComponent")
        highlightComponent = speedSearchEnabledComponent
    }

    override fun getElementText(): String = labelPanel.hiddenLabels().joinToString(" ")

    private val label = Label(providesSpeedSearch = false)
    private var balloon: Balloon? = null

    init {
        add(label)
        label.addMouseListener(HoverListener())
        label.background = UIUtil.getToolTipBackground()
    }

    fun handleMouseEntered() {
        label.isOpaque = true
        label.repaint()

        // Make a copy of the labels so that they aren't in the UI hierarchy twice
        val popup = ShowMorePopup(labelPanel.hiddenLabels(), highlightComponent)

        val newBalloon = JBPopupFactory.getInstance()
            .createBalloonBuilder(popup)
            .setFillColor(WelcomeScreenUIManager.getMainAssociatedComponentBackground())
            .setFadeoutTime(0)
            .setAnimationCycle(0)
            .setHideOnClickOutside(false)
            .setHideOnLinkClick(false)
            .setHideOnKeyOutside(false)
            .setHideOnAction(false)
            .createBalloon()

        newBalloon.show(RelativePoint(label, Point(label.width / 2, 0)), Balloon.Position.above)

        balloon = newBalloon
    }

    fun handleMouseExited() {
        label.isOpaque = false
        label.repaint()

        balloon?.hide()
    }

    fun updateLabel(count: Int) {
        val text = if (count == labelPanel.labels.size) {
            "$count labels"
        } else {
            "$count more"
        }

        label.clear()
        label.append(text)
    }

    inner class ShowMorePopup(labels: List<String>, highlightComponent: JComponent?) : NonOpaquePanel() {
        init {
            labels.onEach { str ->
                Label(str).also { label ->
                    add(label)
                    highlightComponent?.let {
                        label.highlight(it)
                    }
                }
            }
        }

        override fun getMinimumSize(): Dimension = preferredSize

        override fun getPreferredSize(): Dimension {
            // Calculate if the largest label needs to be bigger than the default max width
            val labelArea = calculateLabelArea()
            JBInsets.addTo(labelArea, insets)
            return labelArea
        }

        private fun calculateLabelArea(): Dimension {
            var colCount = 1
            var x = 0
            var y = 0
            var width = 0

            components.forEach { label ->
                // If not the first label in the row, add the gap
                if (colCount > 1) {
                    x += LABEL_GAP
                }

                // Calculate width with gaps in
                val labelSize = label.preferredSize
                x += labelSize.width

                // Only wrap if the added label is larger than our max, and it's not the first label on the row
                if (colCount > 1 && x > LABEL_OVERFLOW_POPUP_WIDTH) {
                    colCount = 1
                    x = 0
                    y += labelSize.height + LABEL_GAP
                } else {
                    width = maxOf(width, x)
                }

                colCount++
            }

            val height = y + components.first().height

            // TODO: We need to find a way to wrap if > LABEL_OVERFLOW_POPUP_WIDTH
            return Dimension(minOf(width, LABEL_OVERFLOW_POPUP_WIDTH), height)
        }

        override fun doLayout() {
            synchronized(treeLock) {
                var colCount = 1
                var x = insets.left
                var y = insets.top

                components.forEach { label ->
                    // If not the first label in the row, add the gap
                    if (colCount > 1) {
                        x += LABEL_GAP
                    }

                    val labelSize = label.preferredSize

                    // Only wrap if the added label is larger than our max, and it's not the first label on the row
                    if (colCount > 1 && (x + labelSize.width) > width) {
                        colCount = 1

                        x = insets.left
                        y += labelSize.height + LABEL_GAP
                    }

                    // TODO: We need to find a way to wrap if > LABEL_OVERFLOW_POPUP_WIDTH
                    label.setBounds(x, y, minOf(labelSize.width, LABEL_OVERFLOW_POPUP_WIDTH), labelSize.height)
                    x += label.preferredSize.width
                    colCount++
                }
            }
        }
    }

    private inner class HoverListener : MouseAdapter() {
        override fun mouseEntered(e: MouseEvent?) {
            handleMouseEntered()
        }

        override fun mouseExited(e: MouseEvent?) {
            handleMouseExited()
        }
    }
}


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

import com.intellij.ui.components.panels.NonOpaquePanel
import com.intellij.util.ui.JBInsets
import com.intellij.util.ui.UIUtil
import java.awt.Dimension

class LabelPanel(labels: List<String>) : NonOpaquePanel() {
    companion object {
        const val LABEL_GAP = UIUtil.DEFAULT_HGAP / 2
    }

    val labels = labels.map { Label(it) }.onEach {
        add(it)
    }

    private val overflowLabel = LabelOverflow(this).also {
        add(it)
    }

    fun hiddenLabels(): List<String> = labels.asSequence().filterNot { it.isVisible }.map { it.toString() }.toList()

    private fun hiddenLabelCount() = labels.count { !it.isVisible }

    override fun getMinimumSize(): Dimension {
        val overflowSize = updateOverflowAndDetermineSize(labels.size)
        JBInsets.addTo(overflowSize, insets)
        return overflowSize
    }

    // Our preferred layout will always be the minimum, if our containing container gives us more space than we asked for, then we will show labels if
    // they fit
    override fun getPreferredSize(): Dimension = minimumSize

    override fun doLayout() {
        synchronized(treeLock) {
            var x = insets.left
            val y = insets.top
            val overflowComponent = overflowLabel

            val maxWidth = width - (insets.left + insets.right)

            var needsOverflow = false
            var lastVisibleIndex = -1

            // Last component is always overflow label
            labels.forEachIndexed { index, label ->
                val labelSize = label.preferredSize
                label.isVisible = x + labelSize.width <= maxWidth
                label.setBounds(x, y, labelSize.width, labelSize.height)

                x += labelSize.width + LABEL_GAP

                if (label.isVisible) {
                    lastVisibleIndex = index
                } else {
                    needsOverflow = true
                }
            }

            if (!needsOverflow) {
                overflowComponent.isVisible = false
                return
            }

            // Keep popping labels off the end till we make space for the overflow label
            var hiddenCount = hiddenLabelCount()

            // Use the first invisible location as the initial place to put the overflow label
            var overflowStart = labels[lastVisibleIndex + 1].x
            var overflowSize = updateOverflowAndDetermineSize(hiddenCount)

            // Make sure that the overflow label will now fit, if it doesn't then:
            // 1. Hide the last label
            // 2. Move the overflow label to its location
            // 3. Re-calculate the overflow label size in case that it grows, (font may not be monospaced or number of digits changes for example)
            // 4. Repeat until there is space, or we popped every label off
            while (overflowStart + overflowSize.width > maxWidth && lastVisibleIndex >= 0) {
                val label = labels[lastVisibleIndex]

                lastVisibleIndex--
                hiddenCount++

                label.isVisible = false

                overflowStart = label.x
                overflowSize = updateOverflowAndDetermineSize(hiddenCount)
            }

            overflowComponent.isVisible = true
            overflowComponent.setBounds(overflowStart, y, overflowSize.width, overflowSize.height)
        }
    }

    private fun updateOverflowAndDetermineSize(count: Int): Dimension {
        overflowLabel.updateLabel(count)
        return overflowLabel.preferredSize
    }
}


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.speedSearch.SpeedSearchUtil
import javax.swing.JComponent

class SearchableLabel(private val text: String = "") : SimpleColoredComponent(), WorkspaceSpeedSearchProvider {
    init {
        append(text)
        isOpaque = false
    }

    override fun highlight(speedSearchEnabledComponent: JComponent) {
        clear()
        append(text)
        SpeedSearchUtil.applySpeedSearchHighlighting(speedSearchEnabledComponent, this, true, false)
    }

    override fun getElementText(): String = text
}


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.tabs.JBTabPainter
import com.intellij.ui.tabs.JBTabsPosition
import com.intellij.ui.tabs.impl.JBDefaultTabPainter
import com.intellij.ui.tabs.impl.themes.DefaultTabTheme
import com.intellij.util.ui.BaseButtonBehavior
import com.intellij.util.ui.JBUI
import java.awt.Color
import java.awt.FlowLayout
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import java.awt.event.MouseEvent
import javax.swing.JPanel

class TabLikeButtons : JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)) {
    private val painter: JBTabPainter = JBDefaultTabPainter(object : DefaultTabTheme() {
        override val inactiveColoredTabBackground: Color = WelcomeScreenUIManager.getMainAssociatedComponentBackground()
    })
    private val tabs = mutableListOf<TabButton>()
    private var selectedTab = 0

    fun addButton(onSelect: () -> Unit): TabButton {
        val tabButton = TabButton(onSelect)
        tabs.add(tabButton)

        add(tabButton)

        return tabButton
    }

    private fun selectContent(tabButton: TabButton) {
        selectedTab = tabs.indexOf(tabButton)
        repaint()
    }

    override fun paintComponent(g: Graphics) {
        super.paintComponent(g)

        val g2d = g.create() as Graphics2D
        g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
        try {
            tabs.forEach {
                val selected = selectedTab == tabs.indexOf(it)
                if (selected) {
                    painter.paintSelectedTab(
                        JBTabsPosition.top,
                        g2d,
                        it.bounds,
                        0,
                        WelcomeScreenUIManager.getMainAssociatedComponentBackground(),
                        active = true,
                        hovered = it.isHovered()
                    )
                } else {
                    painter.paintTab(
                        JBTabsPosition.top,
                        g2d,
                        it.bounds,
                        0,
                        WelcomeScreenUIManager.getMainAssociatedComponentBackground(),
                        active = true,
                        hovered = it.isHovered()
                    )
                }
            }
        } finally {
            g2d.dispose()
        }
    }

    inner class TabButton(private val onSelect: () -> Unit) : JPanel() {
        private val label = SimpleColoredComponent()

        private val behavior = object : BaseButtonBehavior(this) {
            override fun execute(e: MouseEvent) {
                handleMouseClick()
            }
        }

        init {
            isOpaque = false
            border = JBUI.Borders.empty(0, 6)

            label.isOpaque = false

            add(label)
        }

        fun getText() = label

        fun isHovered(): Boolean = behavior.isHovered

        private fun handleMouseClick() {
            selectContent(this)
            onSelect.invoke()
        }
    }
}


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

object WelcomeScreenDataKeys


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager
import com.intellij.ui.layout.Row
import com.intellij.ui.layout.RowBuilder
import com.intellij.util.ui.UIUtil
import java.awt.Component
import java.awt.Container
import javax.swing.BorderFactory
import javax.swing.JComponent

const val PANEL_TOP_INSET = 20
const val PANEL_SIDE_INSET = 24
val DEFAULT_WELCOME_BORDER = BorderFactory.createEmptyBorder(PANEL_TOP_INSET, PANEL_SIDE_INSET, UIUtil.LARGE_VGAP, PANEL_SIDE_INSET)

fun recursivelySetBackground(component: Component) {
    component.background = WelcomeScreenUIManager.getMainAssociatedComponentBackground()

    if (component is Container) {
        component.components.forEach {
            recursivelySetBackground(it)
        }
    }
}

fun setDefaultBackgroundAndBorder(component: JComponent) {
    recursivelySetBackground(component)
    component.border = DEFAULT_WELCOME_BORDER
}

fun (RowBuilder).smallInset(): Row =
    // should be roughly the same as a small inset in the new UI DSL
    row {
        label("")
    }

fun (RowBuilder).biggerLargeGapAfter() {
    // should be roughly the same as a large inset in the new UI DSL
    smallInset().largeGapAfter()
}


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

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.ui.DialogBuilder
import com.intellij.openapi.ui.DialogWrapper
import com.intellij.openapi.ui.setEmptyState
import com.intellij.ui.components.JBLoadingPanel
import com.intellij.ui.dsl.builder.COLUMNS_SHORT
import com.intellij.ui.dsl.builder.bindText
import com.intellij.ui.dsl.builder.columns
import com.intellij.ui.dsl.builder.panel
import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient
import software.amazon.awssdk.services.codecatalyst.model.InstanceType
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.warn
import software.aws.toolkits.jetbrains.core.credentials.sono.lazilyGetUserId
import software.aws.toolkits.jetbrains.gateway.GatewayProduct
import software.aws.toolkits.jetbrains.gateway.Workspace
import software.aws.toolkits.jetbrains.gateway.cawsEnvironmentSize
import software.aws.toolkits.jetbrains.gateway.cawsEnvironmentTimeout
import software.aws.toolkits.jetbrains.gateway.ideVersionComboBox
import software.aws.toolkits.jetbrains.services.caws.InactivityTimeout
import software.aws.toolkits.jetbrains.services.caws.loadParameterDescriptions
import software.aws.toolkits.resources.message
import software.aws.toolkits.telemetry.CodecatalystTelemetry
import software.aws.toolkits.telemetry.CodecatalystUpdateDevEnvironmentLocationType
import java.awt.BorderLayout
import software.aws.toolkits.telemetry.Result as TelemetryResult

class WorkspaceConfigurationDialog private constructor(cawsClient: CodeCatalystClient, isFreeTier: Boolean, ws: Workspace, disposable: Disposable) :
    JBLoadingPanel(BorderLayout(), disposable) {
    val context = ReconfigureWorkspaceSettings(ws)
    val panel = panel {

        row(message("caws.workspace.details.alias.label")) {
            textField()
                .bindText(context::alias)
                .columns(COLUMNS_SHORT)
                .applyToComponent {
                    setEmptyState(message("general.optional"))
                }
        }

        row(message("caws.workspace.ide_label")) {
            ideVersionComboBox(disposable, context::gatewayProduct)
        }

        group(message("caws.workspace.settings"), indent = false) {
            if (isFreeTier) {
                row {
                    comment(message("caws.compute.size.in.free.tier.comment"))
                }
            }
            cawsEnvironmentSize(
                loadParameterDescriptions().environmentParameters,
                context::instanceType,
                isFreeTier
            )

            row {
                cawsEnvironmentTimeout(context::inactivityTimeout)
            }.contextHelp(message("caws.workspace.details.inactivity_timeout_comment"))
        }
    }.also {
        it.apply()
    }

    init {
        contentPanel.add(panel, BorderLayout.CENTER)
    }

    companion object {
        private val LOG = getLogger<WorkspaceConfigurationDialog>()
        fun buildDialog(cawsClient: CodeCatalystClient, isFreeTier: Boolean, initialWs: Workspace, workspaceList: WorkspaceList) =
            DialogBuilder().also { dialog ->
                val content = WorkspaceConfigurationDialog(cawsClient, isFreeTier, initialWs, dialog)
                dialog.addOkAction()
                dialog.addCancelAction()
                dialog.setTitle(
                    message(
                        "caws.configure_workspace_title",
                        initialWs.alias ?: initialWs.branch ?: initialWs.identifier.friendlyString,
                        initialWs.identifier.project.project
                    )
                )
                dialog.setCenterPanel(content)
                dialog.setPreferredFocusComponent(content)
                dialog.setOkText(message("general.update_button"))
                dialog.setOkOperation {
                    ApplicationManager.getApplication().executeOnPooledThread {
                        val errors = content.panel.validateAll()
                        errors.firstOrNull()?.let {
                            dialog.setErrorText(it.message, it.component)
                            return@executeOnPooledThread
                        }
                        content.panel.apply()

                        reconfigureIdeFromContext(dialog, cawsClient, initialWs, content.context, workspaceList)
                    }
                }
                dialog.setCancelOperation {
                    dialog.dialogWrapper.close(DialogWrapper.CANCEL_EXIT_CODE)
                    CodecatalystTelemetry.updateDevEnvironmentSettings(
                        project = null,
                        userId = lazilyGetUserId(),
                        codecatalystUpdateDevEnvironmentLocationType = CodecatalystUpdateDevEnvironmentLocationType.Local,
                        result = TelemetryResult.Cancelled
                    )
                }
            }

        private fun DialogBuilder.setOkText(text: String) {
            runInEdt(ModalityState.any()) {
                okAction.apply {
                    setText(text)
                    // forces text to be applied to action
                    (this as DialogBuilder.OkActionDescriptor).getAction(dialogWrapper)
                }
            }
        }

        private fun reconfigureIdeFromContext(
            dialogBuilder: DialogBuilder,
            cawsClient: CodeCatalystClient,
            ws: Workspace,
            context: ReconfigureWorkspaceSettings,
            workspaceList: WorkspaceList
        ) {
            val oldSettings = ReconfigureWorkspaceSettings(ws)
            if (oldSettings == context) {
                dialogBuilder.setErrorText(message("general.no_changes"))
                return
            }

            dialogBuilder.okActionEnabled(false)
            dialogBuilder.setOkText(message("general.update_in_progress"))
            val userId = lazilyGetUserId()
            try {
                cawsClient.updateDevEnvironment {
                    it.spaceName(ws.identifier.project.space)
                    it.projectName(ws.identifier.project.project)
                    it.id(ws.identifier.id)

                    if (oldSettings.alias != context.alias) {
                        it.alias(context.alias)
                    }

                    if (oldSettings.instanceType != context.instanceType) {
                        it.instanceType(context.instanceType)
                    }

                    if (oldSettings.inactivityTimeout != context.inactivityTimeout) {
                        it.inactivityTimeoutMinutes(context.inactivityTimeout.asMinutes())
                    }

                    context.gatewayProduct?.let { product ->
                        if (oldSettings.gatewayProduct != context.gatewayProduct) {
                            it.ides({ ide ->
                                ide.name(product.apiType)
                                ide.runtime(product.ecrImage)
                            })
                        }
                    }
                }
                CodecatalystTelemetry.updateDevEnvironmentSettings(
                    project = null,
                    userId = userId,
                    codecatalystUpdateDevEnvironmentLocationType = CodecatalystUpdateDevEnvironmentLocationType.Local,
                    result = TelemetryResult.Succeeded
                )
            } catch (e: Exception) {
                dialogBuilder.setErrorText(e.message)
                dialogBuilder.okActionEnabled(true)
                dialogBuilder.setOkText(message("general.update_button"))

                LOG.warn(e) { "reconfigureIdeFromContext failed" }

                CodecatalystTelemetry.updateDevEnvironmentSettings(
                    project = null,
                    userId = userId,
                    codecatalystUpdateDevEnvironmentLocationType = CodecatalystUpdateDevEnvironmentLocationType.Local,
                    result = TelemetryResult.Failed
                )
                return
            }

            workspaceList.markWorkspaceAsDirty(ws)

            runInEdt(ModalityState.any()) {
                dialogBuilder.dialogWrapper.close(DialogWrapper.OK_EXIT_CODE)
            }
        }
    }
}

data class ReconfigureWorkspaceSettings(
    var alias: String,
    var gatewayProduct: GatewayProduct?,
    var instanceType: InstanceType,
    var inactivityTimeout: InactivityTimeout
) {
    constructor(ws: Workspace) :
        this(
            alias = ws.alias ?: "",
            gatewayProduct = GatewayProduct.fromWorkspace(ws),
            instanceType = ws.instanceType,
            inactivityTimeout = ws.inactivityTimeout
        )
}


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

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.util.Alarm
import com.intellij.util.AlarmFactory
import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient
import software.amazon.awssdk.services.codecatalyst.model.DevEnvironmentStatus
import software.aws.toolkits.jetbrains.gateway.SourceRepository
import software.aws.toolkits.jetbrains.gateway.Workspace
import software.aws.toolkits.jetbrains.gateway.WorkspaceIdentifier
import software.aws.toolkits.jetbrains.gateway.inProgress
import software.aws.toolkits.jetbrains.gateway.toSourceRepository
import software.aws.toolkits.jetbrains.gateway.toWorkspace
import software.aws.toolkits.jetbrains.services.caws.CawsProject
import software.aws.toolkits.jetbrains.services.caws.listAccessibleProjectsPaginator
import software.aws.toolkits.jetbrains.settings.CawsSpaceTracker
import java.time.Duration
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write

class WorkspaceDataRetriever(
    private val client: CodeCatalystClient,
    private val spaceName: String
) : WorkspaceList, WorkspaceListStateChangeListener, Disposable {
    private val listeners = mutableListOf<Runnable>()

    private val updateAlarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, this)
    private val workspaces = mutableMapOf<CawsProject, MutableMap<WorkspaceIdentifier, Workspace>>()
    private val repositories = mutableMapOf<CawsProject, List<SourceRepository>>()
    private val lock = ReentrantReadWriteLock()
    private val dirtyWorkspaces = mutableSetOf<WorkspaceIdentifier>()

    init {
        ApplicationManager.getApplication().messageBus.connect(this).subscribe(WorkspaceNotifications.TOPIC, this)
    }

    private fun getCachedWorkspace(identifier: WorkspaceIdentifier) =
        workspaces[identifier.project]?.get(identifier)

    private fun updateCachedWorkspace(ws: Workspace) =
        workspaces.computeIfAbsent(ws.identifier.project) { mutableMapOf() }.put(ws.identifier, ws)

    private fun removeCachedWorkspace(ws: Workspace) =
        workspaces[ws.identifier.project]?.remove(ws.identifier)

    private fun loadData() {
        val initialDirty = mutableSetOf<WorkspaceIdentifier>()
        client.listAccessibleProjectsPaginator { it.spaceName(spaceName) }.items()
            .forEach { project ->
                val cawsProject = CawsProject(spaceName, project.name())
                val result = client.listDevEnvironmentsPaginator { it.projectName(project.name()).spaceName(spaceName) }.items()
                    .map { it.toWorkspace(WorkspaceIdentifier(cawsProject, it.id())) }
                    .filterNot { it.status == DevEnvironmentStatus.DELETED || it.status == DevEnvironmentStatus.DELETING }
                    .onEach {
                        if (it.status.inProgress()) {
                            initialDirty.add(it.identifier)
                        }
                    }
                    .associateBy { it.identifier }
                    .toMutableMap()
                workspaces.put(cawsProject, result)

                val repos = client.listSourceRepositoriesPaginator {
                    it.spaceName(spaceName)
                    it.projectName(project.name())
                }.items()
                    .map { it.toSourceRepository() }
                repositories[cawsProject] = repos
            }

        lock.write {
            if (initialDirty.isNotEmpty()) {
                dirtyWorkspaces.addAll(initialDirty)
                scheduleUpdate()
            }
        }
    }

    private fun pollForUpdate() {
        val dirtyWorkspacesSnapshot = dirtyWorkspaces.toList()
        if (dirtyWorkspacesSnapshot.isEmpty()) return

        val updatedWorkspaces = mutableSetOf<Workspace>()
        dirtyWorkspacesSnapshot.forEach { dirtyWorkspace ->
            try {
                val ws = client.getDevEnvironment {
                    it.spaceName(dirtyWorkspace.project.space)
                    it.projectName(dirtyWorkspace.project.project)
                    it.id(dirtyWorkspace.id)
                }.toWorkspace(dirtyWorkspace)
                if (ws == getCachedWorkspace(dirtyWorkspace)) {
                    return@forEach
                }

                updatedWorkspaces.add(ws)
            } catch (e: Exception) {
                dirtyWorkspaces.remove(dirtyWorkspace)
            }
        }

        if (updatedWorkspaces.isNotEmpty()) {
            lock.write {
                updatedWorkspaces.forEach { ws ->
                    if (!ws.status.inProgress()) {
                        dirtyWorkspaces.remove(ws.identifier)
                    }

                    updateCachedWorkspace(ws)
                }
            }

            runInEdt(ModalityState.any()) {
                listeners.forEach { it.run() }
            }
        }

        if (dirtyWorkspaces.isNotEmpty()) {
            scheduleUpdate()
        }
    }

    private fun scheduleUpdate() {
        updateAlarm.addRequest(::pollForUpdate, POLL_INTERVAL_MS, false)
    }

    override fun workspaces() = lock.read {
        workspaces.mapValues { (_, value) ->
            value.values.toList()
        }
    }

    override fun codeRepos() = lock.read {
        repositories
    }

    override fun removeWorkspace(ws: Workspace) {
        lock.write {
            removeCachedWorkspace(ws)
        }
        listeners.forEach { it.run() }
    }

    fun markWorkspaceAsDirty(identifer: WorkspaceIdentifier) {
        if (CawsSpaceTracker.getInstance().lastSpaceName() != identifer.project.space) {
            return
        }
        dirtyWorkspaces.add(identifer)
        updateAlarm.cancelAllRequests()
        pollForUpdate()
    }

    override fun markWorkspaceAsDirty(ws: Workspace) {
        markWorkspaceAsDirty(ws.identifier)
    }

    override fun addChangeListener(listener: Runnable) {
        listeners.add(listener)
    }

    override fun environmentStarted(context: WorkspaceListStateChangeContext) {
        markWorkspaceAsDirty(context.wsId)
    }

    override fun dispose() {
        listeners.clear()
        lock.write {
            workspaces.clear()
            repositories.clear()
        }
    }

    companion object {
        fun createWorkspaceDataRetriever(client: CodeCatalystClient, spaceName: String): WorkspaceDataRetriever =
            WorkspaceDataRetriever(client, spaceName).also {
                it.loadData()
            }

        private val POLL_INTERVAL_MS = Duration.ofSeconds(5).toMillis().toInt()
    }
}


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.actionSystem.ActionToolbar
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.impl.ActionButton
import com.intellij.openapi.actionSystem.impl.IdeaActionButtonLook
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.project.DefaultProjectFactory
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.rd.util.launchChildOnUi
import com.intellij.openapi.rd.util.withModalProgressContext
import com.intellij.openapi.ui.FrameWrapper
import com.intellij.openapi.ui.InputValidator
import com.intellij.openapi.ui.Messages
import com.intellij.openapi.util.Disposer
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.labels.LinkLabel
import com.intellij.ui.components.panels.NonOpaquePanel
import com.intellij.util.text.DateFormatUtil
import com.intellij.util.text.nullize
import com.intellij.util.ui.AsyncProcessIcon
import com.intellij.util.ui.GridBag
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.jetbrains.gateway.api.GatewayUI
import icons.AwsGatewayIcons
import kotlinx.coroutines.launch
import org.jetbrains.plugins.terminal.LocalTerminalDirectRunner
import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient
import software.amazon.awssdk.services.codecatalyst.model.DevEnvironmentStatus
import software.aws.toolkits.core.utils.error
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.jetbrains.AwsToolkit
import software.aws.toolkits.jetbrains.core.coroutines.applicationCoroutineScope
import software.aws.toolkits.jetbrains.core.utils.buildMap
import software.aws.toolkits.jetbrains.gateway.CawsConnectionParameters
import software.aws.toolkits.jetbrains.gateway.Workspace
import software.aws.toolkits.jetbrains.gateway.connection.ThinClientTrackerService
import software.aws.toolkits.jetbrains.gateway.connection.caws.CawsCommandExecutor
import software.aws.toolkits.jetbrains.gateway.inProgress
import software.aws.toolkits.jetbrains.services.caws.isSubscriptionFreeTier
import software.aws.toolkits.resources.message
import java.awt.Color
import java.awt.FlowLayout
import java.awt.Graphics
import java.awt.GridBagLayout
import java.awt.Rectangle

class WorkspaceDetails(ws: Workspace, workspaces: WorkspaceList, cawsClient: CodeCatalystClient, disposable: Disposable) : NonOpaquePanel() {
    init {
        layout = GridBagLayout()

        val insetSize = 2

        val gbc = GridBag().apply {
            defaultFill = GridBag.HORIZONTAL
            defaultInsets = JBUI.insets(0, insetSize)
        }

        val statusIconLabel = when {
            ws.status == DevEnvironmentStatus.RUNNING -> JBLabel(AwsGatewayIcons.GATEWAY_RUNNING)
            ws.status.inProgress() -> AsyncProcessIcon(ws.status.name).also {
                Disposer.register(disposable, it)
            }
            ws.status == DevEnvironmentStatus.FAILED -> JBLabel(AllIcons.General.Error)
            else -> JBLabel(AwsGatewayIcons.GATEWAY_STOPPED)
        }

        statusIconLabel.toolTipText = if (!ws.statusReason.isNullOrBlank()) "${ws.status.name}: ${ws.statusReason}" else ws.status.name

        val (ideIcon, ideToolTip) = ws.platformProduct?.let {
            it.icon to it.ideName
        } ?: AllIcons.RunConfigurations.TestUnknown to ws.ides.toString()
        val ideIconLabel = JBLabel(ideIcon)
        ideIconLabel.toolTipText = ideToolTip

        add(statusIconLabel, gbc.next().anchor(GridBag.WEST))
        add(ideIconLabel, gbc.next().anchor(GridBag.WEST).insets(JBUI.insetsRight(insetSize)))

        val wsBranchText = ws.branch?.let { message("caws.branch_title", ws.branch) } ?: ws.identifier.id
        if (ws.isCompatible) {
            add(ActionLinkColoredSearchComponent(wsBranchText) { connectToWs(ws, it) }, gbc.next().anchor(GridBag.WEST))
        } else {
            add(JBLabel("$wsBranchText ${message("caws.workspace.incompatible")}"), gbc.next().anchor(GridBag.WEST))
        }

        val labels = LabelPanel(ws.alias?.let { listOf(it) } ?: emptyList())

        add(
            labels,
            gbc.next().anchor(GridBag.CENTER).fillCellHorizontally().weightx(1.0).apply {
                foreground = JBUI.CurrentTheme.ActionsList.MNEMONIC_FOREGROUND
            }
        )

        val buttonPanel = NonOpaquePanel(FlowLayout(FlowLayout.RIGHT, 1, 1))
        buttonPanel.add(
            JBLabel(UIUtil.ComponentStyle.SMALL).apply {
                foreground = JBUI.CurrentTheme.ActionsList.MNEMONIC_FOREGROUND
                text = message("caws.workspace.details.last_used", DateFormatUtil.formatPrettyDate(ws.lastUpdated.toEpochMilli()))
                toolTipText = DateFormatUtil.formatDateTime(ws.lastUpdated.toEpochMilli())
            }
        )
        if (ws.status != DevEnvironmentStatus.STOPPING && ws.status != DevEnvironmentStatus.STOPPED) {
            buttonPanel.add(createActionButton(PauseAction(ws, workspaces, cawsClient)))
        }
        buttonPanel.add(createActionButton(TerminateAction(ws, workspaces, cawsClient)))

        if (ws.status == DevEnvironmentStatus.RUNNING) {
            buttonPanel.add(createActionButton(ConfigureAction(ws, workspaces, cawsClient)))

            if (AwsToolkit.isDeveloperMode()) {
                buttonPanel.add(createActionButton(ShellAction(ws, workspaces, cawsClient)))
            }
        }

        add(buttonPanel, gbc.next().anchor(GridBag.EAST).insets(JBUI.insetsLeft(insetSize)))
    }

    @Suppress("UNUSED_PARAMETER")
    private fun onCawsProjectLinkClicked(label: LinkLabel<Any?>, ignored: Any?) {
    }

    private fun createActionButton(action: AnAction): ActionButton =
        ActionButton(action, action.templatePresentation.clone(), ActionPlaces.UNKNOWN, ActionToolbar.NAVBAR_MINIMUM_BUTTON_SIZE).apply {
            setLook(object : IdeaActionButtonLook() {
                override fun paintLookBorder(g: Graphics, rect: Rectangle, color: Color) {}
            })
        }
}

private fun connectToWs(ws: Workspace, e: AnActionEvent) {
    val connectionParameters = mutableMapOf(
        CawsConnectionParameters.CAWS_SPACE to ws.identifier.project.space,
        CawsConnectionParameters.CAWS_PROJECT to ws.identifier.project.project,
        CawsConnectionParameters.CAWS_ENV_ID to ws.identifier.id
    ) + buildMap {
        if (ws.repo != null) {
            put(CawsConnectionParameters.CAWS_GIT_REPO_NAME, ws.repo)
        }
    }

    GatewayUI.getInstance().connect(connectionParameters)
}

class PauseAction(private val ws: Workspace, private val workspaceList: WorkspaceList, private val cawsClient: CodeCatalystClient) : DumbAwareAction(
    {
        message("caws.pause_action")
    },
    AllIcons.Actions.Pause
) {
    override fun actionPerformed(e: AnActionEvent) {
        val result = Messages.showYesNoCancelDialog(message("caws.pause_warning"), message("caws.pause_warning_title"), null)
        if (result != Messages.YES) return
        ApplicationManager.getApplication().executeOnPooledThread {
            try {
                ThinClientTrackerService.getInstance().terminateIfRunning(ws.identifier.id)
                cawsClient.stopDevEnvironment {
                    it.spaceName(ws.identifier.project.space)
                    it.projectName(ws.identifier.project.project)
                    it.id(ws.identifier.id)
                }
                workspaceList.markWorkspaceAsDirty(ws)
            } catch (e: Exception) {
                val message = message("caws.pause_workspace_failed")
                getLogger<PauseAction>().error(e) { message }
                runInEdt {
                    Messages.showErrorDialog(e.message ?: message("general.unknown_error"), message)
                }
            }
        }
    }
}

class TerminateAction(private val ws: Workspace, private val workspaceList: WorkspaceList, private val cawsClient: CodeCatalystClient) : DumbAwareAction(
    {
        message("caws.delete_workspace")
    },
    AllIcons.Actions.Cancel
) {
    override fun actionPerformed(e: AnActionEvent) {
        val result = Messages.showYesNoDialog(message("caws.delete_workspace_warning"), message("caws.delete_workspace_warning_title"), null)
        if (result != Messages.YES) return
        ApplicationManager.getApplication().executeOnPooledThread {
            try {
                cawsClient.deleteDevEnvironment {
                    it.spaceName(ws.identifier.project.space)
                    it.projectName(ws.identifier.project.project)
                    it.id(ws.identifier.id)
                }
                runInEdt {
                    // TODO: UX question..should we instantly hide this, or let it go into spinning state?
                    workspaceList.removeWorkspace(ws)
                }
            } catch (e: Exception) {
                val message = message("caws.delete_failed")
                getLogger<TerminateAction>().error(e) { message }
                runInEdt {
                    Messages.showErrorDialog(e.message ?: message("general.unknown_error"), message)
                }
            }
        }
    }
}

class ShellAction(private val ws: Workspace, private val workspaceList: WorkspaceList, private val cawsClient: CodeCatalystClient) : DumbAwareAction(
    {
        "Shell"
    }
) {
    override fun actionPerformed(e: AnActionEvent) {
        val portsToForward = Messages.showInputDialog("Comma-separated ports to forward", ws.toString(), null, null, csvIntValidator).splitIntsByComma()

        val disposable = Disposer.newDisposable()

        val project = DefaultProjectFactory.getInstance().defaultProject
        val widget = object : LocalTerminalDirectRunner(project) {
            override fun getInitialCommand(envs: MutableMap<String, String>): List<String> {
                val command = CawsCommandExecutor(cawsClient, ws.identifier.id, ws.identifier.project.space, ws.identifier.project.project)
                    .buildSshCommand {
                        portsToForward.forEach { port ->
                            it.localPortForward(port, port, noShell = false)
                        }
                    }
                return command.getCommandLineList(null)
            }
        }.createTerminalWidget(disposable, null, true)

        val dialog = FrameWrapper(null, null, title = ws.toString(), component = widget)
        Disposer.register(dialog, disposable)
        dialog.show()
    }

    companion object {
        private fun String?.splitIntsByComma(): List<Int> = nullize(nullizeSpaces = true)?.split(',')?.map { it.toInt() } ?: emptyList()
        private val csvIntValidator = object : InputValidator {
            override fun checkInput(inputString: String) = runCatching { inputString.splitIntsByComma() }.isSuccess
            override fun canClose(inputString: String) = runCatching { inputString.splitIntsByComma() }.isSuccess
        }
    }
}

class ConfigureAction(private val ws: Workspace, private val workspaceList: WorkspaceList, private val cawsClient: CodeCatalystClient) : DumbAwareAction(
    {
        message("caws.configure_workspace")
    },
    AllIcons.General.GearPlain
) {
    override fun actionPerformed(e: AnActionEvent) {
        applicationCoroutineScope().launch {
            val isFreeTier = withModalProgressContext(message("loading_resource.loading")) {
                val envStatus = cawsClient.getDevEnvironment {
                    val (cawsProject, envId) = ws.identifier
                    val (space, project) = cawsProject

                    it.spaceName(space)
                    it.projectName(project)
                    it.id(envId)
                }

                if (envStatus.status() != DevEnvironmentStatus.RUNNING) {
                    error(message("caws.configure_workspace_not_running"))
                }

                isSubscriptionFreeTier(cawsClient, ws.identifier.project.space)
            }

            launchChildOnUi {
                WorkspaceConfigurationDialog.buildDialog(cawsClient, isFreeTier, ws, workspaceList)
                    .showAndGet()
            }.join()
        }.invokeOnCompletion { err ->
            if (err == null) {
                return@invokeOnCompletion
            }

            val message = message("caws.configure_workspace_failed")
            getLogger<ConfigureAction>().error(err) { message }
            runInEdt {
                Messages.showErrorDialog(err.message ?: message("general.unknown_error"), message("caws.configure_workspace_failed"))
            }
        }
    }
}


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

import com.intellij.icons.AllIcons
import com.intellij.ide.browsers.BrowserLauncher
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.actionSystem.ActionToolbar
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DefaultActionGroup
import com.intellij.openapi.actionSystem.impl.ActionButton
import com.intellij.openapi.actionSystem.impl.IdeaActionButtonLook
import com.intellij.openapi.rd.createNestedDisposable
import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager
import com.intellij.ui.SeparatorComponent
import com.intellij.ui.SeparatorOrientation
import com.intellij.ui.components.BrowserLink
import com.intellij.ui.components.panels.NonOpaquePanel
import com.intellij.util.ui.GridBag
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import com.jetbrains.rd.util.lifetime.Lifetime
import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient
import software.aws.toolkits.jetbrains.gateway.CawsSettings
import software.aws.toolkits.jetbrains.gateway.SourceRepository
import software.aws.toolkits.jetbrains.gateway.Workspace
import software.aws.toolkits.jetbrains.gateway.cawsWizard
import software.aws.toolkits.jetbrains.services.caws.CawsEndpoints
import software.aws.toolkits.jetbrains.services.caws.CawsProject
import software.aws.toolkits.resources.message
import java.awt.Color
import java.awt.Component
import java.awt.Graphics
import java.awt.GridBagLayout
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JPanel

class WorkspaceGroupsPanel(
    private val workspaces: WorkspaceList,
    private val cawsClient: CodeCatalystClient,
    private val setContentCallback: (Component) -> Unit,
    private val lifetime: Lifetime
) : NonOpaquePanel(GridBagLayout()) {
    private val disposable = lifetime.createNestedDisposable()
    private data class WorkspaceGroup(val repoName: String?, val subtitle: String?, val workspaces: List<Workspace>)

    init {
        addWorkspaces()

        workspaces.addChangeListener {
            removeAll()
            addWorkspaces()

            revalidate()
            repaint()
        }
    }

    private fun addWorkspaces() {
        val gbc = GridBag().apply {
            defaultAnchor = GridBag.NORTH
            defaultFill = GridBag.HORIZONTAL
            defaultWeightX = 1.0
            defaultInsets = JBUI.insets(GAP_BETWEEN_GROUPS, PANEL_SIDE_INSET)
        }

        workspaces.workspaces().entries.forEachIndexed { index, (project, workspaces) ->
            add(createProjectGroup(project, workspaces, this.workspaces.codeRepos()[project] ?: emptyList()), gbc.nextLine().setColumn(0).coverLine())

            if (index < workspaces.size - 1) {
                add(createSeparator(), gbc.nextLine().setColumn(0).coverLine().insets(JBUI.emptyInsets()))
            }
        }

        // Add a "spacer" to push everything else up
        add(NonOpaquePanel(), gbc.nextLine().coverLine().weighty(1.0).fillCellVertically())
    }

    private fun groupByRepo(workspaces: List<Workspace>, allRepos: List<SourceRepository>): List<WorkspaceGroup> {
        val repoGroups = mutableMapOf<String?, MutableList<Workspace>>()

        workspaces.forEach {
            val repo = it.repo
            repoGroups.computeIfAbsent(repo) { mutableListOf() }.add(it)
        }

        allRepos.forEach {
            repoGroups.computeIfAbsent(it.name) { mutableListOf() }
        }

        return repoGroups.map { WorkspaceGroup(it.key?.substringAfterLast("/")?.removeSuffix(".git"), it.key, it.value) }
    }

    private fun createSeparator() = SeparatorComponent(WelcomeScreenUIManager.getSeparatorColor(), SeparatorOrientation.HORIZONTAL)

    private fun createProjectGroup(project: CawsProject, workspaces: List<Workspace>, allRepos: List<SourceRepository>): JComponent {
        val panel = NonOpaquePanel(GridBagLayout())
        val gbc = GridBag().apply {
            defaultWeightX = 1.0
        }

        gbc.nextLine()

        val actions = DefaultActionGroup(null, true).apply {
            templatePresentation.putClientProperty(ActionButton.HIDE_DROPDOWN_ICON, true)
            templatePresentation.icon = AllIcons.Actions.More
            addAction(openUrlAction(message("caws.view.projects_web"), CawsEndpoints.ConsoleFactory.projectHome(project)))
            addAction(openUrlAction(message("caws.view.workspaces_web"), CawsEndpoints.ConsoleFactory.devWorkspaceHome(project)))
        }

        panel.add(
            BorderLayoutPanel().apply {
                isOpaque = false

                addToCenter(
                    SearchableLabel(project.project).apply {
                        font = JBFont.h3().asBold()
                    }
                )

                addToRight(
                    ActionButton(
                        actions,
                        actions.templatePresentation.clone(),
                        ActionPlaces.UNKNOWN,
                        ActionToolbar.DEFAULT_MINIMUM_BUTTON_SIZE
                    ).apply {
                        setLook(MORE_LOOK)
                    }
                )
            },
            gbc.next().anchor(GridBag.WEST)
        )

        val projectGroups = groupByRepo(workspaces, allRepos).sortedWith(compareBy(Comparator.nullsLast<String?>(Comparator.naturalOrder())) { it.repoName })
        projectGroups.forEachIndexed { index, group ->
            panel.createWorkspaceGroup(project, group, gbc)

            if (index < projectGroups.size - 1) {
                add(createSeparator(), gbc.nextLine().insets(JBUI.emptyInsets()))
            }
        }

        if (workspaces.isEmpty() && allRepos.isEmpty()) {
            gbc.nextLine()
            panel.add(
                BrowserLink(message("caws.add_repository"), CawsEndpoints.ConsoleFactory.repositoryHome(project)),
                gbc.next().anchor(GridBag.WEST).insetTop(10)
            )
        }

        return panel
    }

    private fun JPanel.createWorkspaceGroup(project: CawsProject, workspaceGroup: WorkspaceGroup, gbc: GridBag) {
        gbc.nextLine()

        val label = workspaceGroup.subtitle ?: message("caws.no_repo")
        add(
            SearchableLabel(label).apply {
                font = JBFont.label()
            },
            gbc.next().anchor(GridBag.WEST).insetTop(10)
        )
        add(
            JButton(message("caws.add_workspace"), AllIcons.General.Add).apply {
                addActionListener { _ ->
                    setContentCallback(
                        cawsWizard(
                            lifetime,
                            CawsSettings().also {
                                it.project = project
                                it.linkedRepoName = workspaceGroup.repoName
                            }
                        )
                    )
                }
                font = JBUI.Fonts.toolbarFont()
                putClientProperty("ActionToolbar.smallVariant", true)
            },
            gbc.next().anchor(GridBag.EAST).insetTop(10).coverLine()
        )

        createWorkspaceDetailsRow(workspaceGroup.workspaces, gbc)
    }

    private fun JPanel.createWorkspaceDetailsRow(wsDetails: List<Workspace>, gbc: GridBag) {
        wsDetails.forEachIndexed { _, ws ->
            // Change the defaults
            gbc.defaultWeightX = 0.0

            add(WorkspaceDetails(ws, workspaces, cawsClient, disposable), gbc.nextLine().fillCellHorizontally().coverLine().insetTop(10))
        }
    }

    private companion object {
        private const val GAP_BETWEEN_GROUPS = 20
        private val MORE_LOOK = object : IdeaActionButtonLook() {
            override fun paintBorder(g: Graphics?, component: JComponent?, state: Int) {}

            override fun paintBorder(g: Graphics?, component: JComponent?, color: Color?) {}
        }

        private fun openUrlAction(text: String, url: String) =
            object : AnAction({ text }) {
                override fun actionPerformed(p0: AnActionEvent) {
                    BrowserLauncher.instance.browse(url)
                }
            }
    }
}


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

import software.aws.toolkits.jetbrains.gateway.SourceRepository
import software.aws.toolkits.jetbrains.gateway.Workspace
import software.aws.toolkits.jetbrains.services.caws.CawsProject

interface WorkspaceList {
    fun workspaces(): Map<CawsProject, List<Workspace>>
    fun codeRepos(): Map<CawsProject, List<SourceRepository>>
    fun removeWorkspace(ws: Workspace)
    fun markWorkspaceAsDirty(ws: Workspace)

    fun addChangeListener(listener: Runnable)
}


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

import com.intellij.icons.AllIcons
import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager
import com.intellij.ui.ScrollPaneFactory
import com.intellij.ui.SearchTextField
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import com.jetbrains.rd.util.lifetime.Lifetime
import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient
import software.aws.toolkits.resources.message
import java.awt.Component
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.ScrollPaneConstants

class WorkspaceListPanel(
    dataRetriever: WorkspaceDataRetriever,
    private val client: CodeCatalystClient,
    private val setContentCallback: (Component) -> Unit,
    private val refreshCallback: () -> Unit,
    private val lifetime: Lifetime
) : BorderLayoutPanel() {
    private val searchField = createSearchField()
    private val compatibleFilter = FilteringWorkspaceList(dataRetriever) { it.isCompatible }

    init {
        isOpaque = false

        setupWsPanels()

        recursivelySetBackground(this)
    }

    private fun setupWsPanels() {
        addToTop(createFilterBar())
        val wsPanel = WorkspaceGroupsPanel(compatibleFilter, client, setContentCallback, lifetime).also { WorkspaceSpeedSearch(searchField, it) }
        addToCenter(
            ScrollPaneFactory.createScrollPane(wsPanel, true).apply {
                horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
            }
        )
    }

    private fun createFilterBar(): JComponent {
        val filterTabs = TabLikeButtons()

        filterTabs.add(
            JButton(AllIcons.Actions.Refresh).apply {
                isOpaque = false
                border = JBUI.Borders.empty()
                addActionListener {
                    refreshCallback()
                }
            }
        )

        val rightPanel = BorderLayoutPanel().apply {
            isOpaque = false

            addToRight(filterTabs)
        }

        return BorderLayoutPanel().apply {
            isOpaque = false
            border = BottomLineBorder(
                WelcomeScreenUIManager.getSeparatorColor(),
                JBUI.insets(0, PANEL_SIDE_INSET, 1, PANEL_SIDE_INSET)
            )

            addToCenter(searchField)
            addToRight(rightPanel)
        }
    }

    private fun createSearchField(): SearchTextField {
        val projectSearch = SearchTextField(false)
        projectSearch.isOpaque = false
        projectSearch.border = JBUI.Borders.empty()

        projectSearch.textEditor.apply {
            isOpaque = false
            border = JBUI.Borders.empty()
            emptyText.text = message("caws.workspace.list_panel_search_empty_text")
            accessibleContext.accessibleName = message("caws.workspace.list_panel_search_empty_text")
        }

        return projectSearch
    }
}


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

import com.intellij.util.messages.Topic
import software.aws.toolkits.jetbrains.gateway.WorkspaceIdentifier

object WorkspaceNotifications {
    val TOPIC = Topic.create("Dev Environment list state change", WorkspaceListStateChangeListener::class.java)
}

data class WorkspaceListStateChangeContext(
    val wsId: WorkspaceIdentifier
)

interface WorkspaceListStateChangeListener {
    fun environmentStarted(context: WorkspaceListStateChangeContext)
}


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

import com.intellij.openapi.application.runInEdt
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.SearchTextField
import com.intellij.ui.SpeedSearchBase
import java.awt.Container
import javax.swing.event.DocumentEvent

class WorkspaceSpeedSearch(private val searchTextField: SearchTextField, panel: WorkspaceGroupsPanel) : SpeedSearchBase<WorkspaceGroupsPanel>(panel) {
    private val components by lazy {
        fun collectSpeedSearchProviders(container: Container): List<WorkspaceSpeedSearchProvider> = container.components.flatMap { component ->
            if (component is WorkspaceSpeedSearchProvider) {
                return@flatMap listOf(component).apply { if (component is Container) this + collectSpeedSearchProviders(component) }
            }

            if (component is Container) {
                return@flatMap collectSpeedSearchProviders(component)
            }

            return@flatMap emptyList()
        }

        collectSpeedSearchProviders(component)
    }

    override fun getSearchField() = searchTextField.textEditor

    init {
        installSupplyTo(component)
        searchTextField.addDocumentListener(object : DocumentAdapter() {
            override fun textChanged(p0: DocumentEvent) {
                refreshSelection()
                val element = findElement(searchTextField.text)
                runInEdt {
                    if (element == null) {
                        searchTextField.foreground = ERROR_FOREGROUND_COLOR
                    } else {
                        searchTextField.foreground = FOREGROUND_COLOR
                    }
                }

                selectElement(element, searchTextField.text)
                refreshSelection()
            }
        })
    }

    override fun refreshSelection() {
        runInEdt {
            components.forEach { it.highlight(component) }
        }
    }

    override fun showPopup(searchText: String?) {
        // noop
    }

    override fun isPopupActive(): Boolean =
        // true so that speedsearch is always on
        true

    override fun getElementCount(): Int =
        components.size

    override fun getElementAt(viewIndex: Int): Any =
        components[viewIndex]

    override fun getSelectedIndex(): Int =
        // "selection" is not supported
        -1

    override fun getElementText(element: Any?): String? = (element as? WorkspaceSpeedSearchProvider)?.getElementText()

    override fun selectElement(element: Any?, selectedText: String) {
        // noop
    }
}


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

package software.aws.toolkits.jetbrains.gateway.welcomescreen

import javax.swing.JComponent

interface WorkspaceSpeedSearchProvider {
    fun highlight(speedSearchEnabledComponent: JComponent)
    fun getElementText(): String?
}


// 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.jetbrains.gateway.api.DefaultCustomConnectionFrameComponentProvider
import com.jetbrains.gateway.api.GatewayConnectionHandle
import com.jetbrains.rd.util.lifetime.Lifetime
import software.aws.toolkits.resources.message
import javax.swing.JComponent

class CawsGatewayConnectionHandle(
    lifetime: Lifetime,
    private val envId: String,
    private val componentProvider: (GatewayConnectionHandle) -> JComponent
) : GatewayConnectionHandle(lifetime) {
    // framework does not call componentProvider in test mode
    private val component = componentProvider(this)

    override fun customComponentProvider() = DefaultCustomConnectionFrameComponentProvider(getTitle()) {
        component
    }

    override fun getTitle() = message("caws.connection_progress_panel_title", envId)

    override fun hideToTrayOnStart() = true
}


// 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.jetbrains.gateway.api.DefaultCustomConnectionFrameComponentProvider
import com.jetbrains.gateway.api.GatewayConnectionHandle
import com.jetbrains.rd.util.lifetime.Lifetime
import software.aws.toolkits.resources.message
import javax.swing.JComponent

class CawsGatewayConnectionHandle(
    lifetime: Lifetime,
    private val envId: String,
    private val componentProvider: (GatewayConnectionHandle) -> JComponent
) : GatewayConnectionHandle(lifetime) {
    // framework does not call componentProvider in test mode
    private val component = componentProvider(this)

    override fun customComponentProvider(lifetime: Lifetime) = DefaultCustomConnectionFrameComponentProvider(getTitle()) {
        component
    }

    override fun getTitle() = message("caws.connection_progress_panel_title", envId)

    override fun hideToTrayOnStart() = true
}


// 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.openapi.application.ApplicationManager
import com.intellij.testFramework.ApplicationRule
import com.intellij.testFramework.registerServiceInstance
import org.assertj.core.api.Assertions.assertThat
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.experimental.runners.Enclosed
import org.junit.runner.RunWith
import org.junit.runners.Parameterized

@RunWith(Enclosed::class)
class GitUtilsTest {
    @Ignore
    companion object {
        private val baseCases = listOf(
            "https://host",
            "https://user:pass@host",
            "https://user:pass@host:123",
            // these imply ssh protocol
            "host",
            "user@host",
            // path is expected after the colon
            "host:",
            "user@host:"
        )
    }

    @RunWith(Parameterized::class)
    class ExtractRepoNameHostOnly(private val case: String) {
        companion object {
            @Parameterized.Parameters(name = "{0}")
            @JvmStatic
            fun parameters(): Collection<String> = baseCases.flatMap {
                listOf(
                    it,
                    "$it/",
                    "$it/////",
                    "$it/.git",
                    "$it/.git/",
                    "$it/.git/////"
                )
            }
        }

        @Test
        fun `extracts repo name when only host available`() {
            assertThat(extractRepoName(normalizeRepoUrl(case))).isEqualTo("host")
        }
    }

    @RunWith(Parameterized::class)
    class ExtractRepoNameWithSuffix(private val case: String) {
        companion object {
            @Parameterized.Parameters(name = "{0}")
            @JvmStatic
            fun parameters(): Collection<String> = baseCases.flatMap {
                listOf(
                    "$it/foo",
                    "$it/foo////",
                    "$it/foo/.git",
                    "$it/foo/.git///////",
                    "$it/foo.git/",
                    "$it/foo.git/////",
                    "$it/path/to/foo",
                    "$it/path/to/foo/////",
                    "$it/path/to/foo.git",
                    "$it/path/to/foo.git/////",
                    "$it/path/to/foo/.git",
                    "$it/path/to/foo/.git/////"
                )
            } + listOf(
                "host:rel/path/to/foo",
                "host:rel/path/to/foo/////",
                "host:rel/path/to/foo.git",
                "host:rel/path/to/foo.git/////",
                "host:rel/path/to/foo/.git",
                "host:rel/path/to/foo/.git/////",
                "user@host:rel/path/to/foo",
                "user@host:rel/path/to/foo/////",
                "user@host:rel/path/to/foo.git",
                "user@host:rel/path/to/foo.git/////",
                "user@host:rel/path/to/foo/.git",
                "user@host:rel/path/to/foo/.git/////"
            )
        }

        @Test
        fun `extracts repo name with slashes and suffix`() {
            assertThat(extractRepoName(normalizeRepoUrl(case))).isEqualTo("foo")
        }
    }

    class NormalizeRepoUrl {
        @Test
        fun `normalizes scheme correctly from url`() {
            val sut = { it: String -> normalizeRepoUrl(it).scheme }
            assertThat(sut("host.xz:path/to/repo.git/")).isEqualTo("ssh")
            assertThat(sut("user@host.xz:path/to/repo.git/")).isEqualTo("ssh")
            assertThat(sut("ssh://host.xz/path/to/repo.git/")).isEqualTo("ssh")
            assertThat(sut("ssh://user@host.xz:123/path/to/repo.git/")).isEqualTo("ssh")
            assertThat(sut("git://user@host.xz:123/path/to/repo.git/")).isEqualTo("git")
            assertThat(sut("file:///path/to/repo.git/")).isEqualTo("file")
        }

        @Test
        fun `normalizes authority correctly from url`() {
            val sut = { it: String -> normalizeRepoUrl(it).authority }
            assertThat(sut("host.xz:path/to/repo.git/")).isEqualTo("host.xz")
            assertThat(sut("user@host.xz:path/to/repo.git/")).isEqualTo("user@host.xz")
            assertThat(sut("ssh://host.xz/path/to/repo.git/")).isEqualTo("host.xz")
            assertThat(sut("ssh://user@host.xz:123/path/to/repo.git/")).isEqualTo("user@host.xz:123")
            assertThat(sut("file:///path/to/repo.git/")).isNull()
        }
    }

    class PrimeSSHAgentCommand {
        @Rule
        @JvmField
        val applicationRule = ApplicationRule()

        @Test
        fun `builds command correctly`() {
            ApplicationManager.getApplication().registerServiceInstance(SshAgentService::class.java, SshAgentService())

            val sut = { it: String -> buildAgentPrimeCommand(normalizeRepoUrl(it))?.commandLineString }
            assertThat(sut("host")).isEqualTo("ssh -o AddKeysToAgent=yes -T host")
            assertThat(sut("host.xz:path/to/repo.git/")).endsWith("host.xz")
            assertThat(sut("user@host.xz:path/to/repo.git/")).endsWith("user@host.xz")
            assertThat(sut("ssh://host.xz/path/to/repo.git/")).endsWith("host.xz")
            assertThat(sut("ssh://user@host.xz:123/path/to/repo.git/")).endsWith("user@host.xz -p 123")
            assertThat(sut("git://user@host.xz:123/path/to/repo.git/")).isNull()
        }
    }

    class StartSSHAgentCommand {
        @Test
        fun `noop if agent is available`() {
            assertThat(SshAgentService.startSshAgentIfRequired("agent")).isEqualTo(ExistingSshAgent("agent"))
        }

        @Test
        fun `parses agent pid`() {
            assertThat(ProcessBasedSshAgent.fromStdout(AGENT_OUTPUT).pid).isEqualTo(53580)
        }

        @Test
        fun `parses agent socket`() {
            assertThat(ProcessBasedSshAgent.fromStdout(AGENT_OUTPUT).socket).isEqualTo("/var/folders/5s/asdfafds/T//ssh-SBarNVC4jwLg/agent.53580")
        }

        companion object {
            private val AGENT_OUTPUT =
                """
                SSH_AUTH_SOCK=/var/folders/5s/asdfafds/T//ssh-SBarNVC4jwLg/agent.53580; export SSH_AUTH_SOCK;
                echo Agent pid 53580;
                """.trimIndent()
        }
    }
}


// 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.util.ExecUtil
import com.intellij.testFramework.ApplicationRule
import com.intellij.util.io.readBytes
import org.apache.sshd.common.session.Session
import org.apache.sshd.scp.common.ScpTransferEventListener
import org.apache.sshd.server.session.ServerSession
import org.apache.sshd.sftp.server.Handle
import org.apache.sshd.sftp.server.SftpEventListener
import org.assertj.core.api.Assertions.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import software.aws.toolkits.core.utils.test.notNull
import java.nio.file.Path
import java.nio.file.attribute.PosixFilePermission
import java.util.UUID

class ScpCommandLineTest {
    @Rule
    @JvmField
    val applicationRule = ApplicationRule()

    @Rule
    @JvmField
    val tempFolder = TemporaryFolder()

    @Rule
    @JvmField
    val sshServer = SshServerRule(tempFolder)

    @Test
    fun `build command`() {
        val sut = ScpCommandLine(
            "localhost",
            "/path",
            recursive = true,
            port = 12321
        )

        sut.addLocalPaths("local1", "local2")

        assertThat(sut.constructCommandLine().commandLineString).endsWith("-P 12321 local1 local2 localhost:/path")
    }

    @Test
    fun `copies 1 file`() {
        val file = newFile()

        val sut = ScpCommandLine(
            "localhost",
            tempFolder.newFolder().absolutePath,
            recursive = false,
            port = sshServer.server.port
        )
            .addLocalPaths(file)

        val paths = sut.executeScpTest()

        assertThat(paths.size).isEqualTo(1)
        assertThat(paths.first()!!.readBytes()).isEqualTo(file.readBytes())
    }

    @Test
    fun `copies 2 files`() {
        val file1 = newFile()
        val file2 = newFile()

        val sut = ScpCommandLine(
            "localhost",
            tempFolder.newFolder().absolutePath,
            recursive = false,
            port = sshServer.server.port
        )
            .addLocalPaths(file1, file2)

        val paths = sut.executeScpTest()

        assertThat(paths.size).isEqualTo(2)
        assertThat(paths.first()!!.readBytes()).isEqualTo(file1.readBytes())
        assertThat(paths.last()!!.readBytes()).isEqualTo(file2.readBytes())
    }

    @Test
    fun `copies directory recursively`() {
        val directory = tempFolder.newFolder()
        val uuid = UUID.randomUUID().toString()
        directory.resolve(uuid).createNewFile()

        val destination = tempFolder.newFolder()
        val sut = ScpCommandLine(
            "localhost",
            destination.toString(),
            recursive = true,
            port = sshServer.server.port
        )
            .addLocalPaths(directory.toPath())

        val paths = sut.executeScpTest()

        assertThat(paths).singleElement().notNull.satisfies {
            assertThat(it.fileName.toString()).isEqualTo(uuid)
            // is nested
            assertThat(it.parent.fileName.toString()).isEqualTo(directory.name)
        }
    }

    private fun ScpCommandLine.executeScpTest(): List<Path?> {
        val paths = mutableListOf<Path?>()
        attachScpListener(paths)

        ExecUtil.execAndGetOutput(
            this.knownHostsLocation(tempFolder.newFile().toPath())
                .constructCommandLine()
        )

        return paths
    }

    private fun newFile(): Path {
        val text = UUID.randomUUID()
        val file = tempFolder.newFile()
        file.writeText(text.toString())

        return file.toPath()
    }

    private fun attachScpListener(pathsCollector: MutableList<Path?>) {
        sshServer.scpCommandFactory.addEventListener(object : ScpTransferEventListener {
            override fun startFileEvent(
                session: Session?,
                op: ScpTransferEventListener.FileOperation?,
                file: Path?,
                length: Long,
                perms: MutableSet<PosixFilePermission>?
            ) {
                pathsCollector.add(file)
            }
        })

        sshServer.sftpSubsystemFactory.addSftpEventListener(object : SftpEventListener {
            override fun open(session: ServerSession?, remoteHandle: String?, localHandle: Handle?) {
                pathsCollector.add(localHandle?.file)
            }
        })
    }
}


// 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.github.tomakehurst.wiremock.client.WireMock.aResponse
import com.github.tomakehurst.wiremock.client.WireMock.any
import com.github.tomakehurst.wiremock.client.WireMock.anyUrl
import com.github.tomakehurst.wiremock.core.WireMockConfiguration
import com.github.tomakehurst.wiremock.junit.WireMockClassRule
import com.intellij.testFramework.ApplicationRule
import com.intellij.util.io.HttpRequests
import com.intellij.util.io.readText
import com.intellij.util.net.NetUtils
import com.jetbrains.rd.util.spinUntil
import net.schmizz.sshj.common.Buffer
import org.assertj.core.api.Assertions.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.util.Base64

class SshCommandLineTest {
    @Rule
    @JvmField
    val applicationRule = ApplicationRule()

    @Rule
    @JvmField
    val tempFolder = TemporaryFolder()

    @Rule
    @JvmField
    val sshServer = SshServerRule(tempFolder)

    @Rule
    @JvmField
    val wireMock = WireMockClassRule(
        WireMockConfiguration.wireMockConfig()
            .dynamicPort()
    )

    @Test
    fun `known hosts added`() {
        val hostFile = tempFolder.newFile().toPath()
        SshCommandLine("localhost", port = sshServer.server.port)
            .knownHostsLocation(hostFile)
            .executeAndGetOutput()

        // check that file contains rfc4253 key identifier
        val publicKeyBytes = Buffer.PlainBuffer().putPublicKey(sshServer.server.keyPairProvider.loadKeys(null).first().public).compactData
        assertThat(hostFile.readText().trim()).endsWith(Base64.getEncoder().encodeToString(publicKeyBytes))
    }

    @Test
    fun `local port forwarding`() {
        val wireMockPort = wireMock.port()
        val localPort = NetUtils.findAvailableSocketPort()
        wireMock.stubFor(any(anyUrl()).willReturn(aResponse().withBody("hello from wiremock:${wireMock.port()}").withStatus(200)))

        // redirect localhost:localPort to localhost:wireMockPort
        // ideally, we would bind wiremock to a different loopback address, but macOS doesn't allow this by default
        SshCommandLine("localhost", port = sshServer.server.port)
            .knownHostsLocation(tempFolder.newFile().toPath())
            .localPortForward(localPort, wireMockPort)
            .executeInBackground()

        // race between ssh background process and client
        spinUntil(5_000) { sshServer.clientIsConnected() }

        val response = HttpRequests.request("http://localhost:$localPort").readString()
        assertThat(response).contains(wireMockPort.toString())
    }

    @Test
    fun `execute command`() {
        val output = SshCommandLine("localhost", port = sshServer.server.port)
            .knownHostsLocation(tempFolder.newFile().toPath())
            // server rewrites to this to `ls`; we're testing that the command is runnable on the machine
            .addToRemoteCommand("some complicated command { wow }")
            .executeAndGetOutput()

        assertThat(output.exitCode).isEqualTo(0)
        assertThat(output.stdout).isNotBlank
    }
}


// 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.openapi.util.SystemInfo
import com.intellij.util.io.readText
import com.intellij.util.net.NetUtils
import org.apache.sshd.scp.server.ScpCommandFactory
import org.apache.sshd.server.SshServer
import org.apache.sshd.server.auth.UserAuthNoneFactory
import org.apache.sshd.server.forward.AcceptAllForwardingFilter
import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider
import org.apache.sshd.server.shell.ProcessShellCommandFactory
import org.apache.sshd.sftp.server.SftpSubsystemFactory
import org.assertj.core.api.Assertions.assertThat
import org.junit.rules.ExternalResource
import org.junit.rules.TemporaryFolder
import software.aws.toolkits.core.utils.exists
import java.nio.file.Paths

class SshServerRule(private val tempFolderRule: TemporaryFolder) : ExternalResource() {
    lateinit var server: SshServer
    lateinit var scpCommandFactory: ScpCommandFactory
    lateinit var sftpSubsystemFactory: SftpSubsystemFactory

    private var knownHosts: String? = null
    private val hostsFile = Paths.get(System.getProperty("user.home"), ".ssh", "known_hosts")

    override fun before() {
        if (hostsFile.exists()) {
            knownHosts = hostsFile.readText()
        }

        scpCommandFactory = ScpCommandFactory.Builder()
            .withDelegate { channel, _ ->
                val command = if (SystemInfo.isUnix) { "ls" } else { "cmd.exe /c dir" }
                ProcessShellCommandFactory.INSTANCE.createCommand(channel, command)
            }
            .build()

        sftpSubsystemFactory = SftpSubsystemFactory.Builder()
            .build()

        server = SshServer.setUpDefaultServer().also {
            it.port = NetUtils.findAvailableSocketPort()
            it.keyPairProvider = SimpleGeneratorHostKeyProvider(tempFolderRule.newFile().toPath())
            it.forwardingFilter = AcceptAllForwardingFilter()
            it.commandFactory = scpCommandFactory
            it.subsystemFactories = listOf(sftpSubsystemFactory)
        }

        server.start()
        // disable auth because tests blow up on windows due to failure to open /dev/tty on interactive auth
        server.sessionFactory.factoryManager.userAuthFactories = listOf(UserAuthNoneFactory.INSTANCE)
    }

    override fun after() {
        server.stop()

        val hostsFileModifiedError = { "Test erroneously pollutes the known_hosts file in the user home directory" }
        if (knownHosts == null) {
            assertThat(hostsFile.exists())
                .withFailMessage(hostsFileModifiedError)
                .isFalse
        } else {
            assertThat(hostsFile.readText())
                .withFailMessage(hostsFileModifiedError)
                .isEqualTo(knownHosts)
        }
    }

    fun clientIsConnected() = server.activeSessions.isNotEmpty() && server.activeSessions.all { it.isAuthenticated }
}


// 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.openapi.util.SystemInfo
import com.intellij.testFramework.ApplicationRule
import org.assertj.core.api.Assertions.assertThat
import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.Test
import software.aws.toolkits.core.region.AwsRegion

class SsmCommandLineTest {
    @Rule
    @JvmField
    val applicationRule = ApplicationRule()

    // match everything after `ssh` as long as it doesn't contain 'ProxyCommand'
    private val prefix = "^.*?s(sh|cp)(?:\\.exe)?(?:(?!ProxyCommand).)*?"
    private val sutFactory = SsmCommandLineFactory(
        "target",
        StartSessionResponse("session", "stream", "token"),
        AwsRegion.GLOBAL,
        overrideSsmPlugin = "session-manager-plugin"
    )

    @Test
    fun `test sshPrefix regex`() {
        // because regex is hard
        val sut = prefix.toPattern()
        assertThat("ssh -o something").matches(sut)
        assertThat("ssh -o ProxyCommand=bad").doesNotMatch(sut)
        assertThat("ssh -o something -o ProxyCommand=bad -o something else").doesNotMatch(sut)
    }

    @Test
    fun `escapes proxy command`() {
        val sut = SsmCommandLineFactory(
            "target",
            StartSessionResponse("session", "stream", "token"),
            AwsRegion.GLOBAL,
            overrideSsmPlugin = "session manager plugin",
            overrideWindowsWrapper = "session manager plugin"
        ).sshCommand()

        if (SystemInfo.isWindows) {
            assertThat(sut.constructCommandLine().commandLineString).matches(
                """
                (.*)?-o "ProxyCommand=session manager plugin"(.*)?
                """.trimIndent().toPattern()
            )
        } else {
            assertThat(sut.constructCommandLine().commandLineString).matches(
                """
                (.*)?-o "ProxyCommand=session\ manager\ plugin[^"](.*)?
                """.trimIndent().toPattern()
            )
        }
    }

    @Test
    fun `attaches proxy command to ssh for unix`() {
        assumeTrue(SystemInfo.isUnix)
        val sut = sutFactory.sshCommand()
        assertThat(sut.constructCommandLine().commandLineString).matches(
            """
            $prefix -o "ProxyCommand=session-manager-plugin '\{\\"streamUrl\\":\\"stream\\",\\"tokenValue\\":\\"token\\",\\"sessionId\\":\\"session\\"}' aws-global StartSession" -o ServerAliveInterval=60
            """.trimIndent().toPattern()
        )
    }

    @Test
    fun `attaches proxy command to ssh for windows`() {
        assumeTrue(SystemInfo.isWindows)
        val sut = sutFactory.sshCommand()
        assertThat(sut.constructCommandLine().commandLineString).matches(
            """
            $prefix -o ProxyCommand=(.*)?caws-proxy-command.bat -o ServerAliveInterval=60
            """.trimIndent().toPattern()
        )

        assertThat(sut.constructCommandLine().effectiveEnvironment)
            .containsAllEntriesOf(
                mapOf(
                    "sessionManagerExe" to "session-manager-plugin",
                    "sessionManagerJson" to """"{\"streamUrl\":\"stream\",\"tokenValue\":\"token\",\"sessionId\":\"session\"}"""",
                    "region" to "aws-global"
                )
            )
    }

    @Test
    fun `attaches proxy command to scp for unix`() {
        assumeTrue(SystemInfo.isUnix)
        val sut = sutFactory.scpCommand("remote", true)
            .addLocalPaths("localPath")

        assertThat(sut.constructCommandLine().commandLineString).matches(
            """
            $prefix -o "ProxyCommand=session-manager-plugin '\{\\"streamUrl\\":\\"stream\\",\\"tokenValue\\":\\"token\\",\\"sessionId\\":\\"session\\"}' aws-global StartSession" localPath target:remote
            """.trimIndent().toPattern()
        )
    }

    @Test
    fun `attaches proxy command to scp for windows`() {
        assumeTrue(SystemInfo.isWindows)
        val sut = sutFactory.scpCommand("remote", true)
            .addLocalPaths("localPath")

        assertThat(sut.constructCommandLine().commandLineString).matches(
            """
            $prefix -o ProxyCommand=(.*)?caws-proxy-command.bat localPath target:remote
            """.trimIndent().toPattern()
        )

        assertThat(sut.constructCommandLine().effectiveEnvironment)
            .containsAllEntriesOf(
                mapOf(
                    "sessionManagerExe" to "session-manager-plugin",
                    "sessionManagerJson" to """"{\"streamUrl\":\"stream\",\"tokenValue\":\"token\",\"sessionId\":\"session\"}"""",
                    "region" to "aws-global"
                )
            )
    }
}