// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package config

import (
	"errors"
	"fmt"
	"testing"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/awserr"
	"github.com/aws/aws-sdk-go/service/ssm"
	"github.com/aws/copilot-cli/internal/pkg/aws/identity"
	"github.com/stretchr/testify/require"
)

func TestStore_ListApplications(t *testing.T) {
	testApplication := Application{Name: "chicken", Version: "1.0"}
	testApplicationString, err := marshal(testApplication)
	require.NoError(t, err, "Marshal application should not fail")

	cowApplication := Application{Name: "cow", Version: "1.0"}
	cowApplicationString, err := marshal(cowApplication)
	require.NoError(t, err, "Marshal application should not fail")

	lastPageInPaginatedResp := false

	testCases := map[string]struct {
		mockGetParametersByPath func(t *testing.T, param *ssm.GetParametersByPathInput) (*ssm.GetParametersByPathOutput, error)

		wantedApplicationNames []string
		wantedErr              error
	}{
		"with multiple existing applications": {
			mockGetParametersByPath: func(t *testing.T, param *ssm.GetParametersByPathInput) (output *ssm.GetParametersByPathOutput, e error) {
				require.Equal(t, rootApplicationPath, *param.Path)
				return &ssm.GetParametersByPathOutput{
					Parameters: []*ssm.Parameter{
						{
							Name:  aws.String("/copilot/applications/chicken"),
							Value: aws.String(testApplicationString),
						},
						{
							Name:  aws.String("/copilot/applications/cow"),
							Value: aws.String(cowApplicationString),
						},
					},
				}, nil
			},

			wantedApplicationNames: []string{"chicken", "cow"},
			wantedErr:              nil,
		},
		"with malformed json": {
			mockGetParametersByPath: func(t *testing.T, param *ssm.GetParametersByPathInput) (output *ssm.GetParametersByPathOutput, e error) {
				require.Equal(t, rootApplicationPath, *param.Path)
				return &ssm.GetParametersByPathOutput{
					Parameters: []*ssm.Parameter{
						{
							Name:  aws.String("/copilot/applications/chicken"),
							Value: aws.String("oops"),
						},
					},
				}, nil
			},
			wantedErr: fmt.Errorf("read application configuration: invalid character 'o' looking for beginning of value"),
		},
		"with SSM error": {
			mockGetParametersByPath: func(t *testing.T, param *ssm.GetParametersByPathInput) (output *ssm.GetParametersByPathOutput, e error) {
				require.Equal(t, rootApplicationPath, *param.Path)
				return nil, fmt.Errorf("broken")
			},

			wantedApplicationNames: nil,
			wantedErr:              fmt.Errorf("list applications: broken"),
		},
		"with paginated response": {
			mockGetParametersByPath: func(t *testing.T, param *ssm.GetParametersByPathInput) (output *ssm.GetParametersByPathOutput, e error) {
				require.Equal(t, rootApplicationPath, *param.Path)

				// this closure references the `lastPageInPaginatedResp` variable
				// in order to determine the content of the response.
				if !lastPageInPaginatedResp {
					lastPageInPaginatedResp = true
					return &ssm.GetParametersByPathOutput{
						Parameters: []*ssm.Parameter{
							{
								Name:  aws.String("/copilot/applications/chicken"),
								Value: aws.String(testApplicationString),
							},
						},
						NextToken: aws.String("more"),
					}, nil
				}
				return &ssm.GetParametersByPathOutput{
					Parameters: []*ssm.Parameter{
						{
							Name:  aws.String("/copilot/applications/cow"),
							Value: aws.String(cowApplicationString),
						},
					},
				}, nil
			},

			wantedApplicationNames: []string{"chicken", "cow"},
			wantedErr:              nil,
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			// GIVEN
			lastPageInPaginatedResp = false
			store := &Store{
				ssm: &mockSSM{
					t:                       t,
					mockGetParametersByPath: tc.mockGetParametersByPath,
				},
			}

			// WHEN
			apps, err := store.ListApplications()

			// THEN
			if tc.wantedErr != nil {
				require.EqualError(t, err, tc.wantedErr.Error())
			} else {
				var names []string
				for _, a := range apps {
					names = append(names, a.Name)
				}
				require.ElementsMatch(t, tc.wantedApplicationNames, names)

			}
		})
	}
}

func TestStore_GetApplication(t *testing.T) {
	testApplication := Application{Name: "chicken", AccountID: "1234", Version: "1.0"}
	testApplicationString, err := marshal(testApplication)
	testApplicationPath := fmt.Sprintf(fmtApplicationPath, testApplication.Name)
	require.NoError(t, err, "Marshal application should not fail")

	testCases := map[string]struct {
		mockGetParameter       func(t *testing.T, param *ssm.GetParameterInput) (*ssm.GetParameterOutput, error)
		mockIdentityServiceGet func() (identity.Caller, error)
		wantedApplication      Application
		wantedErr              error
	}{
		"with existing application": {
			mockGetParameter: func(t *testing.T, param *ssm.GetParameterInput) (*ssm.GetParameterOutput, error) {
				require.Equal(t, testApplicationPath, *param.Name)
				return &ssm.GetParameterOutput{
					Parameter: &ssm.Parameter{
						Name:  aws.String(testApplicationPath),
						Value: aws.String(testApplicationString),
					},
				}, nil
			},

			wantedApplication: testApplication,
			wantedErr:         nil,
		},
		"with no existing application": {
			mockGetParameter: func(t *testing.T, param *ssm.GetParameterInput) (*ssm.GetParameterOutput, error) {
				require.Equal(t, testApplicationPath, *param.Name)
				return nil, awserr.New(ssm.ErrCodeParameterNotFound, "No Parameter", fmt.Errorf("No Parameter"))
			},
			mockIdentityServiceGet: func() (identity.Caller, error) {
				return identity.Caller{
					Account: "12345",
				}, nil
			},
			wantedErr: &ErrNoSuchApplication{
				ApplicationName: "chicken",
				AccountID:       "12345",
				Region:          "us-west-2",
			},
		},
		"with no existing application and failed STS call": {
			mockGetParameter: func(t *testing.T, param *ssm.GetParameterInput) (*ssm.GetParameterOutput, error) {
				require.Equal(t, testApplicationPath, *param.Name)
				return nil, awserr.New(ssm.ErrCodeParameterNotFound, "No Parameter", fmt.Errorf("No Parameter"))
			},
			mockIdentityServiceGet: func() (identity.Caller, error) {
				return identity.Caller{}, fmt.Errorf("Error")
			},
			wantedErr: &ErrNoSuchApplication{
				ApplicationName: "chicken",
				AccountID:       "unknown",
				Region:          "us-west-2",
			},
		},
		"with malformed json": {
			mockGetParameter: func(t *testing.T, param *ssm.GetParameterInput) (*ssm.GetParameterOutput, error) {
				require.Equal(t, testApplicationPath, *param.Name)
				return &ssm.GetParameterOutput{
					Parameter: &ssm.Parameter{
						Name:  aws.String(testApplicationPath),
						Value: aws.String("oops"),
					},
				}, nil
			},

			wantedErr: fmt.Errorf("read configuration for application chicken: invalid character 'o' looking for beginning of value"),
		},
		"with SSM error": {
			mockGetParameter: func(t *testing.T, param *ssm.GetParameterInput) (*ssm.GetParameterOutput, error) {
				require.Equal(t, testApplicationPath, *param.Name)
				return nil, fmt.Errorf("broken")
			},
			wantedErr: fmt.Errorf("get application chicken: broken"),
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			// GIVEN
			store := &Store{
				ssm: &mockSSM{
					t:                t,
					mockGetParameter: tc.mockGetParameter,
				},
				sts: mockIdentityService{
					mockIdentityServiceGet: tc.mockIdentityServiceGet,
				},
				appRegion: "us-west-2",
			}

			// WHEN
			app, err := store.GetApplication("chicken")

			// THEN
			if tc.wantedErr != nil {
				require.EqualError(t, err, tc.wantedErr.Error())
			} else {
				require.Equal(t, tc.wantedApplication, *app)
			}
		})
	}
}

func TestStore_CreateApplication(t *testing.T) {
	tagForApplicationParam := []*ssm.Tag{
		{
			Key:   aws.String("copilot-application"),
			Value: aws.String("phonetool"),
		},
	}
	testCases := map[string]struct {
		inApplication *Application

		mockPutParameter func(t *testing.T, param *ssm.PutParameterInput) (*ssm.PutParameterOutput, error)
		wantedErr        error
	}{
		"with no existing application": {
			inApplication: &Application{Name: "phonetool", AccountID: "1234", Domain: "phonetool.com", DomainHostedZoneID: "mockHostedZoneID", Tags: map[string]string{"owner": "boss"}},
			mockPutParameter: func(t *testing.T, param *ssm.PutParameterInput) (*ssm.PutParameterOutput, error) {
				require.Equal(t, fmt.Sprintf(fmtApplicationPath, "phonetool"), *param.Name)
				require.Equal(t, fmt.Sprintf(`{"name":"phonetool","account":"1234","domain":"phonetool.com","domainHostedZoneID":"mockHostedZoneID","version":"%s","tags":{"owner":"boss"}}`, schemaVersion), *param.Value)
				require.Equal(t, tagForApplicationParam, param.Tags)
				return &ssm.PutParameterOutput{
					Version: aws.Int64(1),
				}, nil
			},
			wantedErr: nil,
		},
		"with existing application": {
			inApplication: &Application{Name: "phonetool", AccountID: "1234"},
			mockPutParameter: func(t *testing.T, param *ssm.PutParameterInput) (*ssm.PutParameterOutput, error) {
				require.Equal(t, tagForApplicationParam, param.Tags)
				return nil, awserr.New(ssm.ErrCodeParameterAlreadyExists, "Already exists", fmt.Errorf("Already Exists"))
			},
			wantedErr: nil,
		},
		"with SSM error": {
			inApplication: &Application{Name: "phonetool", AccountID: "1234"},
			mockPutParameter: func(t *testing.T, param *ssm.PutParameterInput) (*ssm.PutParameterOutput, error) {
				require.Equal(t, tagForApplicationParam, param.Tags)
				return nil, fmt.Errorf("broken")
			},
			wantedErr: fmt.Errorf("create application phonetool: broken"),
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			store := &Store{
				ssm: &mockSSM{
					t:                t,
					mockPutParameter: tc.mockPutParameter,
				},
			}

			// WHEN
			err := store.CreateApplication(tc.inApplication)

			// THEN
			if tc.wantedErr != nil {
				require.EqualError(t, err, tc.wantedErr.Error())
			} else {
				require.NoError(t, err)
			}
		})
	}
}
func TestStore_UpdateApplication(t *testing.T) {
	testCases := map[string]struct {
		inApplication *Application

		mockPutParameter func(t *testing.T, param *ssm.PutParameterInput) (*ssm.PutParameterOutput, error)
		wantedErr        error
	}{
		"success": {
			inApplication: &Application{Name: "phonetool", AccountID: "1234", Domain: "phonetool.com", DomainHostedZoneID: "mockHostedZoneID", Tags: map[string]string{"owner": "boss"}},
			mockPutParameter: func(t *testing.T, param *ssm.PutParameterInput) (*ssm.PutParameterOutput, error) {
				require.Equal(t, fmt.Sprintf(fmtApplicationPath, "phonetool"), *param.Name)
				require.Equal(t, fmt.Sprintf(`{"name":"phonetool","account":"1234","domain":"phonetool.com","domainHostedZoneID":"mockHostedZoneID","version":"%s","tags":{"owner":"boss"}}`, schemaVersion), *param.Value)

				return &ssm.PutParameterOutput{
					Version: aws.Int64(1),
				}, nil
			},
			wantedErr: nil,
		},
		"with SSM error": {
			inApplication: &Application{Name: "phonetool", AccountID: "1234"},
			mockPutParameter: func(t *testing.T, param *ssm.PutParameterInput) (*ssm.PutParameterOutput, error) {
				return nil, fmt.Errorf("broken")
			},
			wantedErr: fmt.Errorf("update application phonetool: broken"),
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			store := &Store{
				ssm: &mockSSM{
					t:                t,
					mockPutParameter: tc.mockPutParameter,
				},
			}

			// WHEN
			err := store.UpdateApplication(tc.inApplication)

			// THEN
			if tc.wantedErr != nil {
				require.EqualError(t, err, tc.wantedErr.Error())
			} else {
				require.NoError(t, err)
			}
		})
	}
}

func TestDeleteApplication(t *testing.T) {
	mockApplicationName := "mockApplicationName"
	mockError := errors.New("mockError")

	tests := map[string]struct {
		mockDeleteParameter func(t *testing.T, in *ssm.DeleteParameterInput) (*ssm.DeleteParameterOutput, error)

		want error
	}{
		"should return nil given success": {
			mockDeleteParameter: func(t *testing.T, in *ssm.DeleteParameterInput) (*ssm.DeleteParameterOutput, error) {
				require.Equal(t, fmt.Sprintf(fmtApplicationPath, mockApplicationName), *in.Name)

				return &ssm.DeleteParameterOutput{}, nil
			},
			want: nil,
		},
		"should return nil given paramter not found error code": {
			mockDeleteParameter: func(t *testing.T, in *ssm.DeleteParameterInput) (*ssm.DeleteParameterOutput, error) {
				require.Equal(t, fmt.Sprintf(fmtApplicationPath, mockApplicationName), *in.Name)

				return nil, awserr.New(ssm.ErrCodeParameterNotFound, "whatevs", mockError)
			},
			want: nil,
		},
		"should return unhandled non-awserr": {
			mockDeleteParameter: func(t *testing.T, in *ssm.DeleteParameterInput) (*ssm.DeleteParameterOutput, error) {
				require.Equal(t, fmt.Sprintf(fmtApplicationPath, mockApplicationName), *in.Name)

				return nil, mockError
			},
			want: mockError,
		},
	}

	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			store := &Store{
				ssm: &mockSSM{
					t:                   t,
					mockDeleteParameter: test.mockDeleteParameter,
				},
			}

			got := store.DeleteApplication(mockApplicationName)

			require.Equal(t, test.want, got)
		})
	}
}
