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

package addon

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"strings"
	"testing"

	"github.com/aws/copilot-cli/internal/pkg/addon/mocks"
	"github.com/aws/copilot-cli/internal/pkg/aws/s3"
	"github.com/golang/mock/gomock"
	"github.com/spf13/afero"
	"github.com/stretchr/testify/require"
	"gopkg.in/yaml.v3"
)

type addonMocks struct {
	uploader *mocks.Mockuploader
	ws       *mocks.MockWorkspaceAddonsReader
}

func TestPackage(t *testing.T) {
	const (
		wlName = "mock-wl"
		wsPath = "/"
		bucket = "mockBucket"
	)

	lambdaZipHash := sha256.New()
	indexZipHash := sha256.New()
	indexFileHash := sha256.New()

	// fs has the following structure:
	//  .
	//  ├─ lambda
	//  │  ├─ index.js (contains lambda function)
	//  ┴  └─ test.js (empty)
	fs := afero.NewMemMapFs()
	fs.Mkdir("/lambda", 0644)

	f, _ := fs.Create("/lambda/index.js")
	defer f.Close()
	info, _ := f.Stat()
	io.MultiWriter(lambdaZipHash, indexZipHash).Write([]byte("index.js " + info.Mode().String()))
	io.MultiWriter(f, lambdaZipHash, indexZipHash, indexFileHash).Write([]byte(`exports.handler = function(event, context) {}`))

	f2, _ := fs.Create("/lambda/test.js")
	info, _ = f2.Stat()
	lambdaZipHash.Write([]byte("test.js " + info.Mode().String()))

	lambdaZipS3Path := fmt.Sprintf("manual/addons/mock-wl/assets/%s", hex.EncodeToString(lambdaZipHash.Sum(nil)))
	indexZipS3Path := fmt.Sprintf("manual/addons/mock-wl/assets/%s", hex.EncodeToString(indexZipHash.Sum(nil)))
	indexFileS3Path := fmt.Sprintf("manual/addons/mock-wl/assets/%s", hex.EncodeToString(indexFileHash.Sum(nil)))

	tests := map[string]struct {
		inTemplate  string
		outTemplate string
		pkgError    string
		setupMocks  func(m addonMocks)
	}{
		"AWS::Lambda::Function, zipped file": {
			setupMocks: func(m addonMocks) {
				m.uploader.EXPECT().Upload(bucket, indexZipS3Path, gomock.Any()).Return(s3.URL("us-west-2", bucket, "asdf"), nil)
			},
			inTemplate: `
Resources:
  Test:
    Metadata:
      "testKey": "testValue"
    Type: AWS::Lambda::Function
    Properties:
      Code: lambda/index.js
      Handler: "index.handler"
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt "TestRole.Arn"
      Runtime: nodejs20.x
`,
			outTemplate: `
Resources:
  Test:
    Metadata:
      "testKey": "testValue"
    Type: AWS::Lambda::Function
    Properties:
      Code:
        S3Bucket: mockBucket
        S3Key: asdf
      Handler: "index.handler"
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt "TestRole.Arn"
      Runtime: nodejs20.x
`,
		},
		"AWS::Glue::Job, non-zipped file": {
			setupMocks: func(m addonMocks) {
				m.uploader.EXPECT().Upload(bucket, indexFileS3Path, gomock.Any()).Return(s3.URL("us-east-2", bucket, "asdf"), nil)
			},
			inTemplate: `
Resources:
  Test:
    Metadata:
      "testKey": "testValue"
    Type: AWS::Glue::Job
    Properties:
      Command:
        ScriptLocation: lambda/index.js
`,
			outTemplate: `
Resources:
  Test:
    Metadata:
      "testKey": "testValue"
    Type: AWS::Glue::Job
    Properties:
      Command:
        ScriptLocation: s3://mockBucket/asdf
`,
		},
		"AWS::CodeCommit::Repository, directory without slash": {
			setupMocks: func(m addonMocks) {
				m.uploader.EXPECT().Upload(bucket, lambdaZipS3Path, gomock.Any()).Return(s3.URL("ap-northeast-1", bucket, "asdf"), nil)
			},
			inTemplate: `
Resources:
  Test:
    Metadata:
      "testKey": "testValue"
    Type: AWS::CodeCommit::Repository
    Properties:
      Code:
        S3: lambda
`,
			outTemplate: `
Resources:
  Test:
    Metadata:
      "testKey": "testValue"
    Type: AWS::CodeCommit::Repository
    Properties:
      Code:
        S3:
          Bucket: mockBucket
          Key: asdf
`,
		},
		"AWS::ApiGateway::RestApi, directory with slash": {
			setupMocks: func(m addonMocks) {
				m.uploader.EXPECT().Upload(bucket, lambdaZipS3Path, gomock.Any()).Return(s3.URL("eu-west-1", bucket, "asdf"), nil)
			},
			inTemplate: `
Resources:
  Test:
    Metadata:
      "testKey": "testValue"
    Type: AWS::ApiGateway::RestApi
    Properties:
      BodyS3Location: lambda/
`,
			outTemplate: `
Resources:
  Test:
    Metadata:
      "testKey": "testValue"
    Type: AWS::ApiGateway::RestApi
    Properties:
      BodyS3Location:
        Bucket: mockBucket
        Key: asdf
`,
		},
		"AWS::AppSync::Resolver, multiple replacements in one resource": {
			setupMocks: func(m addonMocks) {
				m.uploader.EXPECT().Upload(bucket, lambdaZipS3Path, gomock.Any()).Return(s3.URL("ca-central-1", bucket, "asdf"), nil)
				m.uploader.EXPECT().Upload(bucket, indexFileS3Path, gomock.Any()).Return(s3.URL("ca-central-1", bucket, "hjkl"), nil)
			},
			inTemplate: `
Resources:
  Test:
    Metadata:
      "testKey": "testValue"
    Type: AWS::AppSync::Resolver
    Properties:
      RequestMappingTemplateS3Location: lambda
      ResponseMappingTemplateS3Location: lambda/index.js
`,
			outTemplate: `
Resources:
  Test:
    Metadata:
      "testKey": "testValue"
    Type: AWS::AppSync::Resolver
    Properties:
      RequestMappingTemplateS3Location: s3://mockBucket/asdf
      ResponseMappingTemplateS3Location: s3://mockBucket/hjkl
`,
		},
		"Fn::Transform in lambda function": {
			setupMocks: func(m addonMocks) {
				m.uploader.EXPECT().Upload(bucket, indexFileS3Path, gomock.Any()).Return(s3.URL("us-west-2", bucket, "asdf"), nil)
				m.uploader.EXPECT().Upload(bucket, lambdaZipS3Path, gomock.Any()).Return(s3.URL("us-west-2", bucket, "hjkl"), nil)
			},
			inTemplate: `
Resources:
  Test:
    Metadata:
      "hihi": "byebye"
    Type: AWS::Lambda::Function
    Properties:
      Fn::Transform:
        # test comment uno
        Name: "AWS::Include"
        Parameters:
          # test comment dos
          Location: ./lambda/index.js
      Code: lambda
      Handler: "index.handler"
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt "TestRole.Arn"
      Runtime: nodejs20.x
`,
			outTemplate: `
Resources:
  Test:
    Metadata:
      "hihi": "byebye"
    Type: AWS::Lambda::Function
    Properties:
      Fn::Transform:
        # test comment uno
        Name: "AWS::Include"
        Parameters:
          # test comment dos
          Location: s3://mockBucket/asdf
      Code:
        S3Bucket: mockBucket
        S3Key: hjkl
      Handler: "index.handler"
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt "TestRole.Arn"
      Runtime: nodejs20.x
`,
		},
		"Fn::Transform nested in a yaml mapping and sequence node": {
			setupMocks: func(m addonMocks) {
				m.uploader.EXPECT().Upload(bucket, indexFileS3Path, gomock.Any()).Return(s3.URL("us-west-2", bucket, "asdf"), nil).Times(2)
			},
			inTemplate: `
Resources:
  Test:
    Type: AWS::Fake::Resource
    Properties:
      SequenceProperty:
        - KeyOne: ValOne
          KeyTwo: ValTwo
        - Fn::Transform:
            Name: "AWS::Include"
            Parameters:
              Location: ./lambda/index.js
      MappingProperty:
        KeyOne: ValOne
        Fn::Transform:
          Name: "AWS::Include"
          Parameters:
            Location: ./lambda/index.js
`,
			outTemplate: `
Resources:
  Test:
    Type: AWS::Fake::Resource
    Properties:
      SequenceProperty:
        - KeyOne: ValOne
          KeyTwo: ValTwo
        - Fn::Transform:
            Name: "AWS::Include"
            Parameters:
              Location: s3://mockBucket/asdf
      MappingProperty:
        KeyOne: ValOne
        Fn::Transform:
          Name: "AWS::Include"
          Parameters:
            Location: s3://mockBucket/asdf
`,
		},
		"Fn::Transform ignores top level Transform": {
			// example from https://medium.com/swlh/using-the-cloudformation-aws-include-macro-9e3056cf75b0
			setupMocks: func(m addonMocks) {
				m.uploader.EXPECT().Upload(bucket, indexFileS3Path, gomock.Any()).Return(s3.URL("us-west-2", "chris.hare", "common-tags.yaml"), nil)
			},
			inTemplate: `
Parameters:
  CreatedBy:
    Type: String
    Description: Email address of the person creating the resource.
Transform:
  Name: 'AWS::Include'
  Parameters:
    Location: 's3://chris.hare/alb-cw-mapping.yaml'
Resources:
  bucket:
    Type: AWS::S3::Bucket
    Properties:
      Fn::Transform:
        Name: 'AWS::Include'
        Parameters:
          Location: './lambda/index.js'
Outputs:
  bucketDomainName:
    Value: !GetAtt bucket.DomainName
  bucket:
    Value: !Ref bucket
`,
			outTemplate: `
Parameters:
  CreatedBy:
    Type: String
    Description: Email address of the person creating the resource.
Transform:
  Name: 'AWS::Include'
  Parameters:
    Location: 's3://chris.hare/alb-cw-mapping.yaml'
Resources:
  bucket:
    Type: AWS::S3::Bucket
    Properties:
      Fn::Transform:
        Name: 'AWS::Include'
        Parameters:
          Location: 's3://chris.hare/common-tags.yaml'
Outputs:
  bucketDomainName:
    Value: !GetAtt bucket.DomainName
  bucket:
    Value: !Ref bucket
`,
		},
		"error on file not existing": {
			inTemplate: `
Resources:
  Test:
    Type: AWS::Lambda::Function
    Properties:
      Code: does/not/exist.js
`,
			pkgError: `package property "Code" of "Test": upload asset: stat: open /does/not/exist.js: file does not exist`,
		},
		"error on file upload error": {
			setupMocks: func(m addonMocks) {
				m.uploader.EXPECT().Upload(bucket, indexZipS3Path, gomock.Any()).Return("", errors.New("mockError"))
			},
			inTemplate: `
Resources:
  Test:
    Type: AWS::Lambda::Function
    Properties:
      Code: lambda/index.js
`,
			pkgError: `package property "Code" of "Test": upload asset: upload /lambda/index.js to s3 bucket mockBucket: mockError`,
		},
		"error on file not existing for Fn::Transform": {
			inTemplate: `
Resources:
  Test:
    Type: AWS::Lambda::Function
    Properties:
      Fn::Transform:
        Name: "AWS::Include"
        Parameters:
          Location: does/not/exist.yml
`,
			pkgError: `package transforms: upload asset: stat: open /does/not/exist.yml: file does not exist`,
		},
	}

	for name, tc := range tests {
		t.Run(name, func(t *testing.T) {
			ctrl := gomock.NewController(t)
			defer ctrl.Finish()

			mocks := addonMocks{
				uploader: mocks.NewMockuploader(ctrl),
				ws:       mocks.NewMockWorkspaceAddonsReader(ctrl),
			}
			if tc.setupMocks != nil {
				tc.setupMocks(mocks)
			}

			stack := &WorkloadStack{
				workloadName: wlName,
				stack: stack{
					template: newCFNTemplate("merged"),
				},
			}

			require.NoError(t, yaml.Unmarshal([]byte(tc.inTemplate), stack.template))

			config := PackageConfig{
				Bucket:        bucket,
				WorkspacePath: wsPath,
				Uploader:      mocks.uploader,
				FS:            fs,
			}
			err := stack.Package(config)
			if tc.pkgError != "" {
				require.EqualError(t, err, tc.pkgError)
				return
			}
			require.NoError(t, err)

			tmpl, err := stack.Template()
			require.NoError(t, err)

			require.Equal(t, strings.TrimSpace(tc.outTemplate), strings.TrimSpace(tmpl))
		})
	}

}

func TestEnvironmentAddonStack_PackagePackage(t *testing.T) {
	const (
		wsPath = "/"
		bucket = "mockBucket"
	)

	lambdaZipHash := sha256.New()
	indexZipHash := sha256.New()
	indexFileHash := sha256.New()

	// fs has the following structure:
	//  .
	//  ├─ lambda
	//  │  ├─ index.js (contains lambda function)
	//  ┴  └─ test.js (empty)
	fs := afero.NewMemMapFs()
	fs.Mkdir("/lambda", 0644)

	f, _ := fs.Create("/lambda/index.js")
	defer f.Close()
	info, _ := f.Stat()
	io.MultiWriter(lambdaZipHash, indexZipHash).Write([]byte("index.js " + info.Mode().String()))
	io.MultiWriter(f, lambdaZipHash, indexZipHash, indexFileHash).Write([]byte(`exports.handler = function(event, context) {}`))

	f2, _ := fs.Create("/lambda/test.js")
	info, _ = f2.Stat()
	lambdaZipHash.Write([]byte("test.js " + info.Mode().String()))

	indexZipS3PathForEnvironmentAddon := fmt.Sprintf("manual/addons/environments/assets/%s", hex.EncodeToString(indexZipHash.Sum(nil)))
	t.Run("package zipped AWS::Lambda::Function for environment addons", func(t *testing.T) {
		ctrl := gomock.NewController(t)
		defer ctrl.Finish()

		// Set up mocks.
		m := addonMocks{
			uploader: mocks.NewMockuploader(ctrl),
			ws:       mocks.NewMockWorkspaceAddonsReader(ctrl),
		}
		m.uploader.EXPECT().Upload(bucket, indexZipS3PathForEnvironmentAddon, gomock.Any()).Return(s3.URL("us-west-2", bucket, "asdf"), nil)

		// WHEN.
		inTemplate := `
Resources:
  Test:
    Metadata:
      "testKey": "testValue"
    Type: AWS::Lambda::Function
    Properties:
      Code: lambda/index.js
      Handler: "index.handler"
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt "TestRole.Arn"
      Runtime: nodejs20.x
`
		stack := &EnvironmentStack{
			stack: stack{
				template: newCFNTemplate("merged"),
			},
		}
		require.NoError(t, yaml.Unmarshal([]byte(inTemplate), stack.template))
		config := PackageConfig{
			Bucket:        bucket,
			WorkspacePath: wsPath,
			Uploader:      m.uploader,
			FS:            fs,
		}
		err := stack.Package(config)

		// Expect.
		outTemplate := `
Resources:
  Test:
    Metadata:
      "testKey": "testValue"
    Type: AWS::Lambda::Function
    Properties:
      Code:
        S3Bucket: mockBucket
        S3Key: asdf
      Handler: "index.handler"
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt "TestRole.Arn"
      Runtime: nodejs20.x
`
		require.NoError(t, err)
		tmpl, err := stack.Template()
		require.NoError(t, err)
		require.Equal(t, strings.TrimSpace(outTemplate), strings.TrimSpace(tmpl))
	})

}
