// 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 server

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"time"

	"github.com/gitpod-io/gitpod/common-go/baseserver"
	"github.com/gitpod-io/gitpod/common-go/experiments"
	"github.com/gitpod-io/gitpod/common-go/log"
	"github.com/gitpod-io/gitpod/common-go/watch"
	gitpodapi "github.com/gitpod-io/gitpod/gitpod-protocol"
	api "github.com/gitpod-io/gitpod/ide-service-api"
	"github.com/gitpod-io/gitpod/ide-service-api/config"
	"github.com/heptiolabs/healthcheck"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/sirupsen/logrus"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/health/grpc_health_v1"
)

type IDEServiceServer struct {
	config                 *config.ServiceConfiguration
	originIDEConfig        []byte
	parsedIDEConfigContent string
	ideConfig              *config.IDEConfig
	ideConfigFileName      string
	experiemntsClient      experiments.Client

	api.UnimplementedIDEServiceServer
}

func Start(logger *logrus.Entry, cfg *config.ServiceConfiguration) error {

	ctx := context.Background()
	logger.WithField("config", cfg).Info("Starting ide-service.")

	registry := prometheus.NewRegistry()
	health := healthcheck.NewHandler()

	srv, err := baseserver.New("ide-service",
		baseserver.WithLogger(logger),
		baseserver.WithConfig(cfg.Server),
		baseserver.WithMetricsRegistry(registry),
		baseserver.WithHealthHandler(health),
	)
	if err != nil {
		return fmt.Errorf("failed to initialize ide-service: %w", err)
	}

	s := New(cfg)
	go s.watchIDEConfig(ctx)
	go s.scheduleUpdate(ctx)
	s.register(srv.GRPC())

	health.AddReadinessCheck("ide-service", func() error {
		if s.ideConfig == nil {
			return fmt.Errorf("ide config is not ready")
		}
		return nil
	})
	health.AddReadinessCheck("grpc-server", grpcProbe(*cfg.Server.Services.GRPC))

	if err := srv.ListenAndServe(); err != nil {
		return fmt.Errorf("failed to serve ide-service: %w", err)
	}

	return nil
}

func New(cfg *config.ServiceConfiguration) *IDEServiceServer {
	fn, err := filepath.Abs(cfg.IDEConfigPath)
	if err != nil {
		log.WithField("path", cfg.IDEConfigPath).WithError(err).Fatal("cannot convert ide config path to abs path")
	}
	s := &IDEServiceServer{
		config:            cfg,
		ideConfigFileName: fn,
		experiemntsClient: experiments.NewClient(),
	}
	return s
}

func (s *IDEServiceServer) register(grpcServer *grpc.Server) {
	api.RegisterIDEServiceServer(grpcServer, s)
}

func (s *IDEServiceServer) GetConfig(ctx context.Context, req *api.GetConfigRequest) (*api.GetConfigResponse, error) {
	return &api.GetConfigResponse{
		Content: s.parsedIDEConfigContent,
	}, nil
}

func (s *IDEServiceServer) readIDEConfig(ctx context.Context, isInit bool) {
	b, err := os.ReadFile(s.ideConfigFileName)
	if err != nil {
		log.WithError(err).Warn("cannot read ide config file")
		return
	}
	if config, err := ParseConfig(ctx, b); err != nil {
		if !isInit {
			log.WithError(err).Fatal("cannot parse ide config")
		}
		log.WithError(err).Error("cannot parse ide config")
		return
	} else {
		parsedConfig, err := json.Marshal(config)
		if err != nil {
			log.WithError(err).Error("cannot marshal ide config")
			return
		}
		s.parsedIDEConfigContent = string(parsedConfig)
		s.ideConfig = config
		s.originIDEConfig = b

		log.Info("ide config updated")
	}
}

func (s *IDEServiceServer) watchIDEConfig(ctx context.Context) {
	go s.readIDEConfig(ctx, true)

	// `watch.File` only watch for create and remove event
	// so with locally debugging, we cannot watch example ide config file change
	// but in k8s, configmap change will create/remove file to replace it
	if err := watch.File(ctx, s.ideConfigFileName, func() {
		s.readIDEConfig(ctx, false)
	}); err != nil {
		log.WithError(err).Fatal("cannot start watch of ide config file")
	}
}

func (s *IDEServiceServer) scheduleUpdate(ctx context.Context) {
	t := time.NewTicker(time.Hour * 1)
	for {
		select {
		case <-t.C:
			log.Info("schedule update config")
			s.readIDEConfig(ctx, false)
		case <-ctx.Done():
			t.Stop()
			return
		}
	}
}

func grpcProbe(cfg baseserver.ServerConfiguration) func() error {
	return func() error {
		creds := insecure.NewCredentials()

		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()

		conn, err := grpc.DialContext(ctx, cfg.Address, grpc.WithTransportCredentials(creds))
		if err != nil {
			return err
		}
		defer conn.Close()

		client := grpc_health_v1.NewHealthClient(conn)
		check, err := client.Check(ctx, &grpc_health_v1.HealthCheckRequest{})
		if err != nil {
			return err
		}

		if check.Status == grpc_health_v1.HealthCheckResponse_SERVING {
			return nil
		}
		return fmt.Errorf("grpc service not ready")
	}
}

type IDESettings struct {
	DefaultIde       string `json:"defaultIde,omitempty"`
	UseLatestVersion bool   `json:"useLatestVersion,omitempty"`
}

type WorkspaceContext struct {
	Referrer    string `json:"referrer,omitempty"`
	ReferrerIde string `json:"referrerIde,omitempty"`
}

func (s *IDEServiceServer) resolveReferrerIDE(ideConfig *config.IDEConfig, wsCtx *WorkspaceContext, chosenIDEName string) (ideName string, ideOption *config.IDEOption) {
	if wsCtx == nil || wsCtx.Referrer == "" {
		return
	}

	client, ok := ideConfig.IdeOptions.Clients[wsCtx.Referrer]
	if !ok {
		return
	}

	getValidIDEOption := func(ideName string) (*config.IDEOption, bool) {
		optionToCheck, ok := ideConfig.IdeOptions.Options[ideName]
		if !ok {
			return nil, false
		}
		for _, ide := range client.DesktopIDEs {
			if ide == ideName {
				return &optionToCheck, true
			}
		}
		return nil, false
	}

	ideOption, ok = getValidIDEOption(wsCtx.ReferrerIde)
	if ok {
		ideName = wsCtx.ReferrerIde
		return
	}
	ideOption, ok = getValidIDEOption(chosenIDEName)
	if ok {
		ideName = chosenIDEName
		return
	}
	ideOption, ok = getValidIDEOption(client.DefaultDesktopIDE)
	if ok {
		ideName = client.DefaultDesktopIDE
		return
	}
	return
}

func (s *IDEServiceServer) ResolveWorkspaceConfig(ctx context.Context, req *api.ResolveWorkspaceConfigRequest) (resp *api.ResolveWorkspaceConfigResponse, err error) {
	log.WithField("req", req).Debug("receive ResolveWorkspaceConfig request")

	// make a copy for ref ideConfig, it's safe because we replace ref in update config
	ideConfig := s.ideConfig

	var defaultIde *config.IDEOption

	if ide, ok := ideConfig.IdeOptions.Options[ideConfig.IdeOptions.DefaultIde]; !ok {
		// I think it never happen, we have a check to make sure all DefaultIDE should be in Options
		log.WithError(err).WithField("defaultIDE", ideConfig.IdeOptions.DefaultIde).Error("IDE configuration corrupt, cannot found defaultIDE")
		return nil, fmt.Errorf("IDE configuration corrupt")
	} else {
		defaultIde = &ide
	}

	resp = &api.ResolveWorkspaceConfigResponse{
		SupervisorImage: ideConfig.SupervisorImage,
		WebImage:        defaultIde.Image,
	}

	var wsConfig *gitpodapi.GitpodConfig

	if req.WorkspaceConfig != "" {
		if err := json.Unmarshal([]byte(req.WorkspaceConfig), &wsConfig); err != nil {
			log.WithError(err).WithField("workspaceConfig", req.WorkspaceConfig).Error("failed to parse workspace config")
		}
	}

	if req.Type == api.WorkspaceType_REGULAR {
		var ideSettings *IDESettings
		var wsContext *WorkspaceContext

		if req.IdeSettings != "" {
			if err := json.Unmarshal([]byte(req.IdeSettings), &ideSettings); err != nil {
				log.WithError(err).WithField("ideSetting", req.IdeSettings).Error("failed to parse ide settings")
			}
		}

		if req.Context != "" {
			if err := json.Unmarshal([]byte(req.Context), &wsContext); err != nil {
				log.WithError(err).WithField("context", req.Context).Error("failed to parse context")
			}
		}

		userIdeName := ""
		useLatest := false

		if ideSettings != nil {
			userIdeName = ideSettings.DefaultIde
			useLatest = ideSettings.UseLatestVersion
		}

		chosenIDE := defaultIde

		getUserIDEImage := func(ideOption *config.IDEOption) string {
			if useLatest && ideOption.LatestImage != "" {
				return ideOption.LatestImage
			}

			return ideOption.Image
		}

		getUserImageLayers := func(ideOption *config.IDEOption) []string {
			if useLatest {
				return ideOption.LatestImageLayers
			}

			return ideOption.ImageLayers
		}

		if userIdeName != "" {
			if ide, ok := ideConfig.IdeOptions.Options[userIdeName]; ok {
				chosenIDE = &ide

				// TODO: Currently this variable reflects the IDE selected in
				// user's settings for backward compatibility but in the future
				// we want to make it represent the actual IDE.
				ideAlias := api.EnvironmentVariable{
					Name:  "GITPOD_IDE_ALIAS",
					Value: userIdeName,
				}
				resp.Envvars = append(resp.Envvars, &ideAlias)
			}
		}

		// we always need WebImage for when the user chooses a desktop ide
		resp.WebImage = getUserIDEImage(defaultIde)

		var desktopImageLayer string
		var userImageLayers []string
		if chosenIDE.Type == config.IDETypeDesktop {
			desktopImageLayer = getUserIDEImage(chosenIDE)
			userImageLayers = getUserImageLayers(chosenIDE)
		} else {
			resp.WebImage = getUserIDEImage(chosenIDE)
		}

		ideName, referrer := s.resolveReferrerIDE(ideConfig, wsContext, userIdeName)
		if ideName != "" {
			resp.RefererIde = ideName
			desktopImageLayer = getUserIDEImage(referrer)
			userImageLayers = getUserImageLayers(referrer)
		}

		if desktopImageLayer != "" {
			resp.IdeImageLayers = append(resp.IdeImageLayers, desktopImageLayer)
			resp.IdeImageLayers = append(resp.IdeImageLayers, userImageLayers...)
		}

		if ideConfig.GpRunImage != "" {
			featureFlagAttrs := experiments.Attributes{
				UserID:    req.User.Id,
				UserEmail: req.User.GetEmail(),
			}
			useRunGp := s.experiemntsClient.GetBoolValue(ctx, "ide_service_experimental_rungp", false, featureFlagAttrs)
			log.WithField("featureFlagAttrs", featureFlagAttrs).WithField("useRunGp", useRunGp).Debug("calling configcat to check ide_service_experimental_rungp")
			if useRunGp {
				resp.IdeImageLayers = append(resp.IdeImageLayers, ideConfig.GpRunImage)
				log.Debug("adding gp-run layer")
			}
		}
	}

	jbGW, ok := ideConfig.IdeOptions.Clients["jetbrains-gateway"]
	if req.Type == api.WorkspaceType_PREBUILD && ok {
		warmUpTask := ""
		imageLayers := make(map[string]struct{})
		for _, alias := range jbGW.DesktopIDEs {
			prebuilds := getPrebuilds(wsConfig, alias)
			if prebuilds != nil {
				if prebuilds.Version != "latest" {
					if ide, ok := ideConfig.IdeOptions.Options[alias]; ok {
						for _, ideImageLayer := range ide.ImageLayers {
							if _, ok := imageLayers[ideImageLayer]; !ok {
								imageLayers[ideImageLayer] = struct{}{}
								resp.IdeImageLayers = append(resp.IdeImageLayers, ideImageLayer)
							}
						}
						resp.IdeImageLayers = append(resp.IdeImageLayers, ide.Image)
						template := `
echo 'warming up stable release of ${key}...'
JETBRAINS_BACKEND_QUALIFIER=stable /ide-desktop/jb-launcher warmup ${key}
`
						template = strings.ReplaceAll(template, "${key}", alias)
						warmUpTask += template
					}
				}

				if prebuilds.Version != "stable" {
					if ide, ok := ideConfig.IdeOptions.Options[alias]; ok {
						for _, latestIdeImageLayer := range ide.LatestImageLayers {
							if _, ok := imageLayers[latestIdeImageLayer]; !ok {
								imageLayers[latestIdeImageLayer] = struct{}{}
								resp.IdeImageLayers = append(resp.IdeImageLayers, latestIdeImageLayer)
							}
						}
						resp.IdeImageLayers = append(resp.IdeImageLayers, ide.LatestImage)
						template := `
echo 'warming up latest release of ${key}...'
JETBRAINS_BACKEND_QUALIFIER=latest /ide-desktop/jb-launcher warmup ${key}
`
						template = strings.ReplaceAll(template, "${key}", alias)
						warmUpTask += template
					}
				}
			}
		}

		if warmUpTask != "" {
			warmUpEncoded := new(bytes.Buffer)
			enc := json.NewEncoder(warmUpEncoded)
			enc.SetEscapeHTML(false)

			err := enc.Encode(&[]gitpodapi.TaskConfig{{
				Init: strings.TrimSpace(warmUpTask),
				Name: "GITPOD_JB_WARMUP_TASK",
			}})
			if err != nil {
				log.WithError(err).Error("cannot marshal warm up task")
			}

			resp.Tasks = warmUpEncoded.String()
		}
	}

	return
}

func getPrebuilds(config *gitpodapi.GitpodConfig, alias string) *gitpodapi.Prebuilds {
	if config == nil || config.Jetbrains == nil {
		return nil
	}
	productConfig := getProductConfig(config, alias)
	if productConfig == nil {
		return nil
	}
	return productConfig.Prebuilds
}

func getProductConfig(config *gitpodapi.GitpodConfig, alias string) *gitpodapi.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.(*gitpodapi.JetbrainsProduct)
	if !ok {
		return nil
	}
	return productConfig
}
