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

import (
	"context"
	"crypto/tls"
	"errors"
	"fmt"
	"reflect"
	"time"

	backoff "github.com/cenkalti/backoff/v4"
	"github.com/gitpod-io/gitpod/common-go/experiments"
	"github.com/gitpod-io/gitpod/common-go/log"
	v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1"
	gitpod "github.com/gitpod-io/gitpod/gitpod-protocol"
	"github.com/gitpod-io/gitpod/supervisor/api"
	grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware"
	grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus"
	"github.com/prometheus/client_golang/prometheus"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials"
	"google.golang.org/grpc/metadata"
)

type APIInterface interface {
	GetOwnerID(ctx context.Context, workspaceID string) (ownerID string, err error)
	GetToken(ctx context.Context, query *gitpod.GetTokenSearchOptions) (res *gitpod.Token, err error)
	OpenPort(ctx context.Context, workspaceID string, port *gitpod.WorkspaceInstancePort) (res *gitpod.WorkspaceInstancePort, err error)
	InstanceUpdates(ctx context.Context, instanceID string, workspaceID string) (<-chan *gitpod.WorkspaceInstance, error)

	// Remove this and use segment client directly
	TrackEvent(ctx context.Context, event *gitpod.RemoteTrackMessage) (err error)

	// Metrics
	RegisterMetrics(registry *prometheus.Registry) error
}

const (
	// KindGitpod marks tokens that provide access to the Gitpod server API.
	KindGitpod = "gitpod"
)

var errNotConnected = errors.New("not connected to server/public api")

type ServiceConfig struct {
	Host              string
	Endpoint          string
	InstanceID        string
	WorkspaceID       string
	SupervisorVersion string
}

type Service struct {
	cfg         *ServiceConfig
	experiments experiments.Client

	token   string
	ownerID string

	lastServerInstance *gitpod.WorkspaceInstance

	// gitpodService server API
	gitpodService gitpod.APIInterface
	// publicAPIConn public API publicAPIConn
	publicAPIConn    *grpc.ClientConn
	publicApiMetrics *grpc_prometheus.ClientMetrics

	previousUsingPublicAPI bool
}

var _ APIInterface = (*Service)(nil)

func NewServerApiService(ctx context.Context, cfg *ServiceConfig, tknsrv api.TokenServiceServer) *Service {
	tknres, err := tknsrv.GetToken(context.Background(), &api.GetTokenRequest{
		Kind: KindGitpod,
		Host: cfg.Host,
		Scope: []string{
			"function:getToken",
			"function:openPort",
			"function:trackEvent",
			"function:getWorkspace",
		},
	})
	if err != nil {
		log.WithError(err).Error("cannot get token for Gitpod API")
		return nil
	}
	// server api
	gitpodService, err := gitpod.ConnectToServer(cfg.Endpoint, gitpod.ConnectToServerOpts{
		Token: tknres.Token,
		Log:   log.Log,
		ExtraHeaders: map[string]string{
			"User-Agent":              "gitpod/supervisor",
			"X-Workspace-Instance-Id": cfg.InstanceID,
			"X-Client-Version":        cfg.SupervisorVersion,
		},
	})
	if err != nil {
		log.WithError(err).Error("cannot connect to Gitpod API")
		return nil
	}

	service := &Service{
		token:            tknres.Token,
		gitpodService:    gitpodService,
		cfg:              cfg,
		experiments:      experiments.NewClient(),
		publicApiMetrics: grpc_prometheus.NewClientMetrics(),
	}

	service.publicApiMetrics.EnableClientHandlingTimeHistogram(
		// it should be aligned with https://github.com/gitpod-io/gitpod/blob/84ed1a0672d91446ba33cb7b504cfada769271a8/install/installer/pkg/components/ide-metrics/configmap.go#L315
		grpc_prometheus.WithHistogramBuckets([]float64{0.1, 0.2, 0.5, 1, 2, 5, 10}),
	)

	// public api
	service.tryConnToPublicAPI()
	// listen to server instance update
	go service.listenInstanceUpdate(ctx, cfg.InstanceID)

	if wsInfo, err := gitpodService.GetWorkspace(ctx, cfg.WorkspaceID); err != nil {
		log.WithError(err).Error("cannot get workspace info")
	} else {
		service.ownerID = wsInfo.Workspace.OwnerID
	}
	return service
}

func (s *Service) tryConnToPublicAPI() {
	endpoint := fmt.Sprintf("api.%s:443", s.cfg.Host)
	log.WithField("endpoint", endpoint).Info("connecting to PublicAPI...")
	opts := []grpc.DialOption{
		grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS13})),
		grpc.WithUnaryInterceptor(grpc_middleware.ChainUnaryClient([]grpc.UnaryClientInterceptor{
			s.publicApiMetrics.UnaryClientInterceptor(),
			func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
				withAuth := metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+s.token)
				return invoker(withAuth, method, req, reply, cc, opts...)
			},
		}...)),
	}
	if conn, err := grpc.Dial(endpoint, opts...); err != nil {
		log.WithError(err).Errorf("failed to dial public api %s", endpoint)
	} else {
		s.publicAPIConn = conn
	}
}

func (s *Service) persistServerAPIChannelWhenStart(ctx context.Context) bool {
	if s.publicAPIConn == nil || s.ownerID == "" {
		return true
	}
	return experiments.SupervisorPersistServerAPIChannelWhenStart(ctx, s.experiments, experiments.Attributes{
		UserID: s.ownerID,
	})
}

func (s *Service) usePublicAPI(ctx context.Context) bool {
	if s.publicAPIConn == nil || s.ownerID == "" {
		return false
	}
	usePublicAPI := experiments.SupervisorUsePublicAPI(ctx, s.experiments, experiments.Attributes{
		UserID: s.ownerID,
	})
	if usePublicAPI != s.previousUsingPublicAPI {
		if usePublicAPI {
			log.Info("switch to use PublicAPI")
		} else {
			log.Info("switch to use ServerAPI")
		}
		s.previousUsingPublicAPI = usePublicAPI
	}
	return usePublicAPI
}

// GetToken implements protocol.APIInterface
func (s *Service) GetToken(ctx context.Context, query *gitpod.GetTokenSearchOptions) (res *gitpod.Token, err error) {
	if s == nil {
		return nil, errNotConnected
	}
	if !s.usePublicAPI(ctx) {
		return s.gitpodService.GetToken(ctx, query)
	}

	service := v1.NewUserServiceClient(s.publicAPIConn)
	resp, err := service.GetGitToken(ctx, &v1.GetGitTokenRequest{
		Host: query.Host,
	})
	if err != nil {
		log.WithField("method", "GetGitToken").WithError(err).Error("failed to call PublicAPI")
		return nil, err
	}
	return &gitpod.Token{
		ExpiryDate:   resp.Token.ExpiryDate,
		IDToken:      resp.Token.IdToken,
		RefreshToken: resp.Token.RefreshToken,
		Scopes:       resp.Token.Scopes,
		UpdateDate:   resp.Token.UpdateDate,
		Username:     resp.Token.Username,
		Value:        resp.Token.Value,
	}, nil
}

// OpenPort implements protocol.APIInterface
func (s *Service) OpenPort(ctx context.Context, workspaceID string, port *gitpod.WorkspaceInstancePort) (res *gitpod.WorkspaceInstancePort, err error) {
	if s == nil {
		return nil, errNotConnected
	}
	if !s.usePublicAPI(ctx) {
		return s.gitpodService.OpenPort(ctx, workspaceID, port)
	}
	service := v1.NewWorkspacesServiceClient(s.publicAPIConn)

	payload := &v1.UpdatePortRequest{
		WorkspaceId: workspaceID,
		Port: &v1.PortSpec{
			Port: uint64(port.Port),
		},
	}
	if port.Visibility == gitpod.PortVisibilityPublic {
		payload.Port.Policy = v1.PortPolicy_PORT_POLICY_PUBLIC
	} else {
		payload.Port.Policy = v1.PortPolicy_PORT_POLICY_PRIVATE
	}
	_, err = service.UpdatePort(ctx, payload)
	if err != nil {
		log.WithField("method", "UpdatePort").WithError(err).Error("failed to call PublicAPI")
		return nil, err
	}
	// server don't respond anything
	// see https://github.com/gitpod-io/gitpod/blob/2967579c330de67090d975661a6e3e1cd970ab68/components/server/src/workspace/gitpod-server-impl.ts#L1521
	return port, nil
}

func (s *Service) listenInstanceUpdate(ctx context.Context, instanceID string) {
	for {
		uptChan, err := backoff.RetryWithData(
			func() (<-chan *gitpod.WorkspaceInstance, error) {
				return s.gitpodService.InstanceUpdates(ctx, instanceID)
			},
			backoff.NewExponentialBackOff(),
		)
		if err != nil {
			log.WithError(err).Error("failed to get workspace instance chan several retries")
			continue
		}
		for {
			select {
			case <-ctx.Done():
				return
			case instance := <-uptChan:
				s.lastServerInstance = instance
			}
		}
	}
}

func (s *Service) getWorkspaceInfo(ctx context.Context, instanceID, workspaceID string) (*gitpod.WorkspaceInstance, error) {
	getData := func() (*gitpod.WorkspaceInstance, error) {
		if !s.usePublicAPI(ctx) {
			return s.lastServerInstance, nil
		}
		service := v1.NewWorkspacesServiceClient(s.publicAPIConn)
		resp, err := service.GetWorkspace(ctx, &v1.GetWorkspaceRequest{
			WorkspaceId: workspaceID,
		})
		if err != nil {
			log.WithField("method", "GetWorkspace").WithError(err).Error("failed to call PublicAPI")
			return nil, err
		}
		instance := &gitpod.WorkspaceInstance{
			CreationTime: resp.Result.Status.Instance.CreatedAt.String(),
			ID:           resp.Result.Status.Instance.InstanceId,
			Status: &gitpod.WorkspaceInstanceStatus{
				ExposedPorts: []*gitpod.WorkspaceInstancePort{},
				Message:      resp.Result.Status.Instance.Status.Message,
				// OwnerToken:   "", not used so ignore
				Phase:   resp.Result.Status.Instance.Status.Phase.String(),
				Timeout: resp.Result.Status.Instance.Status.Conditions.Timeout,
				Version: int(resp.Result.Status.Instance.Status.StatusVersion),
			},
			WorkspaceID: resp.Result.WorkspaceId,
		}
		for _, port := range resp.Result.Status.Instance.Status.Ports {
			info := &gitpod.WorkspaceInstancePort{
				Port: float64(port.Port),
				URL:  port.Url,
			}
			if port.Policy == v1.PortPolicy_PORT_POLICY_PUBLIC {
				info.Visibility = gitpod.PortVisibilityPublic
			} else {
				info.Visibility = gitpod.PortVisibilityPrivate
			}
			instance.Status.ExposedPorts = append(instance.Status.ExposedPorts, info)
		}
		return instance, nil
	}
	exp := &backoff.ExponentialBackOff{
		InitialInterval:     2 * time.Second,
		RandomizationFactor: 0.5,
		Multiplier:          1.5,
		MaxInterval:         30 * time.Second,
		MaxElapsedTime:      0,
		Stop:                backoff.Stop,
		Clock:               backoff.SystemClock,
	}
	return backoff.RetryWithData(getData, exp)
}

// InstanceUpdates implements protocol.APIInterface
func (s *Service) InstanceUpdates(ctx context.Context, instanceID string, workspaceID string) (<-chan *gitpod.WorkspaceInstance, error) {
	if s == nil {
		return nil, errNotConnected
	}
	if !s.usePublicAPI(ctx) && s.persistServerAPIChannelWhenStart(ctx) {
		return s.gitpodService.InstanceUpdates(ctx, instanceID)
	}
	updateChan := make(chan *gitpod.WorkspaceInstance)
	var latestInstance *gitpod.WorkspaceInstance
	go func() {
		for {
			if ctx.Err() != nil {
				close(updateChan)
				break
			}
			if instance, err := s.getWorkspaceInfo(ctx, instanceID, workspaceID); err == nil {
				if reflect.DeepEqual(latestInstance, instance) {
					continue
				}
				latestInstance = instance
				updateChan <- instance
			}
			time.Sleep(1 * time.Second)
		}
	}()
	return updateChan, nil
}

// GetOwnerID implements APIInterface
func (s *Service) GetOwnerID(ctx context.Context, workspaceID string) (ownerID string, err error) {
	if s == nil {
		return "", errNotConnected
	}
	if !s.usePublicAPI(ctx) {
		resp, err := s.gitpodService.GetWorkspace(ctx, workspaceID)
		if err != nil {
			return "", err
		}
		return resp.Workspace.OwnerID, nil
	}
	service := v1.NewWorkspacesServiceClient(s.publicAPIConn)
	resp, err := service.GetWorkspace(ctx, &v1.GetWorkspaceRequest{
		WorkspaceId: workspaceID,
	})
	if err != nil {
		return "", err
	}
	return resp.Result.OwnerId, nil
}

func (s *Service) TrackEvent(ctx context.Context, event *gitpod.RemoteTrackMessage) (err error) {
	if s == nil {
		return errNotConnected
	}
	return s.gitpodService.TrackEvent(ctx, event)
}

func (s *Service) RegisterMetrics(registry *prometheus.Registry) error {
	if s == nil {
		return errNotConnected
	}
	return registry.Register(s.publicApiMetrics)
}
