/*
Copyright 2020 The cert-manager 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 ctl

import (
	"bytes"
	"context"
	"fmt"
	"os"
	"path"
	"testing"
	"time"

	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/util/wait"
	"k8s.io/cli-runtime/pkg/genericclioptions"

	"github.com/cert-manager/cert-manager/cmd/ctl/pkg/create/certificaterequest"
	"github.com/cert-manager/cert-manager/cmd/ctl/pkg/factory"
	"github.com/cert-manager/cert-manager/integration-tests/framework"
	cmapiv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
	cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1"
	"github.com/cert-manager/cert-manager/pkg/util/pki"
)

type CreateCRTest struct {
	inputFile      string
	inputArgs      []string
	inputNamespace string
	keyFilename    string
	certFilename   string
	fetchCert      bool
	timeout        time.Duration
	crStatus       cmapiv1.CertificateRequestStatus

	expRunErr          bool
	expErrMsg          string
	expNamespace       string
	expName            string
	expKeyFilename     string
	expCertFilename    string
	expCertFileContent []byte
}

// TestCtlCreateCRBeforeCRIsCreated tests the behaviour in the case where the command fails
// after the private key has been written to file and before the CR is successfully created.
// Achieved by trying to create two CRs with the same name, storing the private key to two different files.
func TestCtlCreateCRBeforeCRIsCreated(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*40)
	defer cancel()

	config, stopFn := framework.RunControlPlane(t, ctx)
	defer stopFn()

	// Build clients
	kubernetesCl, _, cmCl, _ := framework.NewClients(t, config)

	testdataPath := getTestDataDir(t)

	const (
		cr5Name = "testcr-5"
		ns1     = "testns-1"
	)

	// Create Namespace
	ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns1}}
	_, err := kubernetesCl.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{})
	if err != nil {
		t.Fatal(err)
	}

	tests := map[string]CreateCRTest{
		"path to file to store private key provided": {
			inputFile:      path.Join(testdataPath, "create_cr_cert_with_ns1.yaml"),
			inputArgs:      []string{cr5Name},
			inputNamespace: ns1,
			keyFilename:    "test.key",
			expRunErr:      true,
			expErrMsg:      fmt.Sprintf("error creating CertificateRequest: certificaterequests.cert-manager.io %q already exists", cr5Name),
			expKeyFilename: "test.key",
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			streams, _, _, _ := genericclioptions.NewTestIOStreams()

			cleanUpFunc := setupPathForTest(t)
			defer cleanUpFunc()

			// Options to run create CR command
			opts := &certificaterequest.Options{
				InputFilename: test.inputFile,
				CertFileName:  test.certFilename,
				Factory: &factory.Factory{
					CMClient:         cmCl,
					RESTConfig:       config,
					Namespace:        test.inputNamespace,
					EnforceNamespace: test.inputNamespace != "",
				},
				IOStreams: streams,
			}

			err := opts.Run(ctx, test.inputArgs)
			if err != nil {
				t.Fatal("failed to set up test to fail after writing private key to file and during creating CR")
			}

			// By now we have created a CR already
			// Now we try to create another CR with the same name, but storing the private key somewhere else
			// This should break after writing private key to file and during creating CR
			opts.KeyFilename = test.keyFilename
			// Validating args and flags
			err = opts.Validate(test.inputArgs)
			if err != nil {
				t.Fatal(err)
			}

			// Run ctl create cr command with input options
			err = opts.Run(ctx, test.inputArgs)
			if err != nil {
				if !test.expRunErr {
					t.Errorf("got unexpected error when trying to create CR: %v", err)
				} else if err.Error() != test.expErrMsg {
					t.Errorf("got unexpected error when trying to create CR, expected: %v; actual: %v", test.expErrMsg, err)
				}
			} else {
				// got no error
				if test.expRunErr {
					t.Errorf("expected but got no error when creating CR")
				}
			}

			// Check the file where the private key is stored
			keyData, err := os.ReadFile(test.expKeyFilename)
			if err != nil {
				t.Errorf("error when reading file storing private key: %v", err)
			}
			_, err = pki.DecodePrivateKeyBytes(keyData)
			if err != nil {
				t.Errorf("invalid private key: %v", err)
			}

		})
	}
}

// TestCtlCreateCRSuccessful tests the behaviour in the case where the command successfully
// creates the CR, including the --fetch-certificate logic.
func TestCtlCreateCRSuccessful(t *testing.T) {
	rootCtx, cancelRoot := context.WithTimeout(context.Background(), time.Second*200)
	defer cancelRoot()

	config, stopFn := framework.RunControlPlane(t, rootCtx)
	defer stopFn()

	// Build clients
	kubernetesCl, _, cmCl, _ := framework.NewClients(t, config)

	testdataPath := getTestDataDir(t)

	const (
		cr1Name = "testcr-1"
		cr2Name = "testcr-2"
		cr5Name = "testcr-5"
		cr6Name = "testcr-6"
		cr7Name = "testcr-7"
		ns1     = "testns-1"
	)
	exampleCertificate := []byte(` `)

	// Create Namespace
	_, err := kubernetesCl.CoreV1().Namespaces().Create(rootCtx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns1}}, metav1.CreateOptions{})
	if err != nil {
		t.Fatal(err)
	}

	tests := map[string]CreateCRTest{
		"v1 Certificate given": {
			inputFile:      path.Join(testdataPath, "create_cr_cert_with_ns1.yaml"),
			inputArgs:      []string{cr1Name},
			inputNamespace: ns1,
			keyFilename:    "",
			expRunErr:      false,
			expNamespace:   ns1,
			expName:        cr1Name,
			expKeyFilename: cr1Name + ".key",
		},
		"v1alpha3 Certificate given": {
			inputFile:      path.Join(testdataPath, "create_cr_v1alpha3_cert_with_ns1.yaml"),
			inputArgs:      []string{cr2Name},
			inputNamespace: ns1,
			keyFilename:    "",
			expRunErr:      false,
			expNamespace:   ns1,
			expName:        cr2Name,
			expKeyFilename: cr2Name + ".key",
		},
		"path to file to store private key provided": {
			inputFile:      path.Join(testdataPath, "create_cr_cert_with_ns1.yaml"),
			inputArgs:      []string{cr5Name},
			inputNamespace: ns1,
			keyFilename:    "test.key",
			expRunErr:      false,
			expNamespace:   ns1,
			expName:        cr5Name,
			expKeyFilename: "test.key",
		},
		"fetch flag set and CR will be ready and status.certificate set": {
			inputFile:      path.Join(testdataPath, "create_cr_cert_with_ns1.yaml"),
			inputArgs:      []string{cr6Name},
			inputNamespace: ns1,
			keyFilename:    "",
			fetchCert:      true,
			timeout:        5 * time.Minute,
			crStatus: cmapiv1.CertificateRequestStatus{
				Conditions: []cmapiv1.CertificateRequestCondition{
					{Type: cmapiv1.CertificateRequestConditionReady, Status: cmmeta.ConditionTrue},
				},
				Certificate: exampleCertificate,
			},
			expRunErr:          false,
			expNamespace:       ns1,
			expName:            cr6Name,
			expKeyFilename:     cr6Name + ".key",
			expCertFilename:    cr6Name + ".crt",
			expCertFileContent: exampleCertificate,
		},
		"fetch flag set and CR will be ready but status.certificate empty": {
			inputFile:      path.Join(testdataPath, "create_cr_cert_with_ns1.yaml"),
			inputArgs:      []string{cr7Name},
			inputNamespace: ns1,
			keyFilename:    "",
			fetchCert:      true,
			timeout:        5 * time.Second,
			crStatus: cmapiv1.CertificateRequestStatus{
				Conditions: []cmapiv1.CertificateRequestCondition{
					{Type: cmapiv1.CertificateRequestConditionReady, Status: cmmeta.ConditionTrue},
				},
			},
			expRunErr:          true,
			expErrMsg:          "error when waiting for CertificateRequest to be signed: context deadline exceeded",
			expNamespace:       ns1,
			expName:            cr7Name,
			expKeyFilename:     cr7Name + ".key",
			expCertFilename:    cr7Name + ".crt",
			expCertFileContent: exampleCertificate,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			ctx, cancel := context.WithTimeout(rootCtx, time.Second*20)
			defer cancel()

			streams, _, _, _ := genericclioptions.NewTestIOStreams()

			cleanUpFunc := setupPathForTest(t)
			defer cleanUpFunc()

			// Options to run create CR command
			opts := &certificaterequest.Options{
				Factory: &factory.Factory{
					CMClient:         cmCl,
					RESTConfig:       config,
					Namespace:        test.inputNamespace,
					EnforceNamespace: test.inputNamespace != "",
				},
				IOStreams:     streams,
				InputFilename: test.inputFile,
				KeyFilename:   test.keyFilename,
				CertFileName:  test.certFilename,
				FetchCert:     test.fetchCert,
				Timeout:       test.timeout,
			}

			// Validating args and flags
			err := opts.Validate(test.inputArgs)
			if err != nil {
				t.Fatal(err)
			}

			if test.fetchCert {
				req := &cmapiv1.CertificateRequest{}
				// Start a goroutine that will periodically check whether the CertificateRequest has been created
				// by the CLI command yet.
				// Once it has been created, set the `status.certificate` and `Ready` condition so that the `--fetch-certificate`
				// part of the command is able to proceed.
				errCh := make(chan error)
				pollCtx, cancelPoll := context.WithCancel(ctx)
				defer func() {
					cancelPoll()
					err := <-errCh
					if err != nil {
						t.Fatal(err)
					}
				}()
				go func() {
					defer close(errCh)
					err = wait.PollUntilContextCancel(pollCtx, time.Second, true, func(ctx context.Context) (done bool, err error) {
						req, err = cmCl.CertmanagerV1().CertificateRequests(test.inputNamespace).Get(ctx, test.inputArgs[0], metav1.GetOptions{})
						if err != nil {
							return false, nil
						}
						return true, nil
					})
					if err != nil {
						errCh <- fmt.Errorf("timeout when waiting for CertificateRequest to be created, error: %v", err)
						return
					}

					// CR has been created, try update status
					req.Status.Conditions = test.crStatus.Conditions
					req.Status.Certificate = test.crStatus.Certificate
					req, err = cmCl.CertmanagerV1().CertificateRequests(test.inputNamespace).UpdateStatus(pollCtx, req, metav1.UpdateOptions{})
					if err != nil {
						errCh <- err
					}
				}()
			}

			// Run ctl create cr command with input options
			err = opts.Run(ctx, test.inputArgs)
			if err != nil {
				if !test.expRunErr {
					t.Errorf("got unexpected error when trying to create CR: %v", err)
				} else if err.Error() != test.expErrMsg {
					t.Errorf("got unexpected error when trying to create CR, expected: %v; actual: %v", test.expErrMsg, err)
				}
			} else {
				// got no error
				if test.expRunErr {
					t.Errorf("expected but got no error when creating CR")
				}
			}

			// Check the file where the private key is stored
			keyData, err := os.ReadFile(test.expKeyFilename)
			if err != nil {
				t.Errorf("error when reading file storing private key: %v", err)
			}
			_, err = pki.DecodePrivateKeyBytes(keyData)
			if err != nil {
				t.Errorf("invalid private key: %v", err)
			}

			// Finished creating CR, check if everything is expected
			crName := test.inputArgs[0]
			gotCr, err := cmCl.CertmanagerV1().CertificateRequests(test.inputNamespace).Get(ctx, crName, metav1.GetOptions{})
			if err != nil {
				t.Fatal(err)
			}

			if gotCr.Name != test.expName {
				t.Errorf("CR created has unexpected Name, expected: %s, actual: %s", test.expName, gotCr.Name)
			}

			if gotCr.Namespace != test.expNamespace {
				t.Errorf("CR created in unexpected Namespace, expected: %s, actual: %s", test.expNamespace, gotCr.Namespace)
			}

			// If applicable, check the file where the certificate is stored
			// If the expected error message is the one below, we skip checking
			// because no certificate will have been written to file
			if test.fetchCert && test.expErrMsg != "error when waiting for CertificateRequest to be signed: context deadline exceeded" {
				certData, err := os.ReadFile(test.expCertFilename)
				if err != nil {
					t.Errorf("error when reading file storing private key: %v", err)
				}

				if !bytes.Equal(test.expCertFileContent, certData) {
					t.Errorf("certificate written to file is wrong, expected: %s,\nactual: %s", test.expCertFileContent, certData)
				}
			}
		})
	}
}

func getTestDataDir(t *testing.T) string {
	testWorkingDirectory, err := os.Getwd()
	if err != nil {
		t.Fatal(err)
	}
	return path.Join(testWorkingDirectory, "testdata")
}

// setupPathForTest sets up a tmp directory and cd into it for tests as the command being tested creates files
// in the local directory.
// Returns a cleanup function which will change cd back to original working directory and remove the tmp directory.
func setupPathForTest(t *testing.T) func() {
	workingDirectoryBeforeTest, err := os.Getwd()
	if err != nil {
		t.Fatal(err)
	}

	// Create tmp directory and cd into it to store private key files
	tmpDir, err := os.MkdirTemp("", "tmp-ctl-test-*")
	if err != nil {
		t.Fatal(err)
	}

	if err := os.Chdir(tmpDir); err != nil {
		t.Fatal(err)
	}
	return func() {
		if err := os.Chdir(workingDirectoryBeforeTest); err != nil {
			t.Fatal(err)
		}
		if err := os.RemoveAll(tmpDir); err != nil {
			t.Fatal(err)
		}
	}
}
