/*
Copyright (c) Edgeless Systems GmbH

SPDX-License-Identifier: AGPL-3.0-only
*/

package terraform

import (
	"context"
	"errors"
	"io/fs"
	"path"
	"path/filepath"
	"strings"
	"testing"

	"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
	"github.com/edgelesssys/constellation/v2/internal/constants"
	"github.com/edgelesssys/constellation/v2/internal/file"
	"github.com/hashicorp/terraform-exec/tfexec"
	tfjson "github.com/hashicorp/terraform-json"
	"github.com/spf13/afero"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"go.uber.org/multierr"
)

func TestPrepareCluster(t *testing.T) {
	qemuVars := &QEMUVariables{
		CommonVariables: CommonVariables{
			Name:               "name",
			CountControlPlanes: 1,
			CountWorkers:       2,
			StateDiskSizeGB:    11,
		},
		CPUCount:         1,
		MemorySizeMiB:    1024,
		ImagePath:        "path",
		ImageFormat:      "format",
		MetadataAPIImage: "api",
	}

	testCases := map[string]struct {
		pathBase           string
		provider           cloudprovider.Provider
		vars               Variables
		fs                 afero.Fs
		partiallyExtracted bool
		wantErr            bool
	}{
		"qemu": {
			pathBase: "terraform",
			provider: cloudprovider.QEMU,
			vars:     qemuVars,
			fs:       afero.NewMemMapFs(),
			wantErr:  false,
		},
		"no vars": {
			pathBase: "terraform",
			provider: cloudprovider.QEMU,
			fs:       afero.NewMemMapFs(),
			wantErr:  true,
		},
		"continue on partially extracted": {
			pathBase:           "terraform",
			provider:           cloudprovider.QEMU,
			vars:               qemuVars,
			fs:                 afero.NewMemMapFs(),
			partiallyExtracted: true,
			wantErr:            false,
		},
		"prepare workspace fails": {
			pathBase: "terraform",
			provider: cloudprovider.QEMU,
			vars:     qemuVars,
			fs:       afero.NewReadOnlyFs(afero.NewMemMapFs()),
			wantErr:  true,
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)
			require := require.New(t)

			c := &Client{
				tf:         &stubTerraform{},
				file:       file.NewHandler(tc.fs),
				workingDir: constants.TerraformWorkingDir,
			}

			path := path.Join(tc.pathBase, strings.ToLower(tc.provider.String()))
			err := c.PrepareWorkspace(path, tc.vars)

			// Test case: Check if we can continue to create on an incomplete workspace.
			if tc.partiallyExtracted {
				require.NoError(c.file.Remove(filepath.Join(c.workingDir, "main.tf")))
				err = c.PrepareWorkspace(path, tc.vars)
			}

			if tc.wantErr {
				assert.Error(err)
				return
			}
			assert.NoError(err)
		})
	}
}

func TestPrepareIAM(t *testing.T) {
	gcpVars := &GCPIAMVariables{
		Project:          "const-1234",
		Region:           "europe-west1",
		Zone:             "europe-west1-a",
		ServiceAccountID: "const-test-case",
	}
	azureVars := &AzureIAMVariables{
		Region:           "westus",
		ServicePrincipal: "constell-test-sp",
		ResourceGroup:    "constell-test-rg",
	}
	awsVars := &AWSIAMVariables{
		Region: "eu-east-2a",
		Prefix: "test",
	}
	testCases := map[string]struct {
		pathBase           string
		provider           cloudprovider.Provider
		vars               Variables
		fs                 afero.Fs
		partiallyExtracted bool
		wantErr            bool
	}{
		"no vars": {
			pathBase: path.Join("terraform", "iam"),
			fs:       afero.NewMemMapFs(),
			wantErr:  true,
		},
		"invalid path": {
			pathBase: path.Join("abc", "123"),
			fs:       afero.NewMemMapFs(),
			wantErr:  true,
		},
		"gcp": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.GCP,
			vars:     gcpVars,
			fs:       afero.NewMemMapFs(),
			wantErr:  false,
		},
		"continue on partially extracted": {
			pathBase:           path.Join("terraform", "iam"),
			provider:           cloudprovider.GCP,
			vars:               gcpVars,
			fs:                 afero.NewMemMapFs(),
			partiallyExtracted: true,
			wantErr:            false,
		},
		"azure": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.Azure,
			vars:     azureVars,
			fs:       afero.NewMemMapFs(),
			wantErr:  false,
		},
		"aws": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.AWS,
			vars:     awsVars,
			fs:       afero.NewMemMapFs(),
			wantErr:  false,
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)
			require := require.New(t)

			c := &Client{
				tf:         &stubTerraform{},
				file:       file.NewHandler(tc.fs),
				workingDir: constants.TerraformIAMWorkingDir,
			}

			path := path.Join(tc.pathBase, strings.ToLower(tc.provider.String()))
			err := c.PrepareWorkspace(path, tc.vars)

			// Test case: Check if we can continue to create on an incomplete workspace.
			if tc.partiallyExtracted {
				require.NoError(c.file.Remove(filepath.Join(c.workingDir, "main.tf")))
				err = c.PrepareWorkspace(path, tc.vars)
			}

			if tc.wantErr {
				assert.Error(err)
				return
			}
			assert.NoError(err)
		})
	}
}

func TestCreateCluster(t *testing.T) {
	someErr := errors.New("failed")
	newTestState := func() *tfjson.State {
		workingState := tfjson.State{
			Values: &tfjson.StateValues{
				Outputs: map[string]*tfjson.StateOutput{
					"ip": {
						Value: "192.0.2.100",
					},
					"initSecret": {
						Value: "initSecret",
					},
				},
			},
		}
		return &workingState
	}
	qemuVars := &QEMUVariables{
		CommonVariables: CommonVariables{
			Name:               "name",
			CountControlPlanes: 1,
			CountWorkers:       2,
			StateDiskSizeGB:    11,
		},
		CPUCount:         1,
		MemorySizeMiB:    1024,
		ImagePath:        "path",
		ImageFormat:      "format",
		MetadataAPIImage: "api",
	}

	testCases := map[string]struct {
		pathBase string
		provider cloudprovider.Provider
		vars     Variables
		tf       *stubTerraform
		fs       afero.Fs
		wantErr  bool
	}{
		"works": {
			pathBase: "terraform",
			provider: cloudprovider.QEMU,
			vars:     qemuVars,
			tf:       &stubTerraform{showState: newTestState()},
			fs:       afero.NewMemMapFs(),
		},
		"init fails": {
			pathBase: "terraform",
			provider: cloudprovider.QEMU,
			vars:     qemuVars,
			tf:       &stubTerraform{initErr: someErr},
			fs:       afero.NewMemMapFs(),
			wantErr:  true,
		},
		"apply fails": {
			pathBase: "terraform",
			provider: cloudprovider.QEMU,
			vars:     qemuVars,
			tf:       &stubTerraform{applyErr: someErr},
			fs:       afero.NewMemMapFs(),
			wantErr:  true,
		},
		"show fails": {
			pathBase: "terraform",
			provider: cloudprovider.QEMU,
			vars:     qemuVars,
			tf:       &stubTerraform{showErr: someErr},
			fs:       afero.NewMemMapFs(),
			wantErr:  true,
		},
		"no ip": {
			pathBase: "terraform",
			provider: cloudprovider.QEMU,
			vars:     qemuVars,
			tf: &stubTerraform{
				showState: &tfjson.State{
					Values: &tfjson.StateValues{
						Outputs: map[string]*tfjson.StateOutput{},
					},
				},
			},
			fs:      afero.NewMemMapFs(),
			wantErr: true,
		},
		"ip has wrong type": {
			pathBase: "terraform",
			provider: cloudprovider.QEMU,
			vars:     qemuVars,
			tf: &stubTerraform{
				showState: &tfjson.State{
					Values: &tfjson.StateValues{
						Outputs: map[string]*tfjson.StateOutput{"ip": {Value: 42}},
					},
				},
			},
			fs:      afero.NewMemMapFs(),
			wantErr: true,
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)
			require := require.New(t)

			c := &Client{
				tf:         tc.tf,
				file:       file.NewHandler(tc.fs),
				workingDir: constants.TerraformWorkingDir,
			}

			path := path.Join(tc.pathBase, strings.ToLower(tc.provider.String()))
			require.NoError(c.PrepareWorkspace(path, tc.vars))
			ip, initSecret, err := c.CreateCluster(context.Background())

			if tc.wantErr {
				assert.Error(err)
				return
			}
			assert.NoError(err)
			assert.Equal("192.0.2.100", ip)
			assert.Equal("initSecret", initSecret)
		})
	}
}

func TestCreateIAM(t *testing.T) {
	someErr := errors.New("failed")
	newTestState := func() *tfjson.State {
		workingState := tfjson.State{
			Values: &tfjson.StateValues{
				Outputs: map[string]*tfjson.StateOutput{
					"sa_key": {
						Value: "12345678_abcdefg",
					},
					"subscription_id": {
						Value: "test_subscription_id",
					},
					"tenant_id": {
						Value: "test_tenant_id",
					},
					"application_id": {
						Value: "test_application_id",
					},
					"uami_id": {
						Value: "test_uami_id",
					},
					"application_client_secret_value": {
						Value: "test_application_client_secret_value",
					},
					"control_plane_instance_profile": {
						Value: "test_control_plane_instance_profile",
					},
					"worker_nodes_instance_profile": {
						Value: "test_worker_nodes_instance_profile",
					},
				},
			},
		}
		return &workingState
	}
	gcpVars := &GCPIAMVariables{
		Project:          "const-1234",
		Region:           "europe-west1",
		Zone:             "europe-west1-a",
		ServiceAccountID: "const-test-case",
	}
	azureVars := &AzureIAMVariables{
		Region:           "westus",
		ServicePrincipal: "constell-test-sp",
		ResourceGroup:    "constell-test-rg",
	}
	awsVars := &AWSIAMVariables{
		Region: "eu-east-2a",
		Prefix: "test",
	}

	testCases := map[string]struct {
		pathBase string
		provider cloudprovider.Provider
		vars     Variables
		tf       *stubTerraform
		fs       afero.Fs
		wantErr  bool
		want     IAMOutput
	}{
		"gcp works": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.GCP,
			vars:     gcpVars,
			tf:       &stubTerraform{showState: newTestState()},
			fs:       afero.NewMemMapFs(),
			want:     IAMOutput{GCP: GCPIAMOutput{SaKey: "12345678_abcdefg"}},
		},
		"gcp init fails": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.GCP,
			vars:     gcpVars,
			tf:       &stubTerraform{initErr: someErr},
			fs:       afero.NewMemMapFs(),
			wantErr:  true,
		},
		"gcp apply fails": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.GCP,
			vars:     gcpVars,
			tf:       &stubTerraform{applyErr: someErr},
			fs:       afero.NewMemMapFs(),
			wantErr:  true,
		},
		"gcp show fails": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.GCP,
			vars:     gcpVars,
			tf:       &stubTerraform{showErr: someErr},
			fs:       afero.NewMemMapFs(),
			wantErr:  true,
		},
		"gcp no sa_key": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.GCP,
			vars:     gcpVars,
			tf: &stubTerraform{
				showState: &tfjson.State{
					Values: &tfjson.StateValues{
						Outputs: map[string]*tfjson.StateOutput{},
					},
				},
			},
			fs:      afero.NewMemMapFs(),
			wantErr: true,
		},
		"gcp sa_key has wrong type": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.GCP,
			vars:     gcpVars,
			tf: &stubTerraform{
				showState: &tfjson.State{
					Values: &tfjson.StateValues{
						Outputs: map[string]*tfjson.StateOutput{"sa_key": {Value: 42}},
					},
				},
			},
			fs:      afero.NewMemMapFs(),
			wantErr: true,
		},
		"azure works": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.Azure,
			vars:     azureVars,
			tf:       &stubTerraform{showState: newTestState()},
			fs:       afero.NewMemMapFs(),
			want: IAMOutput{Azure: AzureIAMOutput{
				SubscriptionID:               "test_subscription_id",
				TenantID:                     "test_tenant_id",
				ApplicationID:                "test_application_id",
				ApplicationClientSecretValue: "test_application_client_secret_value",
				UAMIID:                       "test_uami_id",
			}},
		},
		"azure init fails": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.Azure,
			vars:     azureVars,
			tf:       &stubTerraform{initErr: someErr},
			fs:       afero.NewMemMapFs(),
			wantErr:  true,
		},
		"azure apply fails": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.Azure,
			vars:     azureVars,
			tf:       &stubTerraform{applyErr: someErr},
			fs:       afero.NewMemMapFs(),
			wantErr:  true,
		},
		"azure show fails": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.Azure,
			vars:     azureVars,
			tf:       &stubTerraform{showErr: someErr},
			fs:       afero.NewMemMapFs(),
			wantErr:  true,
		},
		"azure no subscription_id": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.Azure,
			vars:     azureVars,
			tf: &stubTerraform{
				showState: &tfjson.State{
					Values: &tfjson.StateValues{
						Outputs: map[string]*tfjson.StateOutput{},
					},
				},
			},
			fs:      afero.NewMemMapFs(),
			wantErr: true,
		},
		"azure subscription_id has wrong type": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.Azure,
			vars:     azureVars,
			tf: &stubTerraform{
				showState: &tfjson.State{
					Values: &tfjson.StateValues{
						Outputs: map[string]*tfjson.StateOutput{"subscription_id": {Value: 42}},
					},
				},
			},
			fs:      afero.NewMemMapFs(),
			wantErr: true,
		},
		"aws works": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.AWS,
			vars:     awsVars,
			tf:       &stubTerraform{showState: newTestState()},
			fs:       afero.NewMemMapFs(),
			want: IAMOutput{AWS: AWSIAMOutput{
				ControlPlaneInstanceProfile: "test_control_plane_instance_profile",
				WorkerNodeInstanceProfile:   "test_worker_nodes_instance_profile",
			}},
		},
		"aws init fails": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.AWS,
			vars:     awsVars,
			tf:       &stubTerraform{initErr: someErr},
			fs:       afero.NewMemMapFs(),
			wantErr:  true,
		},
		"aws apply fails": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.AWS,
			vars:     awsVars,
			tf:       &stubTerraform{applyErr: someErr},
			fs:       afero.NewMemMapFs(),
			wantErr:  true,
		},
		"aws show fails": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.AWS,
			vars:     awsVars,
			tf:       &stubTerraform{showErr: someErr},
			fs:       afero.NewMemMapFs(),
			wantErr:  true,
		},
		"aws no control_plane_instance_profile": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.AWS,
			vars:     awsVars,
			tf: &stubTerraform{
				showState: &tfjson.State{
					Values: &tfjson.StateValues{
						Outputs: map[string]*tfjson.StateOutput{},
					},
				},
			},
			fs:      afero.NewMemMapFs(),
			wantErr: true,
		},
		"azure control_plane_instance_profile has wrong type": {
			pathBase: path.Join("terraform", "iam"),
			provider: cloudprovider.AWS,
			vars:     awsVars,
			tf: &stubTerraform{
				showState: &tfjson.State{
					Values: &tfjson.StateValues{
						Outputs: map[string]*tfjson.StateOutput{"control_plane_instance_profile": {Value: 42}},
					},
				},
			},
			fs:      afero.NewMemMapFs(),
			wantErr: true,
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)
			require := require.New(t)

			c := &Client{
				tf:         tc.tf,
				file:       file.NewHandler(tc.fs),
				workingDir: constants.TerraformIAMWorkingDir,
			}

			path := path.Join(tc.pathBase, strings.ToLower(tc.provider.String()))
			require.NoError(c.PrepareWorkspace(path, tc.vars))
			IAMoutput, err := c.CreateIAMConfig(context.Background(), tc.provider)

			if tc.wantErr {
				assert.Error(err)
				return
			}
			assert.NoError(err)
			assert.Equal(tc.want, IAMoutput)
		})
	}
}

func TestDestroyInstances(t *testing.T) {
	testCases := map[string]struct {
		tf      *stubTerraform
		wantErr bool
	}{
		"works": {
			tf: &stubTerraform{},
		},
		"destroy fails": {
			tf: &stubTerraform{
				destroyErr: errors.New("error"),
			},
			wantErr: true,
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)

			c := &Client{
				tf: tc.tf,
			}

			err := c.DestroyCluster(context.Background())
			if tc.wantErr {
				assert.Error(err)
				return
			}

			assert.NoError(err)
		})
	}
}

func TestCleanupWorkspace(t *testing.T) {
	someContent := []byte("some content")

	testCases := map[string]struct {
		provider  cloudprovider.Provider
		prepareFS func(file.Handler) error
		wantErr   bool
	}{
		"files are cleaned up": {
			provider: cloudprovider.QEMU,
			prepareFS: func(f file.Handler) error {
				var err error
				err = multierr.Append(err, f.Write("terraform.tfvars", someContent))
				err = multierr.Append(err, f.Write("terraform.tfstate", someContent))
				return multierr.Append(err, f.Write("terraform.tfstate.backup", someContent))
			},
		},
		"no error if files do not exist": {
			provider:  cloudprovider.QEMU,
			prepareFS: func(f file.Handler) error { return nil },
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)
			require := require.New(t)

			file := file.NewHandler(afero.NewMemMapFs())
			require.NoError(tc.prepareFS(file))

			c := &Client{
				file:       file,
				tf:         &stubTerraform{},
				workingDir: constants.TerraformWorkingDir,
			}

			err := c.CleanUpWorkspace()
			if tc.wantErr {
				assert.Error(err)
				return
			}
			assert.NoError(err)
			_, err = file.Stat(filepath.Join(c.workingDir, "terraform.tfvars"))
			assert.ErrorIs(err, fs.ErrNotExist)
			_, err = file.Stat(filepath.Join(c.workingDir, "terraform.tfstate"))
			assert.ErrorIs(err, fs.ErrNotExist)
			_, err = file.Stat(filepath.Join(c.workingDir, "terraform.tfstate.backup"))
			assert.ErrorIs(err, fs.ErrNotExist)
		})
	}
}

type stubTerraform struct {
	applyErr   error
	destroyErr error
	initErr    error
	showErr    error
	showState  *tfjson.State
}

func (s *stubTerraform) Apply(context.Context, ...tfexec.ApplyOption) error {
	return s.applyErr
}

func (s *stubTerraform) Destroy(context.Context, ...tfexec.DestroyOption) error {
	return s.destroyErr
}

func (s *stubTerraform) Init(context.Context, ...tfexec.InitOption) error {
	return s.initErr
}

func (s *stubTerraform) Show(context.Context, ...tfexec.ShowOption) (*tfjson.State, error) {
	return s.showState, s.showErr
}
