// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License.AGPL.txt in the project root for license information.

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io/ioutil"
	"net"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"os/signal"
	"path/filepath"
	"reflect"
	"regexp"
	"strconv"
	"strings"
	"syscall"
	"time"

	"github.com/google/uuid"
	"github.com/hashicorp/go-version"
	"golang.org/x/xerrors"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	yaml "gopkg.in/yaml.v2"

	"github.com/gitpod-io/gitpod/common-go/log"
	"github.com/gitpod-io/gitpod/common-go/util"
	gitpod "github.com/gitpod-io/gitpod/gitpod-protocol"
	supervisor "github.com/gitpod-io/gitpod/supervisor/api"
)

const defaultBackendPort = "63342"

var (
	// ServiceName is the name we use for tracing/logging.
	ServiceName = "jetbrains-launcher"
	// Version of this service - set during build.
	Version = ""
)

type LaunchContext struct {
	startTime time.Time

	port   string
	alias  string
	label  string
	warmup bool

	qualifier      string
	productDir     string
	backendDir     string
	info           *ProductInfo
	backendVersion *version.Version
	wsInfo         *supervisor.WorkspaceInfoResponse

	vmOptionsFile     string
	projectDir        string
	configDir         string
	systemDir         string
	projectConfigDir  string
	projectContextDir string
	riderSolutionFile string

	env []string
}

// JB startup entrypoint
func main() {
	log.Init(ServiceName, Version, true, false)

	if len(os.Args) == 3 && os.Args[1] == "env" && os.Args[2] != "" {
		var mark = os.Args[2]
		content, err := json.Marshal(os.Environ())
		if err != nil {
			log.WithError(err).Fatal()
		}
		fmt.Printf("%s%s%s", mark, content, mark)
		return
	}

	log.Info(ServiceName + ": " + Version)
	startTime := time.Now()

	var port string
	var warmup bool

	if len(os.Args) < 2 {
		log.Fatalf("Usage: %s (warmup|<port>)\n", os.Args[0])
	}

	if os.Args[1] == "warmup" {
		if len(os.Args) < 3 {
			log.Fatalf("Usage: %s %s <alias>\n", os.Args[0], os.Args[1])
		}

		warmup = true
	} else {
		if len(os.Args) < 3 {
			log.Fatalf("Usage: %s <port> <kind> [<link label>]\n", os.Args[0])
		}

		port = os.Args[1]
	}

	alias := os.Args[2]
	label := "Open JetBrains IDE"
	if len(os.Args) > 3 {
		label = os.Args[3]
	}

	qualifier := os.Getenv("JETBRAINS_BACKEND_QUALIFIER")
	if qualifier == "stable" {
		qualifier = ""
	} else {
		qualifier = "-" + qualifier
	}
	productDir := "/ide-desktop/" + alias + qualifier
	backendDir := productDir + "/backend"

	info, err := resolveProductInfo(backendDir)
	if err != nil {
		log.WithError(err).Error("failed to resolve product info")
		return
	}

	backendVersion, err := version.NewVersion(info.Version)
	if err != nil {
		log.WithError(err).Error("failed to resolve backend version")
		return
	}

	wsInfo, err := resolveWorkspaceInfo(context.Background())
	if err != nil || wsInfo == nil {
		log.WithError(err).WithField("wsInfo", wsInfo).Error("resolve workspace info failed")
		return
	}

	launchCtx := &LaunchContext{
		startTime: startTime,

		warmup: warmup,
		port:   port,
		alias:  alias,
		label:  label,

		qualifier:      qualifier,
		productDir:     productDir,
		backendDir:     backendDir,
		info:           info,
		backendVersion: backendVersion,
		wsInfo:         wsInfo,
	}

	if launchCtx.warmup {
		waitForTasksToFinish()
		launch(launchCtx)
		return
	}
	// we should start serving immediately and postpone launch
	// in order to enable a JB Gateway to connect as soon as possible
	go launch(launchCtx)
	// IMPORTANT: don't put startup logic in serve!!!
	serve(launchCtx)
}

func serve(launchCtx *LaunchContext) {
	debugAgentPrefix := "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:"
	http.HandleFunc("/debug", func(w http.ResponseWriter, r *http.Request) {
		options, err := readVMOptions(launchCtx.vmOptionsFile)
		if err != nil {
			log.WithError(err).Error("failed to configure debug agent")
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		debugPort := ""
		i := len(options) - 1
		for i >= 0 && debugPort == "" {
			option := options[i]
			if strings.HasPrefix(option, debugAgentPrefix) {
				debugPort = option[len(debugAgentPrefix):]
				if debugPort == "0" {
					debugPort = ""
				}
			}
			i--
		}

		if debugPort != "" {
			fmt.Fprint(w, debugPort)
			return
		}
		netListener, err := net.Listen("tcp", "localhost:0")
		if err != nil {
			log.WithError(err).Error("failed to configure debug agent")
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		debugPort = strconv.Itoa(netListener.(*net.TCPListener).Addr().(*net.TCPAddr).Port)
		_ = netListener.Close()

		debugOptions := []string{debugAgentPrefix + debugPort}
		options = deduplicateVMOption(options, debugOptions, func(l, r string) bool {
			return strings.HasPrefix(l, debugAgentPrefix) && strings.HasPrefix(r, debugAgentPrefix)
		})
		err = writeVMOptions(launchCtx.vmOptionsFile, options)
		if err != nil {
			log.WithError(err).Error("failed to configure debug agent")
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		fmt.Fprint(w, debugPort)
		restart(r)
	})
	http.HandleFunc("/restart", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "terminated")
		restart(r)
	})
	http.HandleFunc("/joinLink", func(w http.ResponseWriter, r *http.Request) {
		backendPort := r.URL.Query().Get("backendPort")
		if backendPort == "" {
			backendPort = defaultBackendPort
		}
		jsonLink, err := resolveJsonLink(backendPort)
		if err != nil {
			log.WithError(err).Error("cannot resolve join link")
			http.Error(w, err.Error(), http.StatusServiceUnavailable)
			return
		}
		fmt.Fprint(w, jsonLink)
	})
	http.HandleFunc("/gatewayLink", func(w http.ResponseWriter, r *http.Request) {
		backendPort := r.URL.Query().Get("backendPort")
		if backendPort == "" {
			backendPort = defaultBackendPort
		}
		jsonLink, err := resolveGatewayLink(backendPort, launchCtx.wsInfo)
		if err != nil {
			log.WithError(err).Error("cannot resolve gateway link")
			http.Error(w, err.Error(), http.StatusServiceUnavailable)
			return
		}
		fmt.Fprint(w, jsonLink)
	})
	http.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
		backendPort := r.URL.Query().Get("backendPort")
		if backendPort == "" {
			backendPort = defaultBackendPort
		}
		gatewayLink, err := resolveGatewayLink(backendPort, launchCtx.wsInfo)
		if err != nil {
			log.WithError(err).Error("cannot resolve gateway link")
			http.Error(w, err.Error(), http.StatusServiceUnavailable)
			return
		}
		response := make(map[string]string)
		response["link"] = gatewayLink
		response["label"] = launchCtx.label
		response["clientID"] = "jetbrains-gateway"
		response["kind"] = launchCtx.alias
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(response)
	})

	fmt.Printf("Starting status proxy for desktop IDE at port %s\n", launchCtx.port)
	if err := http.ListenAndServe(fmt.Sprintf(":%s", launchCtx.port), nil); err != nil {
		log.Fatal(err)
	}
}

func restart(r *http.Request) {
	backendPort := r.URL.Query().Get("backendPort")
	if backendPort == "" {
		backendPort = defaultBackendPort
	}
	err := terminateIDE(backendPort)
	if err != nil {
		log.WithError(err).Error("failed to terminate IDE gracefully")
		os.Exit(1)
	}
	os.Exit(0)
}

type Projects struct {
	JoinLink string `json:"joinLink"`
}
type Response struct {
	Projects []Projects `json:"projects"`
}

func resolveGatewayLink(backendPort string, wsInfo *supervisor.WorkspaceInfoResponse) (string, error) {
	gitpodUrl, err := url.Parse(wsInfo.GitpodHost)
	if err != nil {
		return "", err
	}
	link := url.URL{
		Scheme:   "jetbrains-gateway",
		Host:     "connect",
		Fragment: fmt.Sprintf("gitpodHost=%s&workspaceId=%s&backendPort=%s", gitpodUrl.Hostname(), wsInfo.WorkspaceId, backendPort),
	}
	return link.String(), nil
}

func resolveJsonLink(backendPort string) (string, error) {
	var (
		hostStatusUrl = "http://localhost:" + backendPort + "/codeWithMe/unattendedHostStatus?token=gitpod"
		client        = http.Client{Timeout: 1 * time.Second}
	)
	resp, err := client.Get(hostStatusUrl)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	bodyBytes, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}
	if resp.StatusCode != http.StatusOK {
		return "", xerrors.Errorf("failed to resolve project status: %s (%d)", bodyBytes, resp.StatusCode)
	}
	jsonResp := &Response{}
	err = json.Unmarshal(bodyBytes, &jsonResp)
	if err != nil {
		return "", err
	}
	if len(jsonResp.Projects) != 1 {
		return "", xerrors.Errorf("project is not found")
	}
	return jsonResp.Projects[0].JoinLink, nil
}

func terminateIDE(backendPort string) error {
	var (
		hostStatusUrl = "http://localhost:" + backendPort + "/codeWithMe/unattendedHostStatus?token=gitpod&exit=true"
		client        = http.Client{Timeout: 10 * time.Second}
	)
	resp, err := client.Get(hostStatusUrl)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	bodyBytes, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return err
	}
	if resp.StatusCode != http.StatusOK {
		return xerrors.Errorf("failed to resolve terminate IDE: %s (%d)", bodyBytes, resp.StatusCode)
	}
	return nil
}

func resolveWorkspaceInfo(ctx context.Context) (*supervisor.WorkspaceInfoResponse, error) {
	resolve := func(ctx context.Context) (wsInfo *supervisor.WorkspaceInfoResponse, err error) {
		supervisorConn, err := grpc.Dial(util.GetSupervisorAddress(), grpc.WithTransportCredentials(insecure.NewCredentials()))
		if err != nil {
			err = errors.New("dial supervisor failed: " + err.Error())
			return
		}
		defer supervisorConn.Close()
		if wsInfo, err = supervisor.NewInfoServiceClient(supervisorConn).WorkspaceInfo(ctx, &supervisor.WorkspaceInfoRequest{}); err != nil {
			err = errors.New("get workspace info failed: " + err.Error())
			return
		}
		return
	}
	// try resolve workspace info 10 times
	for attempt := 0; attempt < 10; attempt++ {
		if wsInfo, err := resolve(ctx); err != nil {
			log.WithError(err).Error("resolve workspace info failed")
			time.Sleep(1 * time.Second)
		} else {
			return wsInfo, err
		}
	}
	return nil, errors.New("failed with attempt 10 times")
}

func launch(launchCtx *LaunchContext) {
	projectDir := launchCtx.wsInfo.GetCheckoutLocation()
	gitpodConfig, err := parseGitpodConfig(projectDir)
	if err != nil {
		log.WithError(err).Error("failed to parse .gitpod.yml")
	}

	// configure vmoptions
	idePrefix := launchCtx.alias
	if launchCtx.alias == "intellij" {
		idePrefix = "idea"
	}
	// [idea64|goland64|pycharm64|phpstorm64].vmoptions
	launchCtx.vmOptionsFile = fmt.Sprintf(launchCtx.backendDir+"/bin/%s64.vmoptions", idePrefix)
	err = configureVMOptions(gitpodConfig, launchCtx.alias, launchCtx.vmOptionsFile)
	if err != nil {
		log.WithError(err).Error("failed to configure vmoptions")
	}

	var riderSolutionFile string
	if launchCtx.alias == "rider" {
		riderSolutionFile, err = findRiderSolutionFile(projectDir)
		if err != nil {
			log.WithError(err).Error("failed to find a rider solution file")
		}
	}

	configDir := fmt.Sprintf("/workspace/.config/JetBrains%s", launchCtx.qualifier)
	launchCtx.projectDir = projectDir
	launchCtx.configDir = configDir
	launchCtx.systemDir = fmt.Sprintf("/workspace/.cache/JetBrains%s", launchCtx.qualifier)
	launchCtx.riderSolutionFile = riderSolutionFile
	launchCtx.projectContextDir = resolveProjectContextDir(launchCtx)
	launchCtx.projectConfigDir = fmt.Sprintf("%s/RemoteDev-%s/%s", configDir, launchCtx.info.ProductCode, strings.ReplaceAll(launchCtx.projectContextDir, "/", "_"))

	// sync initial options
	err = syncOptions(launchCtx)
	if err != nil {
		log.WithError(err).Error("failed to sync initial options")
	}

	// install project plugins
	version_2022_1, _ := version.NewVersion("2022.1")
	if version_2022_1.LessThanOrEqual(launchCtx.backendVersion) {
		err = installPlugins(gitpodConfig, launchCtx)
		installPluginsCost := time.Now().Local().Sub(launchCtx.startTime).Milliseconds()
		if err != nil {
			log.WithError(err).WithField("cost", installPluginsCost).Error("installing repo plugins: done")
		} else {
			log.WithField("cost", installPluginsCost).Info("installing repo plugins: done")
		}
	}

	// install gitpod plugin
	err = linkRemotePlugin(launchCtx)
	if err != nil {
		log.WithError(err).Error("failed to install gitpod-remote plugin")
	}

	// run backend
	run(launchCtx)
}

func run(launchCtx *LaunchContext) {
	var args []string
	if launchCtx.warmup {
		args = append(args, "warmup")
	} else {
		args = append(args, "run")
	}
	args = append(args, launchCtx.projectContextDir)

	cmd := remoteDevServerCmd(args, launchCtx)
	cmd.Env = append(cmd.Env, "JETBRAINS_GITPOD_BACKEND_KIND="+launchCtx.alias)
	workspaceUrl, err := url.Parse(launchCtx.wsInfo.WorkspaceUrl)
	if err == nil {
		cmd.Env = append(cmd.Env, "JETBRAINS_GITPOD_WORKSPACE_HOST="+workspaceUrl.Hostname())
	}
	// Enable host status endpoint
	cmd.Env = append(cmd.Env, "CWM_HOST_STATUS_OVER_HTTP_TOKEN=gitpod")

	if err := cmd.Start(); err != nil {
		log.WithError(err).Error("failed to start")
	}

	// Nicely handle SIGTERM sinal
	go handleSignal()

	if err := cmd.Wait(); err != nil {
		log.WithError(err).Error("failed to wait")
	}
	log.Info("IDE stopped, exiting")
	os.Exit(cmd.ProcessState.ExitCode())
}

// resolveUserEnvs emulats the interactive login shell to ensure that all user defined shell scripts are loaded
func resolveUserEnvs(launchCtx *LaunchContext) (userEnvs []string, err error) {
	shell := os.Getenv("SHELL")
	if shell == "" {
		shell = "/bin/bash"
	}
	mark, err := uuid.NewRandom()
	if err != nil {
		return
	}
	envCmd := exec.Command(shell, []string{"-ilc", "/ide-desktop/jb-launcher env " + mark.String()}...)
	envCmd.Stderr = os.Stderr
	output, err := envCmd.Output()
	if err != nil {
		return
	}
	markByte := []byte(mark.String())
	start := bytes.Index(output, markByte)
	if start == -1 {
		err = fmt.Errorf("no %s in output", mark.String())
		return
	}
	start = start + len(markByte)
	if start > len(output) {
		err = fmt.Errorf("no %s in output", mark.String())
		return
	}
	end := bytes.LastIndex(output, markByte)
	if end == -1 {
		err = fmt.Errorf("no %s in output", mark.String())
		return
	}
	err = json.Unmarshal(output[start:end], &userEnvs)
	return
}

func remoteDevServerCmd(args []string, launchCtx *LaunchContext) *exec.Cmd {
	if launchCtx.env == nil {
		userEnvs, err := resolveUserEnvs(launchCtx)
		if err == nil {
			launchCtx.env = append(launchCtx.env, userEnvs...)
		} else {
			log.WithError(err).Error("failed to resolve user env vars")
			launchCtx.env = os.Environ()
		}

		// Set default config and system directories under /workspace to preserve between restarts
		launchCtx.env = append(launchCtx.env,
			// Set default config and system directories under /workspace to preserve between restarts
			fmt.Sprintf("IJ_HOST_CONFIG_BASE_DIR=%s", launchCtx.configDir),
			fmt.Sprintf("IJ_HOST_SYSTEM_BASE_DIR=%s", launchCtx.systemDir),
		)

		// instead put them into /ide-desktop/${alias}${qualifier}/backend/bin/idea64.vmoptions
		// otherwise JB will complain to a user on each startup
		// by default remote dev already set -Xmx2048m, see /ide-desktop/${alias}${qualifier}/backend/plugins/remote-dev-server/bin/launcher.sh
		launchCtx.env = append(launchCtx.env, "JAVA_TOOL_OPTIONS=")

		log.WithField("env", launchCtx.env).Debug("resolved launch env")
	}

	cmd := exec.Command(launchCtx.backendDir+"/bin/remote-dev-server.sh", args...)
	cmd.Env = launchCtx.env
	cmd.Stderr = os.Stderr
	cmd.Stdout = os.Stdout
	return cmd
}

func handleSignal() {
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

	<-sigChan
	log.WithField("port", defaultBackendPort).Info("receive SIGTERM signal, terminating IDE")
	if err := terminateIDE(defaultBackendPort); err != nil {
		log.WithError(err).Error("failed to terminate IDE")
	}
	log.Info("asked IDE to terminate")
}

func configureVMOptions(config *gitpod.GitpodConfig, alias string, vmOptionsPath string) error {
	options, err := readVMOptions(vmOptionsPath)
	if err != nil {
		return err
	}
	newOptions := updateVMOptions(config, alias, options)
	return writeVMOptions(vmOptionsPath, newOptions)
}

func readVMOptions(vmOptionsPath string) ([]string, error) {
	content, err := ioutil.ReadFile(vmOptionsPath)
	if err != nil {
		return nil, err
	}
	return strings.Fields(string(content)), nil
}

func writeVMOptions(vmOptionsPath string, vmoptions []string) error {
	// vmoptions file should end with a newline
	content := strings.Join(vmoptions, "\n") + "\n"
	return ioutil.WriteFile(vmOptionsPath, []byte(content), 0)
}

// deduplicateVMOption append new VMOptions onto old VMOptions and remove any duplicated leftmost options
func deduplicateVMOption(oldLines []string, newLines []string, predicate func(l, r string) bool) []string {
	var result []string
	var merged = append(oldLines, newLines...)
	for i, left := range merged {
		for _, right := range merged[i+1:] {
			if predicate(left, right) {
				left = ""
				break
			}
		}
		if left != "" {
			result = append(result, left)
		}
	}
	return result
}

func updateVMOptions(
	config *gitpod.GitpodConfig,
	alias string,
	// original vmoptions (inherited from $JETBRAINS_IDE_HOME/bin/idea64.vmoptions)
	ideaVMOptionsLines []string) []string {
	// inspired by how intellij platform merge the VMOptions
	// https://github.com/JetBrains/intellij-community/blob/master/platform/platform-impl/src/com/intellij/openapi/application/ConfigImportHelper.java#L1115
	filterFunc := func(l, r string) bool {
		isEqual := l == r
		isXmx := strings.HasPrefix(l, "-Xmx") && strings.HasPrefix(r, "-Xmx")
		isXms := strings.HasPrefix(l, "-Xms") && strings.HasPrefix(r, "-Xms")
		isXss := strings.HasPrefix(l, "-Xss") && strings.HasPrefix(r, "-Xss")
		isXXOptions := strings.HasPrefix(l, "-XX:") && strings.HasPrefix(r, "-XX:") &&
			strings.Split(l, "=")[0] == strings.Split(r, "=")[0]
		return isEqual || isXmx || isXms || isXss || isXXOptions
	}
	// Gitpod's default customization
	var gitpodVMOptions []string
	gitpodVMOptions = append(gitpodVMOptions, "-Dgtw.disable.exit.dialog=true")
	if alias == "intellij" {
		gitpodVMOptions = append(gitpodVMOptions, "-Djdk.configure.existing=true")
	}
	vmoptions := deduplicateVMOption(ideaVMOptionsLines, gitpodVMOptions, filterFunc)

	// user-defined vmoptions (EnvVar)
	userVMOptionsVar := os.Getenv(strings.ToUpper(alias) + "_VMOPTIONS")
	userVMOptions := strings.Fields(userVMOptionsVar)
	if len(userVMOptions) > 0 {
		vmoptions = deduplicateVMOption(vmoptions, userVMOptions, filterFunc)
	}

	// project-defined vmoptions (.gitpod.yml)
	if config != nil {
		productConfig := getProductConfig(config, alias)
		if productConfig != nil {
			projectVMOptions := strings.Fields(productConfig.Vmoptions)
			if len(projectVMOptions) > 0 {
				vmoptions = deduplicateVMOption(vmoptions, projectVMOptions, filterFunc)
			}
		}
	}

	return vmoptions
}

/*
*

	{
	  "buildNumber" : "221.4994.44",
	  "customProperties" : [ ],
	  "dataDirectoryName" : "IntelliJIdea2022.1",
	  "launch" : [ {
	    "javaExecutablePath" : "jbr/bin/java",
	    "launcherPath" : "bin/idea.sh",
	    "os" : "Linux",
	    "startupWmClass" : "jetbrains-idea",
	    "vmOptionsFilePath" : "bin/idea64.vmoptions"
	  } ],
	  "name" : "IntelliJ IDEA",
	  "productCode" : "IU",
	  "svgIconPath" : "bin/idea.svg",
	  "version" : "2022.1",
	  "versionSuffix" : "EAP"
	}
*/
type ProductInfo struct {
	Version     string `json:"version"`
	ProductCode string `json:"productCode"`
}

func resolveProductInfo(backendDir string) (*ProductInfo, error) {
	f, err := os.Open(backendDir + "/product-info.json")
	if err != nil {
		return nil, err
	}
	defer f.Close()
	content, err := ioutil.ReadAll(f)
	if err != nil {
		return nil, err
	}

	var info ProductInfo
	err = json.Unmarshal(content, &info)
	return &info, err
}

func syncOptions(launchCtx *LaunchContext) error {
	userHomeDir, err := os.UserHomeDir()
	if err != nil {
		return err
	}
	var srcDirs []string
	for _, srcDir := range []string{
		fmt.Sprintf("%s/.gitpod/jetbrains/options", userHomeDir),
		fmt.Sprintf("%s/.gitpod/jetbrains/%s/options", userHomeDir, launchCtx.alias),
		fmt.Sprintf("%s/.gitpod/jetbrains/options", launchCtx.projectDir),
		fmt.Sprintf("%s/.gitpod/jetbrains/%s/options", launchCtx.projectDir, launchCtx.alias),
	} {
		srcStat, err := os.Stat(srcDir)
		if os.IsNotExist(err) {
			// nothing to sync
			continue
		}
		if err != nil {
			return err
		}
		if !srcStat.IsDir() {
			return fmt.Errorf("%s is not a directory", srcDir)
		}
		srcDirs = append(srcDirs, srcDir)
	}
	if len(srcDirs) == 0 {
		// nothing to sync
		return nil
	}

	destDir := fmt.Sprintf("%s/options", launchCtx.projectConfigDir)
	_, err = os.Stat(destDir)
	if !os.IsNotExist(err) {
		// already synced skipping, i.e. restart of jb backend
		return nil
	}
	err = os.MkdirAll(destDir, os.ModePerm)
	if err != nil {
		return err
	}

	for _, srcDir := range srcDirs {
		cp := exec.Command("cp", "-rf", srcDir+"/.", destDir)
		err = cp.Run()
		if err != nil {
			return err
		}
	}
	return nil
}

func installPlugins(config *gitpod.GitpodConfig, launchCtx *LaunchContext) error {
	plugins, err := getPlugins(config, launchCtx.alias)
	if err != nil {
		return err
	}
	if len(plugins) <= 0 {
		return nil
	}

	var args []string
	args = append(args, "installPlugins")
	args = append(args, launchCtx.projectContextDir)
	args = append(args, plugins...)
	cmd := remoteDevServerCmd(args, launchCtx)
	installErr := cmd.Run()

	// delete alien_plugins.txt to suppress 3rd-party plugins consent on startup to workaround backend startup freeze
	err = os.Remove(launchCtx.projectConfigDir + "/alien_plugins.txt")
	if err != nil {
		log.WithError(err).Error("failed to suppress 3rd-party plugins consent")
	}

	if installErr != nil {
		return errors.New("failed to install repo plugins: " + installErr.Error())
	}
	return nil
}

func parseGitpodConfig(repoRoot string) (*gitpod.GitpodConfig, error) {
	if repoRoot == "" {
		return nil, errors.New("repoRoot is empty")
	}
	data, err := os.ReadFile(filepath.Join(repoRoot, ".gitpod.yml"))
	if err != nil {
		// .gitpod.yml not exist is ok
		if errors.Is(err, os.ErrNotExist) {
			return nil, nil
		}
		return nil, errors.New("read .gitpod.yml file failed: " + err.Error())
	}
	var config *gitpod.GitpodConfig
	if err = yaml.Unmarshal(data, &config); err != nil {
		return nil, errors.New("unmarshal .gitpod.yml file failed" + err.Error())
	}
	return config, nil
}

func getPlugins(config *gitpod.GitpodConfig, alias string) ([]string, error) {
	var plugins []string
	if config == nil || config.Jetbrains == nil {
		return nil, nil
	}
	if config.Jetbrains.Plugins != nil {
		plugins = append(plugins, config.Jetbrains.Plugins...)
	}
	productConfig := getProductConfig(config, alias)
	if productConfig != nil && productConfig.Plugins != nil {
		plugins = append(plugins, productConfig.Plugins...)
	}
	return plugins, nil
}

func getProductConfig(config *gitpod.GitpodConfig, alias string) *gitpod.JetbrainsProduct {
	defer func() {
		if err := recover(); err != nil {
			log.WithField("error", err).WithField("alias", alias).Error("failed to extract JB product config")
		}
	}()
	v := reflect.ValueOf(*config.Jetbrains).FieldByNameFunc(func(s string) bool {
		return strings.ToLower(s) == alias
	}).Interface()
	productConfig, ok := v.(*gitpod.JetbrainsProduct)
	if !ok {
		return nil
	}
	return productConfig
}

func linkRemotePlugin(launchCtx *LaunchContext) error {
	remotePluginDir := launchCtx.backendDir + "/plugins/gitpod-remote"
	_, err := os.Stat(remotePluginDir)
	if err == nil || !errors.Is(err, os.ErrNotExist) {
		return nil
	}

	// added for backwards compatibility, can be removed in the future
	sourceDir := "/ide-desktop-plugins/gitpod-remote-" + os.Getenv("JETBRAINS_BACKEND_QUALIFIER")
	_, err = os.Stat(sourceDir)
	if err == nil {
		return os.Symlink(sourceDir, remotePluginDir)
	}

	return os.Symlink("/ide-desktop-plugins/gitpod-remote", remotePluginDir)
}

// TODO(andreafalzetti): remove dir scanning once this is implemented https://youtrack.jetbrains.com/issue/GTW-2402/Rider-Open-Project-dialog-not-displaying-in-remote-dev
func findRiderSolutionFile(root string) (string, error) {
	slnRegEx := regexp.MustCompile(`^.+\.sln$`)
	projRegEx := regexp.MustCompile(`^.+\.csproj$`)

	var slnFiles []string
	var csprojFiles []string

	err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		} else if slnRegEx.MatchString(info.Name()) {
			slnFiles = append(slnFiles, path)
		} else if projRegEx.MatchString(info.Name()) {
			csprojFiles = append(csprojFiles, path)
		}
		return nil
	})

	if err != nil {
		return "", err
	}

	if len(slnFiles) > 0 {
		return slnFiles[0], nil
	} else if len(csprojFiles) > 0 {
		return csprojFiles[0], nil
	}

	return root, nil
}

func resolveProjectContextDir(launchCtx *LaunchContext) string {
	if launchCtx.alias == "rider" {
		return launchCtx.riderSolutionFile
	}

	return launchCtx.projectDir
}

func waitForTasksToFinish() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	var conn *grpc.ClientConn
	var err error

	for {
		conn, err = dial(ctx)
		if err == nil {
			err = checkTasks(ctx, conn)
		}

		if err == nil {
			return
		}

		log.WithError(err).Error("launcher: failed to check tasks status")

		select {
		case <-ctx.Done():
			return
		case <-time.After(1 * time.Second):
		}
	}
}

func checkTasks(ctx context.Context, conn *grpc.ClientConn) error {
	client := supervisor.NewStatusServiceClient(conn)
	tasksResponse, err := client.TasksStatus(ctx, &supervisor.TasksStatusRequest{Observe: true})
	if err != nil {
		return xerrors.Errorf("failed get tasks status client: %w", err)
	}

	for {
		var runningTasksCounter int

		resp, err := tasksResponse.Recv()
		if err != nil {
			return err
		}

		for _, task := range resp.Tasks {
			if task.State != supervisor.TaskState_closed && task.Presentation.Name != "GITPOD_JB_WARMUP_TASK" {
				runningTasksCounter++
			}
		}
		if runningTasksCounter == 0 {
			break
		}
	}

	return nil
}

func dial(ctx context.Context) (*grpc.ClientConn, error) {
	supervisorConn, err := grpc.DialContext(ctx, util.GetSupervisorAddress(), grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		err = xerrors.Errorf("failed connecting to supervisor: %w", err)
	}
	return supervisorConn, err
}
