/*
Copyright 2016 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package envtest

import (
	"context"
	"fmt"
	"os"
	"strings"
	"time"

	corev1 "k8s.io/api/core/v1"
	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	"k8s.io/apimachinery/pkg/util/wait"
	"k8s.io/client-go/kubernetes/scheme"
	"k8s.io/client-go/rest"
	"sigs.k8s.io/controller-runtime/pkg/client"

	"sigs.k8s.io/controller-runtime/pkg/client/config"
	logf "sigs.k8s.io/controller-runtime/pkg/internal/log"
	"sigs.k8s.io/controller-runtime/pkg/internal/testing/controlplane"
	"sigs.k8s.io/controller-runtime/pkg/internal/testing/process"
)

var log = logf.RuntimeLog.WithName("test-env")

/*
It's possible to override some defaults, by setting the following environment variables:
* USE_EXISTING_CLUSTER (boolean): if set to true, envtest will use an existing cluster
* TEST_ASSET_KUBE_APISERVER (string): path to the api-server binary to use
* TEST_ASSET_ETCD (string): path to the etcd binary to use
* TEST_ASSET_KUBECTL (string): path to the kubectl binary to use
* KUBEBUILDER_ASSETS (string): directory containing the binaries to use (api-server, etcd and kubectl). Defaults to /usr/local/kubebuilder/bin.
* KUBEBUILDER_CONTROLPLANE_START_TIMEOUT (string supported by time.ParseDuration): timeout for test control plane to start. Defaults to 20s.
* KUBEBUILDER_CONTROLPLANE_STOP_TIMEOUT (string supported by time.ParseDuration): timeout for test control plane to start. Defaults to 20s.
* KUBEBUILDER_ATTACH_CONTROL_PLANE_OUTPUT (boolean): if set to true, the control plane's stdout and stderr are attached to os.Stdout and os.Stderr
*/
const (
	envUseExistingCluster = "USE_EXISTING_CLUSTER"
	envStartTimeout       = "KUBEBUILDER_CONTROLPLANE_START_TIMEOUT"
	envStopTimeout        = "KUBEBUILDER_CONTROLPLANE_STOP_TIMEOUT"
	envAttachOutput       = "KUBEBUILDER_ATTACH_CONTROL_PLANE_OUTPUT"
	StartTimeout          = 60
	StopTimeout           = 60

	defaultKubebuilderControlPlaneStartTimeout = 20 * time.Second
	defaultKubebuilderControlPlaneStopTimeout  = 20 * time.Second
)

// internal types we expose as part of our public API.
type (
	// ControlPlane is the re-exported ControlPlane type from the internal testing package.
	ControlPlane = controlplane.ControlPlane

	// APIServer is the re-exported APIServer from the internal testing package.
	APIServer = controlplane.APIServer

	// Etcd is the re-exported Etcd from the internal testing package.
	Etcd = controlplane.Etcd

	// User represents a Kubernetes user to provision for auth purposes.
	User = controlplane.User

	// AuthenticatedUser represets a Kubernetes user that's been provisioned.
	AuthenticatedUser = controlplane.AuthenticatedUser

	// ListenAddr indicates the address and port that the API server should listen on.
	ListenAddr = process.ListenAddr

	// SecureServing contains details describing how the API server should serve
	// its secure endpoint.
	SecureServing = controlplane.SecureServing

	// Authn is an authentication method that can be used with the control plane to
	// provision users.
	Authn = controlplane.Authn

	// Arguments allows configuring a process's flags.
	Arguments = process.Arguments

	// Arg is a single flag with one or more values.
	Arg = process.Arg
)

var (
	// EmptyArguments constructs a new set of flags with nothing set.
	//
	// This is mostly useful for testing helper methods -- you'll want to call
	// Configure on the APIServer (or etcd) to configure their arguments.
	EmptyArguments = process.EmptyArguments
)

// Environment creates a Kubernetes test environment that will start / stop the Kubernetes control plane and
// install extension APIs.
type Environment struct {
	// ControlPlane is the ControlPlane including the apiserver and etcd
	ControlPlane controlplane.ControlPlane

	// Scheme is used to determine if conversion webhooks should be enabled
	// for a particular CRD / object.
	//
	// Conversion webhooks are going to be enabled if an object in the scheme
	// implements Hub and Spoke conversions.
	//
	// If nil, scheme.Scheme is used.
	Scheme *runtime.Scheme

	// Config can be used to talk to the apiserver.  It's automatically
	// populated if not set using the standard controller-runtime config
	// loading.
	Config *rest.Config

	// KubeConfig provides []byte of a kubeconfig file to talk to the apiserver
	// It's automatically populated if not set based on the `Config`
	KubeConfig []byte

	// CRDInstallOptions are the options for installing CRDs.
	CRDInstallOptions CRDInstallOptions

	// WebhookInstallOptions are the options for installing webhooks.
	WebhookInstallOptions WebhookInstallOptions

	// ErrorIfCRDPathMissing provides an interface for the underlying
	// CRDInstallOptions.ErrorIfPathMissing. It prevents silent failures
	// for missing CRD paths.
	ErrorIfCRDPathMissing bool

	// CRDs is a list of CRDs to install.
	// If both this field and CRDs field in CRDInstallOptions are specified, the
	// values are merged.
	CRDs []*apiextensionsv1.CustomResourceDefinition

	// CRDDirectoryPaths is a list of paths containing CRD yaml or json configs.
	// If both this field and Paths field in CRDInstallOptions are specified, the
	// values are merged.
	CRDDirectoryPaths []string

	// DownloadBinaryAssets indicates that the envtest binaries should be downloaded.
	// If BinaryAssetsDirectory is also set, it is used to store the downloaded binaries,
	// otherwise a tmp directory is created.
	DownloadBinaryAssets bool

	// DownloadBinaryAssetsVersion is the version of envtest binaries to download.
	// Defaults to the latest stable version (i.e. excluding alpha / beta / RC versions).
	DownloadBinaryAssetsVersion string

	// DownloadBinaryAssetsIndexURL is the index used to discover envtest binaries to download.
	// Defaults to https://raw.githubusercontent.com/kubernetes-sigs/controller-tools/HEAD/envtest-releases.yaml.
	DownloadBinaryAssetsIndexURL string

	// BinaryAssetsDirectory is the path where the binaries required for the envtest are
	// located in the local environment. This field can be overridden by setting KUBEBUILDER_ASSETS.
	// Set this field to SetupEnvtestDefaultBinaryAssetsDirectory() to share binaries with setup-envtest.
	BinaryAssetsDirectory string

	// UseExistingCluster indicates that this environments should use an
	// existing kubeconfig, instead of trying to stand up a new control plane.
	// This is useful in cases that need aggregated API servers and the like.
	UseExistingCluster *bool

	// ControlPlaneStartTimeout is the maximum duration each controlplane component
	// may take to start. It defaults to the KUBEBUILDER_CONTROLPLANE_START_TIMEOUT
	// environment variable or 20 seconds if unspecified
	ControlPlaneStartTimeout time.Duration

	// ControlPlaneStopTimeout is the maximum duration each controlplane component
	// may take to stop. It defaults to the KUBEBUILDER_CONTROLPLANE_STOP_TIMEOUT
	// environment variable or 20 seconds if unspecified
	ControlPlaneStopTimeout time.Duration

	// AttachControlPlaneOutput indicates if control plane output will be attached to os.Stdout and os.Stderr.
	// Enable this to get more visibility of the testing control plane.
	// It respect KUBEBUILDER_ATTACH_CONTROL_PLANE_OUTPUT environment variable.
	AttachControlPlaneOutput bool
}

// Stop stops a running server.
// Previously installed CRDs, as listed in CRDInstallOptions.CRDs, will be uninstalled
// if CRDInstallOptions.CleanUpAfterUse are set to true.
func (te *Environment) Stop() error {
	if te.CRDInstallOptions.CleanUpAfterUse {
		if err := UninstallCRDs(te.Config, te.CRDInstallOptions); err != nil {
			return err
		}
	}

	if err := te.WebhookInstallOptions.Cleanup(); err != nil {
		return err
	}

	if te.useExistingCluster() {
		return nil
	}

	return te.ControlPlane.Stop()
}

// Start starts a local Kubernetes server and updates te.ApiserverPort with the port it is listening on.
func (te *Environment) Start() (*rest.Config, error) {
	if te.useExistingCluster() {
		log.V(1).Info("using existing cluster")
		if te.Config == nil {
			// we want to allow people to pass in their own config, so
			// only load a config if it hasn't already been set.
			log.V(1).Info("automatically acquiring client configuration")

			var err error
			te.Config, err = config.GetConfig()
			if err != nil {
				return nil, fmt.Errorf("unable to get configuration for existing cluster: %w", err)
			}
		}
	} else {
		apiServer := te.ControlPlane.GetAPIServer()

		if te.ControlPlane.Etcd == nil {
			te.ControlPlane.Etcd = &controlplane.Etcd{}
		}

		if os.Getenv(envAttachOutput) == "true" {
			te.AttachControlPlaneOutput = true
		}
		if te.AttachControlPlaneOutput {
			if apiServer.Out == nil {
				apiServer.Out = os.Stdout
			}
			if apiServer.Err == nil {
				apiServer.Err = os.Stderr
			}
			if te.ControlPlane.Etcd.Out == nil {
				te.ControlPlane.Etcd.Out = os.Stdout
			}
			if te.ControlPlane.Etcd.Err == nil {
				te.ControlPlane.Etcd.Err = os.Stderr
			}
		}

		if te.DownloadBinaryAssets {
			apiServerPath, etcdPath, kubectlPath, err := downloadBinaryAssets(context.TODO(),
				te.BinaryAssetsDirectory, te.DownloadBinaryAssetsVersion, te.DownloadBinaryAssetsIndexURL)
			if err != nil {
				return nil, err
			}

			apiServer.Path = apiServerPath
			te.ControlPlane.Etcd.Path = etcdPath
			te.ControlPlane.KubectlPath = kubectlPath
		} else {
			apiServer.Path = process.BinPathFinder("kube-apiserver", te.BinaryAssetsDirectory)
			te.ControlPlane.Etcd.Path = process.BinPathFinder("etcd", te.BinaryAssetsDirectory)
			te.ControlPlane.KubectlPath = process.BinPathFinder("kubectl", te.BinaryAssetsDirectory)
		}

		if err := te.defaultTimeouts(); err != nil {
			return nil, fmt.Errorf("failed to default controlplane timeouts: %w", err)
		}
		te.ControlPlane.Etcd.StartTimeout = te.ControlPlaneStartTimeout
		te.ControlPlane.Etcd.StopTimeout = te.ControlPlaneStopTimeout
		apiServer.StartTimeout = te.ControlPlaneStartTimeout
		apiServer.StopTimeout = te.ControlPlaneStopTimeout

		log.V(1).Info("starting control plane")
		if err := te.startControlPlane(); err != nil {
			return nil, fmt.Errorf("unable to start control plane itself: %w", err)
		}

		// Create the *rest.Config for creating new clients
		baseConfig := &rest.Config{
			// gotta go fast during tests -- we don't really care about overwhelming our test API server
			QPS:   1000.0,
			Burst: 2000.0,
		}

		adminInfo := User{Name: "admin", Groups: []string{"system:masters"}}
		adminUser, err := te.ControlPlane.AddUser(adminInfo, baseConfig)
		if err != nil {
			return te.Config, fmt.Errorf("unable to provision admin user: %w", err)
		}
		te.Config = adminUser.Config()
	}

	if len(te.KubeConfig) == 0 {
		var err error
		te.KubeConfig, err = controlplane.KubeConfigFromREST(te.Config)
		if err != nil {
			return nil, fmt.Errorf("unable to set KubeConfig field: %w", err)
		}
	}

	// Set the default scheme if nil.
	if te.Scheme == nil {
		te.Scheme = scheme.Scheme
	}

	// If we are bringing etcd up for the first time, it can take some time for the
	// default namespace to actually be created and seen as available to the apiserver
	if err := te.waitForDefaultNamespace(te.Config); err != nil {
		return nil, fmt.Errorf("default namespace didn't register within deadline: %w", err)
	}

	// Call PrepWithoutInstalling to setup certificates first
	// and have them available to patch CRD conversion webhook as well.
	if err := te.WebhookInstallOptions.PrepWithoutInstalling(); err != nil {
		return nil, err
	}

	log.V(1).Info("installing CRDs")
	if te.CRDInstallOptions.Scheme == nil {
		te.CRDInstallOptions.Scheme = te.Scheme
	}
	te.CRDInstallOptions.CRDs = mergeCRDs(te.CRDInstallOptions.CRDs, te.CRDs)
	te.CRDInstallOptions.Paths = mergePaths(te.CRDInstallOptions.Paths, te.CRDDirectoryPaths)
	te.CRDInstallOptions.ErrorIfPathMissing = te.ErrorIfCRDPathMissing
	te.CRDInstallOptions.WebhookOptions = te.WebhookInstallOptions
	crds, err := InstallCRDs(te.Config, te.CRDInstallOptions)
	if err != nil {
		return te.Config, fmt.Errorf("unable to install CRDs onto control plane: %w", err)
	}
	te.CRDs = crds

	log.V(1).Info("installing webhooks")
	if err := te.WebhookInstallOptions.Install(te.Config); err != nil {
		return nil, fmt.Errorf("unable to install webhooks onto control plane: %w", err)
	}
	return te.Config, nil
}

// AddUser provisions a new user for connecting to this Environment.  The user will
// have the specified name & belong to the specified groups.
//
// If you specify a "base" config, the returned REST Config will contain those
// settings as well as any required by the authentication method.  You can use
// this to easily specify options like QPS.
//
// This is effectively a convinience alias for ControlPlane.AddUser -- see that
// for more low-level details.
func (te *Environment) AddUser(user User, baseConfig *rest.Config) (*AuthenticatedUser, error) {
	return te.ControlPlane.AddUser(user, baseConfig)
}

func (te *Environment) startControlPlane() error {
	numTries, maxRetries := 0, 5
	var err error
	for ; numTries < maxRetries; numTries++ {
		// Start the control plane - retry if it fails
		err = te.ControlPlane.Start()
		if err == nil {
			break
		}
		log.Error(err, "unable to start the controlplane", "tries", numTries)
	}
	if numTries == maxRetries {
		return fmt.Errorf("failed to start the controlplane. retried %d times: %w", numTries, err)
	}
	return nil
}

func (te *Environment) waitForDefaultNamespace(config *rest.Config) error {
	cs, err := client.New(config, client.Options{})
	if err != nil {
		return fmt.Errorf("unable to create client: %w", err)
	}
	// It shouldn't take longer than 5s for the default namespace to be brought up in etcd
	return wait.PollUntilContextTimeout(context.TODO(), time.Millisecond*50, time.Second*5, true, func(ctx context.Context) (bool, error) {
		if err = cs.Get(ctx, types.NamespacedName{Name: "default"}, &corev1.Namespace{}); err != nil {
			return false, nil //nolint:nilerr
		}
		return true, nil
	})
}

func (te *Environment) defaultTimeouts() error {
	var err error
	if te.ControlPlaneStartTimeout == 0 {
		if envVal := os.Getenv(envStartTimeout); envVal != "" {
			te.ControlPlaneStartTimeout, err = time.ParseDuration(envVal)
			if err != nil {
				return err
			}
		} else {
			te.ControlPlaneStartTimeout = defaultKubebuilderControlPlaneStartTimeout
		}
	}

	if te.ControlPlaneStopTimeout == 0 {
		if envVal := os.Getenv(envStopTimeout); envVal != "" {
			te.ControlPlaneStopTimeout, err = time.ParseDuration(envVal)
			if err != nil {
				return err
			}
		} else {
			te.ControlPlaneStopTimeout = defaultKubebuilderControlPlaneStopTimeout
		}
	}
	return nil
}

func (te *Environment) useExistingCluster() bool {
	if te.UseExistingCluster == nil {
		return strings.ToLower(os.Getenv(envUseExistingCluster)) == "true"
	}
	return *te.UseExistingCluster
}

// DefaultKubeAPIServerFlags exposes the default args for the APIServer so that
// you can use those to append your own additional arguments.
//
// Deprecated: use APIServer.Configure() instead.
var DefaultKubeAPIServerFlags = controlplane.APIServerDefaultArgs //nolint:staticcheck
