// Copyright (c) 2020 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 manager

import (
	"context"
	"crypto/sha256"
	"encoding/json"
	"flag"
	"fmt"
	"net"
	"strings"
	"sync"
	"testing"
	"testing/fstest"
	"time"

	"github.com/golang/mock/gomock"
	"github.com/google/uuid"
	"golang.org/x/xerrors"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/test/bufconn"
	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/labels"
	"k8s.io/client-go/kubernetes"
	_ "k8s.io/client-go/plugin/pkg/client/auth/gcp"
	utilpointer "k8s.io/utils/pointer"
	"sigs.k8s.io/controller-runtime/pkg/client"
	ctrler_client "sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/envtest"
	"sigs.k8s.io/yaml"

	"github.com/gitpod-io/gitpod/common-go/log"
	"github.com/gitpod-io/gitpod/common-go/util"
	csapi "github.com/gitpod-io/gitpod/content-service/api"
	"github.com/gitpod-io/gitpod/content-service/pkg/layer"
	"github.com/gitpod-io/gitpod/content-service/pkg/storage"
	wsdaemon "github.com/gitpod-io/gitpod/ws-daemon/api"
	wsdaemon_mock "github.com/gitpod-io/gitpod/ws-daemon/api/mock"
	"github.com/gitpod-io/gitpod/ws-manager/api"
	config "github.com/gitpod-io/gitpod/ws-manager/api/config"
	"github.com/gitpod-io/gitpod/ws-manager/pkg/manager/internal/grpcpool"
	"github.com/gitpod-io/gitpod/ws-manager/pkg/test"

	volumesnapshotclientv1 "github.com/kubernetes-csi/external-snapshotter/client/v4/clientset/versioned"
)

var integrationFlag = flag.String("integration-test", "disabled", "configures integration tests. Valid values are disabled, local or a path to a kubeconfig file")

func init() {
	wsdaemonRetryInterval = 0
}

func forIntegrationTestGetManager(t *testing.T) *Manager {
	client, ns, err := test.GetIntegrationTestClient(*integrationFlag)
	if err != nil {
		t.Fatalf("cannot get integration test client: %q", err)
	}
	if client == nil {
		t.Skipf("integration tests disabled")
		return nil
	}

	config := config.Configuration{
		Namespace:                ns,
		HeartbeatInterval:        util.Duration(30 * time.Second),
		WorkspaceHostPath:        "/tmp",
		GitpodHostURL:            "gitpod.io",
		WorkspaceURLTemplate:     "{{ .ID }}-{{ .Prefix }}-{{ .Host }}",
		WorkspacePortURLTemplate: "{{ .Host }}:{{ .IngressPort }}",
		RegistryFacadeHost:       "registry-facade:8080",
		WorkspaceClasses: map[string]*config.WorkspaceClass{
			"": {
				Container: config.ContainerConfiguration{
					Limits: &config.ResourceLimitConfiguration{
						CPU: &config.CpuResourceLimit{
							MinLimit:   "600m",
							BurstLimit: "900m",
						},
						Memory: "1000M",
					},
					Requests: &config.ResourceRequestConfiguration{
						CPU:    "1m",
						Memory: "1m",
					},
				},
			},
		},
		Timeouts: config.WorkspaceTimeoutConfiguration{
			AfterClose:          util.Duration(1 * time.Minute),
			Initialization:      util.Duration(30 * time.Minute),
			TotalStartup:        util.Duration(45 * time.Minute),
			RegularWorkspace:    util.Duration(60 * time.Minute),
			MaxLifetime:         util.Duration(36 * time.Hour),
			HeadlessWorkspace:   util.Duration(90 * time.Minute),
			Stopping:            util.Duration(60 * time.Minute),
			ContentFinalization: util.Duration(15 * time.Minute),
			Interrupted:         util.Duration(5 * time.Minute),
		},
		EventTraceLog: fmt.Sprintf("/tmp/evts-%x.json", sha256.Sum256([]byte(t.Name()))),
	}

	testEnv := &envtest.Environment{}
	cfg, err := testEnv.Start()
	if err != nil {
		t.Errorf("cannot create test environment: %v", err)
		return nil
	}

	t.Cleanup(func() {
		_ = testEnv.Stop()
	})

	clientset, err := kubernetes.NewForConfig(cfg)
	if err != nil {
		t.Errorf("cannot create test environment: %v", err)
		return nil
	}

	volumesnapshotclientset, err := volumesnapshotclientv1.NewForConfig(cfg)
	if err != nil {
		t.Errorf("cannt create test environment: %v", err)
		return nil
	}

	ctrlClient, err := ctrler_client.New(cfg, ctrler_client.Options{Scheme: scheme})
	if err != nil {
		t.Errorf("cannot create test environment: %v", err)
		return nil
	}

	m, err := New(config, ctrlClient, clientset, volumesnapshotclientset, &layer.Provider{Storage: &storage.PresignedNoopStorage{}})
	if err != nil {
		t.Fatalf("cannot create manager: %s", err.Error())
	}
	// we don't have propr DNS resolution and network access - and we cannot mock it
	m.Config.InitProbe.Disabled = true

	t.Cleanup(func() {
		_ = testEnv.Stop()
	})

	return m
}

func NewStatusRecorder(t *testing.T) *StatusRecoder {
	res := &StatusRecoder{t: t, inc: make(chan api.WorkspaceStatus)}
	go res.run()
	return res
}

type StatusRecoder struct {
	t *testing.T

	log    []api.WorkspaceStatus
	waiter map[string]*statusWaiter
	inc    chan api.WorkspaceStatus
	mu     sync.Mutex
}

func (r *StatusRecoder) run() {
	for s := range r.inc {
		r.log = append(r.log, s)

		r.mu.Lock()
		for k, w := range r.waiter {
			if w.P(&s) {
				close(w.C)
				delete(r.waiter, k)
			}
		}
		r.mu.Unlock()
	}
}

func (r *StatusRecoder) String() string {
	res := make([]string, len(r.log))
	for i, u := range r.log {
		up, err := json.Marshal(u)
		if err != nil {
			res[i] = fmt.Sprintf("phase:%d %q", u.Phase, err)
			continue
		}
		res[i] = fmt.Sprintf("phase:%d %s", u.Phase, up)
	}
	return strings.Join(res, "\n")
}

type statusWaiter struct {
	C chan struct{}
	P StatusWaitFunc
}

type StatusWaitFunc func(*api.WorkspaceStatus) bool

func (r *StatusRecoder) Send(resp *api.SubscribeResponse) (err error) {
	status := resp.GetStatus()
	if status == nil {
		return
	}

	r.inc <- *status
	return
}

func (r *StatusRecoder) WaitFor(p StatusWaitFunc, timeout time.Duration) (ok bool) {
	r.mu.Lock()
	if r.waiter == nil {
		r.waiter = make(map[string]*statusWaiter)
	}
	w := &statusWaiter{C: make(chan struct{}), P: p}
	r.waiter[uuid.New().String()] = w
	r.mu.Unlock()

	if timeout == 0 {
		<-w.C
		return true
	}

	select {
	case <-w.C:
		return true
	case <-time.After(timeout):
		return false
	}
}

func (r *StatusRecoder) Log() []api.WorkspaceStatus {
	r.mu.Lock()
	defer r.mu.Unlock()

	return r.log
}

func ensureIntegrationTestTheiaLabelOnNodes(clientset client.Client, namespace string) (version string, err error) {
	version = "wsman-test"

	ctx, cancel := context.WithTimeout(context.Background(), kubernetesOperationTimeout)
	defer cancel()

	selector, err := labels.Parse(fmt.Sprintf("gitpod.io/theia.%s", version))
	if err != nil {
		return "", xerrors.Errorf("cannot build label selector for theia: %w", err)
	}

	var nodes corev1.NodeList
	err = clientset.List(ctx, &nodes,
		client.InNamespace(namespace),
		client.MatchingLabelsSelector{Selector: selector},
	)

	if err != nil {
		log.WithError(err).Warnf("cannot list nodes to check if one has the gitpod.io/theia.%s label", version)
		return "wsman-test", nil
	}

	if len(nodes.Items) == 0 {
		return "", xerrors.Errorf("no nodes with the gitpod.io/theia.%s label available", version)
	}

	return
}

func connectToMockWsdaemon(ctx context.Context, wsdaemonSrv wsdaemon.WorkspaceContentServiceServer) (*grpc.ClientConn, error) {
	lis := bufconn.Listen(1024 * 1024)
	srv := grpc.NewServer()
	wsdaemon.RegisterWorkspaceContentServiceServer(srv, wsdaemonSrv)
	go func() {
		err := srv.Serve(lis)
		if err != nil {
			panic(fmt.Sprintf("grpc failure: %q", err))
		}
	}()

	conn, err := grpc.DialContext(ctx, "bufnet", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return lis.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		return nil, err
	}
	return conn, nil
}

type IntegrationTestPodTemplates struct {
	Default  *corev1.Pod
	Prebuild *corev1.Pod
	Probe    *corev1.Pod
	Regular  *corev1.Pod
}

type SingleWorkspaceIntegrationTest struct {
	MockWsdaemon              func(t *testing.T, s *wsdaemon_mock.MockWorkspaceContentServiceServer)
	WsdaemonConnectionContext func() context.Context
	StartRequestModifier      func(t *testing.T, req *api.StartWorkspaceRequest)
	PostStart                 func(t *testing.T, monitor *Monitor, instanceID string, updates *StatusRecoder)
	PodTemplates              IntegrationTestPodTemplates
}

func (test *SingleWorkspaceIntegrationTest) FillDefaults() *SingleWorkspaceIntegrationTest {
	if test.MockWsdaemon == nil {
		test.MockWsdaemon = func(t *testing.T, s *wsdaemon_mock.MockWorkspaceContentServiceServer) {}
	}
	if test.WsdaemonConnectionContext == nil {
		test.WsdaemonConnectionContext = context.Background
	}
	if test.StartRequestModifier == nil {
		test.StartRequestModifier = func(t *testing.T, req *api.StartWorkspaceRequest) {}
	}
	if test.PostStart == nil {
		test.PostStart = func(t *testing.T, monitor *Monitor, instanceID string, updates *StatusRecoder) {}
	}
	return test
}

func (test *SingleWorkspaceIntegrationTest) Run(t *testing.T) {
	manager := forIntegrationTestGetManager(t)
	defer manager.Close()

	// create in-memory file system
	fs := fstest.MapFS{}

	updateDefaultTemplate := func(update func(*config.WorkspacePodTemplateConfiguration)) {
		c := manager.Config.WorkspaceClasses[""]
		tpls := c.Templates
		update(&tpls)
		c.Templates = tpls
		manager.Config.WorkspaceClasses[""] = c
	}
	files := []struct {
		tplfn  string
		ctnt   interface{}
		setter func(fn string)
	}{
		{"default-template.yaml", test.PodTemplates.Default, func(fn string) {
			updateDefaultTemplate(func(wptc *config.WorkspacePodTemplateConfiguration) { wptc.DefaultPath = fn })
		}},
		{"prebuild-template.yaml", test.PodTemplates.Prebuild, func(fn string) {
			updateDefaultTemplate(func(wptc *config.WorkspacePodTemplateConfiguration) { wptc.PrebuildPath = fn })
		}},
		{"probe-template.yaml", test.PodTemplates.Probe, func(fn string) {
			updateDefaultTemplate(func(wptc *config.WorkspacePodTemplateConfiguration) { wptc.ProbePath = fn })
		}},
		{"regular-template.yaml", test.PodTemplates.Regular, func(fn string) {
			updateDefaultTemplate(func(wptc *config.WorkspacePodTemplateConfiguration) { wptc.RegularPath = fn })
		}},
	}
	for _, f := range files {
		if f.ctnt == nil {
			continue
		}

		b, err := yaml.Marshal(f.ctnt)
		if err != nil {
			t.Fatalf("cannot re-marshal %s template: %q", f.tplfn, err)
		}

		fs[f.tplfn] = &fstest.MapFile{Data: b}

		f.setter(f.tplfn)
	}

	deployedVersion, err := ensureIntegrationTestTheiaLabelOnNodes(manager.Clientset, manager.Config.Namespace)
	if err != nil {
		t.Fatal(err)
	}

	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	manager.wsdaemonPool = grpcpool.New(func(host string) (*grpc.ClientConn, error) {
		s := wsdaemon_mock.NewMockWorkspaceContentServiceServer(ctrl)
		test.MockWsdaemon(t, s)
		ctx := test.WsdaemonConnectionContext()
		return connectToMockWsdaemon(ctx, s)
	}, func(checkAddress string) bool { return false })

	monitor, err := manager.CreateMonitor()
	if err != nil {
		t.Fatalf("cannot create monitor: %q", err)
	}
	monitor.OnError = func(err error) {
		t.Errorf("monitor error: %+q", err)
	}
	err = monitor.Start()
	if err != nil {
		t.Fatalf("cannot start monitor: %q", err)
	}
	defer monitor.Stop()

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

	updates := NewStatusRecorder(t)
	go func() {
		err := monitor.manager.subscribe(ctx, updates)
		if err != nil && err != context.Canceled {
			// different Go routine context - cannot use t here
			panic(fmt.Sprintf("subscription failed: %q", err))
		}
	}()

	var (
		instanceID    = uuid.New().String()
		workspaceID   = uuid.New().String()
		servicePrefix = uuid.New().String()
	)
	startreq := &api.StartWorkspaceRequest{
		Id: instanceID,
		Metadata: &api.WorkspaceMetadata{
			MetaId: workspaceID,
			Owner:  "integration-test",
		},
		ServicePrefix: servicePrefix,
		Type:          api.WorkspaceType_REGULAR,
		Spec: &api.StartWorkspaceSpec{
			Git: &api.GitSpec{
				Email:    "none@none.com",
				Username: "integration-test",
			},
			WorkspaceImage:    "gitpod/workspace-full:latest",
			IdeImage:          &api.IDEImage{WebRef: "gitpod/theia:" + deployedVersion},
			WorkspaceLocation: "/workspace",
			Initializer: &csapi.WorkspaceInitializer{
				Spec: &csapi.WorkspaceInitializer_Empty{Empty: &csapi.EmptyInitializer{}},
			},
		},
	}
	test.StartRequestModifier(t, startreq)
	_, err = manager.StartWorkspace(ctx, startreq)
	if err != nil {
		t.Errorf("cannot start test workspace: %q", err)
	}
	defer manager.Clientset.Delete(ctx,
		&corev1.Pod{
			ObjectMeta: metav1.ObjectMeta{
				Name:      fmt.Sprintf("ws-%s", instanceID),
				Namespace: manager.Config.Namespace,
			},
		},
		&client.DeleteOptions{GracePeriodSeconds: utilpointer.Int64Ptr(30)})

	test.PostStart(t, monitor, instanceID, updates)
}
