package it

import (
	"bytes"
	"context"
	"crypto/rand"
	"fmt"
	"os"
	"reflect"
	"runtime"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/containerd/errdefs"
	"github.com/docker/docker/api/types/container"
	"github.com/stretchr/testify/suite"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/log"
	"github.com/testcontainers/testcontainers-go/modules/k3s"
	"github.com/testcontainers/testcontainers-go/modules/redis"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/api"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/managed_resources"
	managed_resources_rpc "gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/managed_resources/rpc"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/cryptoz"
	"go.uber.org/mock/gomock"
	"golang.org/x/sync/errgroup"
	corev1 "k8s.io/api/core/v1"
	k8serrors "k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/api/meta"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/util/sets"
	"k8s.io/apimachinery/pkg/util/wait"
	"k8s.io/client-go/discovery"
	"k8s.io/client-go/dynamic"
	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/restmapper"
	"k8s.io/client-go/tools/clientcmd"
	"k8s.io/utils/ptr"
	"sigs.k8s.io/yaml"
)

const (
	// https://hub.docker.com/r/rancher/k3s/tags
	imageK3s = "docker.io/rancher/k3s:v1.33.3-k3s1"
	// https://hub.docker.com/_/redis
	// https://docs.gitlab.com/install/requirements/#redis
	imageRedis = "docker.io/redis:7-alpine"

	agentkAndKASImageRepo = "repo.local/agentk_and_kas"
	agentkAndKASImageTag  = "latest"
	agentkAndKASImage     = agentkAndKASImageRepo + ":" + agentkAndKASImageTag

	nsLabel = "test-ns"
)

type integrationSuite struct {
	suite.Suite

	// START suite-wide state

	suiteCancel     context.CancelFunc
	k3sC            *k3s.K3sContainer
	redisC          *redis.RedisContainer
	agentC          testcontainers.Container
	agentCInspect   *container.InspectResponse
	k8s             kubernetes.Interface
	k8sDyn          dynamic.Interface
	discoveryClient discovery.AggregatedDiscoveryInterface

	// END suite-wide state

	// START test-specific state

	ctx       context.Context
	ctxCancel context.CancelFunc
	// kas->GitLab secret used to sign JWT tokens for authn.
	gitLabAccessSecret []byte
	// GitLab->kas secret used to sign JWT tokens for authn.
	kasAPIServerSecret      []byte
	kasPrivateAPISecret     []byte
	kasWebsocketTokenSecret []byte
	// Containers that were started during the test.
	startedContainers []testcontainers.Container
	cachedRESTMapper  func() meta.RESTMapper

	mu           sync.Mutex
	cleanupFuncs []func()
	// Kubernetes namespace
	ns    string
	nsObj *corev1.Namespace
	wg    wait.Group

	// mocks

	ctrl    *gomock.Controller
	gitLabM *MockGitLabAPI
	gitalyM *MockGitalyAPI

	// END test-specific state
}

func (s *integrationSuite) SetupSuite() {
	s.T().Log("SetupSuite() start")
	suiteCtx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
	s.suiteCancel = cancel

	success := false
	defer func() {
		if success {
			return
		}
		cancel()
		s.TearDownSuite() // tear everything down if setup failed.
	}()

	var err error
	var g errgroup.Group
	g.Go(s.startK3s(suiteCtx))
	g.Go(s.startRedis(suiteCtx))
	g.Go(s.buildAgentC(suiteCtx))
	err = g.Wait()
	s.Require().NoError(err)

	s.agentCInspect, err = s.agentC.Inspect(suiteCtx)
	s.Require().NoError(err)

	err = s.k3sC.LoadImages(suiteCtx, agentkAndKASImage) // load agent+kas image into the K3s node
	s.Require().NoError(err)

	kubeConfigYaml, err := s.k3sC.GetKubeConfig(suiteCtx)
	s.Require().NoError(err)

	restcfg, err := clientcmd.RESTConfigFromKubeConfig(kubeConfigYaml)
	s.Require().NoError(err)

	s.k8s, err = kubernetes.NewForConfig(restcfg)
	s.Require().NoError(err)

	s.k8sDyn, err = dynamic.NewForConfig(restcfg)
	s.Require().NoError(err)

	s.discoveryClient, err = discovery.NewDiscoveryClientForConfig(restcfg)
	s.Require().NoError(err)

	success = true
	s.T().Log("SetupSuite() done")
}

func (s *integrationSuite) freshRESTMapper() meta.RESTMapper {
	groupResources, err := restmapper.GetAPIGroupResources(s.discoveryClient)
	s.Require().NoError(err)

	return restmapper.NewDiscoveryRESTMapper(groupResources)
}

func (s *integrationSuite) SetupTest() {
	s.T().Log("SetupTest() start")
	s.ctx, s.ctxCancel = context.WithTimeout(context.Background(), 5*time.Minute)
	s.cachedRESTMapper = sync.OnceValue(s.freshRESTMapper)

	s.gitLabAccessSecret = make([]byte, cryptoz.HMACSHA256MaxKeySize)
	_, err := rand.Read(s.gitLabAccessSecret)
	s.Require().NoError(err)

	s.kasAPIServerSecret = make([]byte, cryptoz.HMACSHA256MaxKeySize)
	_, err = rand.Read(s.kasAPIServerSecret)
	s.Require().NoError(err)

	s.kasPrivateAPISecret = make([]byte, cryptoz.HMACSHA256MaxKeySize)
	_, err = rand.Read(s.kasPrivateAPISecret)
	s.Require().NoError(err)

	s.kasWebsocketTokenSecret = make([]byte, cryptoz.HMACSHA3_512MaxKeySize)
	_, err = rand.Read(s.kasWebsocketTokenSecret)
	s.Require().NoError(err)

	s.ctrl = gomock.NewController(s.T())
	s.gitLabM = NewMockGitLabAPI(s.ctrl)
	s.gitalyM = NewMockGitalyAPI(s.ctrl)

	// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names
	s.ns = s.T().Name()
	s.ns = strings.NewReplacer("/", "-", "_", "-").Replace(s.ns)
	s.ns = strings.ToLower(s.ns)
	if len(s.ns) > 63 {
		s.ns = s.ns[:63] // max namespace length
	}

	s.nsObj = s.createNS(s.ns, nsLabel)
	s.wg.Start(func() {
		s.logNamespaceEvents(s.ns)
	})

	s.T().Log("SetupTest() done")
}

func (s *integrationSuite) TearDownSuite() {
	s.T().Log("TearDownSuite() start")
	cnt := []testcontainers.Container{s.k3sC, s.agentC, s.redisC} // order is important
	for _, c := range cnt {
		if c == nil || reflect.ValueOf(c).IsNil() { // handle typed nils
			continue
		}
		s.terminateContainer(c)
	}
	s.suiteCancel()
	s.T().Log("TearDownSuite() done")
}

func (s *integrationSuite) TearDownTest() {
	s.T().Log("TearDownTest() start")
	for _, c := range s.startedContainers {
		s.terminateContainer(c)
	}
	s.startedContainers = nil
	s.ctxCancel()
	s.doCleanup()
	s.wg.Wait()
	s.deleteNS(s.ns)
	s.T().Log("TearDownTest() done")
}

func (s *integrationSuite) terminateContainer(c testcontainers.Container) {
	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
	defer cancel()
	err := c.Stop(ctx, nil)
	s.NoError(err)
	err = c.Terminate(ctx)
	if !errdefs.IsNotFound(err) { // ignore weird not found errors
		s.NoError(err)
	}
}

func (s *integrationSuite) containerToTerminate(c testcontainers.Container) {
	s.startedContainers = append(s.startedContainers, c)
}

func (s *integrationSuite) startK3s(ctx context.Context) func() error {
	const k3sLog = false
	return func() error {
		var err error
		s.k3sC, err = k3s.Run(ctx, imageK3s,
			testcontainers.WithLogger(log.TestLogger(s.T())),
			testcontainers.CustomizeRequestOption(func(req *testcontainers.GenericContainerRequest) error {
				if k3sLog {
					req.LogConsumerCfg = &testcontainers.LogConsumerConfig{
						Consumers: []testcontainers.LogConsumer{
							&testLogConsumer{t: s.T(), prefix: "k3sC:"},
						},
					}
				}
				return nil
			}),
		)
		return err
	}
}

func (s *integrationSuite) startRedis(ctx context.Context) func() error {
	const redisLog = false
	return func() error {
		var err error
		s.redisC, err = redis.Run(ctx, imageRedis,
			testcontainers.WithLogger(log.TestLogger(s.T())),
			testcontainers.CustomizeRequestOption(func(req *testcontainers.GenericContainerRequest) error {
				if redisLog {
					req.LogConsumerCfg = &testcontainers.LogConsumerConfig{
						Consumers: []testcontainers.LogConsumer{
							&testLogConsumer{t: s.T(), prefix: "redisC:"},
						},
					}
				}
				return nil
			}),
		)
		return err
	}
}

func (s *integrationSuite) buildAgentC(ctx context.Context) func() error {
	const buildLog = false
	return func() error {
		var err error
		var logCfg *testcontainers.LogConsumerConfig
		if buildLog {
			logCfg = &testcontainers.LogConsumerConfig{
				Consumers: []testcontainers.LogConsumer{
					&testLogConsumer{t: s.T(), prefix: "buildAgentC:"},
				},
			}
		}
		s.agentC, err = testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
			ContainerRequest: testcontainers.ContainerRequest{
				FromDockerfile: testcontainers.FromDockerfile{
					Context:    "../..",
					Dockerfile: "internal/it/Dockerfile",
					Repo:       agentkAndKASImageRepo,
					Tag:        agentkAndKASImageTag,
					BuildArgs: map[string]*string{
						"TARGETPLATFORM": ptr.To("linux/" + runtime.GOARCH),
						"TARGETARCH":     ptr.To(runtime.GOARCH),
						"GO_VERSION":     ptr.To(strings.TrimPrefix(runtime.Version(), "go")),
					},
					BuildLogWriter: os.Stderr,
					KeepImage:      true,
				},
				Name:           "kas_and_agentk",
				LogConsumerCfg: logCfg,
			},
			Started: false,
			Logger:  log.TestLogger(s.T()),
			Reuse:   true,
		})
		return err
	}
}

// cleanup registers a func to be called after test context has been canceled.
// Functions are called in LIFO order.
func (s *integrationSuite) cleanup(f func()) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.cleanupFuncs = append(s.cleanupFuncs, f)
}

// doCleanup calls cleanup functions in LIFO order.
func (s *integrationSuite) doCleanup() {
	for {
		var funcs []func()
		func() {
			s.mu.Lock()
			defer s.mu.Unlock()
			funcs = make([]func(), len(s.cleanupFuncs))
			copy(funcs, s.cleanupFuncs)
			s.cleanupFuncs = nil
		}()
		l := len(funcs)
		if l == 0 {
			return
		}
		for i := l - 1; i >= 0; i-- {
			funcs[i]()
		}
	}
}

func (s *integrationSuite) assertObjectsBelongToEnvs(objs []*managed_resources_rpc.Object, infos ...*managed_resources.TemplatingInfo) {
	mapper := s.cachedRESTMapper()
	for _, obj := range objs {
		mapping, err := mapper.RESTMapping(obj.GroupKind(), obj.Version)
		s.Require().NoError(err)
		objResp, err := s.k8sDyn.
			Resource(mapping.Resource).
			Namespace(obj.Namespace).
			Get(s.ctx, obj.Name, metav1.GetOptions{})
		s.Require().NoErrorf(err, "Object info: %s, ns=%s, n=%s", obj.GroupVersionKind(), obj.Namespace, obj.Name)
		objBytes, err := yaml.Marshal(objResp.Object)
		s.Require().NoError(err)
		s.T().Log("Found Kubernetes object:\n", string(objBytes))
		for _, info := range infos {
			// Matches renderer
			envInfoKey := fmt.Sprintf("%s/env-%s-%d-%d",
				api.AgentKeyPrefix,
				info.Environment.Slug,
				info.Project.Id,
				info.Agent.Id,
			)
			s.Require().Contains(objResp.GetLabels(), envInfoKey)
			s.Require().Contains(objResp.GetAnnotations(), envInfoKey)
		}
	}
}

func (s *integrationSuite) assertObjectsDoNotExist(objs ...*managed_resources_rpc.Object) {
	mapper := s.cachedRESTMapper()
	for _, obj := range objs {
		s.T().Log("Checking if object does not exist", obj.GroupVersionKind(), obj.Namespace, obj.Name)
		mapping, err := mapper.RESTMapping(obj.GroupKind(), obj.Version)
		s.Require().NoError(err)
		_, err = s.k8sDyn.
			Resource(mapping.Resource).
			Namespace(obj.Namespace).
			Get(s.ctx, obj.Name, metav1.GetOptions{})
		s.Require().True(k8serrors.IsNotFound(err), "Expected a NotFound error", err)
	}
}

type testLogConsumer struct {
	t      *testing.T
	prefix string
}

func (c *testLogConsumer) Accept(log testcontainers.Log) {
	c.t.Log(c.prefix, string(bytes.TrimRight(log.Content, "\r\n")))
}

// object is the same as managed_resources_rpc.Object but comparable.
type object struct {
	group     string
	version   string
	kind      string
	namespace string
	name      string
}

func rpcObj2setOfObjs(objs []*managed_resources_rpc.Object) sets.Set[object] {
	res := make(sets.Set[object], len(objs))
	for _, obj := range objs {
		res.Insert(object{
			group:     obj.Group,
			version:   obj.Version,
			kind:      obj.Kind,
			namespace: obj.Namespace,
			name:      obj.Name,
		})
	}
	return res
}

func setOfObjs2rpcObj(objs sets.Set[object]) []*managed_resources_rpc.Object {
	res := make([]*managed_resources_rpc.Object, 0, len(objs))
	for obj := range objs {
		res = append(res, &managed_resources_rpc.Object{
			Group:     obj.group,
			Version:   obj.version,
			Kind:      obj.kind,
			Namespace: obj.namespace,
			Name:      obj.name,
		})
	}
	return res
}
