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

package override

import (
	"fmt"
	"path/filepath"
	"strings"
	"testing"

	"github.com/spf13/afero"
	"github.com/stretchr/testify/require"
	"gopkg.in/yaml.v3"
)

func TestScaffoldWithPatch(t *testing.T) {
	t.Run("scaffolds files in an empty directory", func(t *testing.T) {
		fs := afero.NewMemMapFs()
		dir := filepath.Join("copilot", "frontend", "overrides")

		err := ScaffoldWithPatch(fs, dir)
		require.NoError(t, err)

		ok, _ := afero.Exists(fs, filepath.Join(dir, "README.md"))
		require.True(t, ok, "README.md should exist")

		ok, _ = afero.Exists(fs, filepath.Join(dir, yamlPatchFile))
		require.True(t, ok, "cfn.patches.yml should exist")
	})
	t.Run("should return an error if the directory is not empty", func(t *testing.T) {
		fs := afero.NewMemMapFs()
		dir := filepath.Join("copilot", "frontend", "overrides")

		_ = fs.MkdirAll(dir, 0755)
		_ = afero.WriteFile(fs, filepath.Join(dir, "random.txt"), []byte("content"), 0644)

		err := ScaffoldWithPatch(fs, dir)
		require.EqualError(t, err, fmt.Sprintf("directory %q is not empty", dir))
	})
}

func TestPatch_Override(t *testing.T) {
	tests := map[string]struct {
		yaml        string
		overrides   string
		expected    string
		expectedErr string
	}{
		"add to map": {
			yaml: `
Resources:
  TaskDef:
    Type: AWS::ECS::TaskDefinition`,
			overrides: `
- op: add
  path: /Resources/TaskDef
  value:
    Properties:
      Prop1: value
      Prop2: false`,
			expected: `
Resources:
  TaskDef:
    Properties:
      Prop1: value
      Prop2: false`,
		},
		"add to map in pointer": {
			yaml: `
Resources:
  TaskDef:
    Type: AWS::ECS::TaskDefinition`,
			overrides: `
- op: add
  path: /Resources/TaskDef/Properties
  value:
    Prop1: value
    Prop2: false`,
			expected: `
Resources:
  TaskDef:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Prop1: value
      Prop2: false`,
		},
		"add to beginning sequence": {
			yaml: `
Resources:
  TaskDef:
    List:
      - asdf
      - jkl;`,
			overrides: `
- op: add
  path: /Resources/TaskDef/List/0
  value: qwerty`,
			expected: `
Resources:
  TaskDef:
    List:
      - qwerty
      - asdf
      - jkl;`,
		},
		"add to middle sequence": {
			yaml: `
Resources:
  TaskDef:
    List:
      - asdf
      - jkl;`,
			overrides: `
- op: add
  path: /Resources/TaskDef/List/1
  value: qwerty`,
			expected: `
Resources:
  TaskDef:
    List:
      - asdf
      - qwerty
      - jkl;`,
		},
		"add to end sequence by index": {
			yaml: `
Resources:
  TaskDef:
    List:
      - asdf
      - jkl;`,
			overrides: `
- op: add
  path: /Resources/TaskDef/List/2
  value: qwerty`,
			expected: `
Resources:
  TaskDef:
    List:
      - asdf
      - jkl;
      - qwerty`,
		},
		"add to end sequence with -": {
			yaml: `
Resources:
  IAMRole:
    Type: AWS::IAM::Role
    Properties:
      Policies:
        - PolicyName: "Test"
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - "*"
                  - "**"
                Resource:
                  - "*"`,
			overrides: `
- op: add
  path: /Resources/IAMRole/Properties/Policies/0/PolicyDocument/Statement/0/Action/-
  value:
    key: value
    key2: value2`,
			expected: `
Resources:
  IAMRole:
    Type: AWS::IAM::Role
    Properties:
      Policies:
        - PolicyName: "Test"
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - "*"
                  - "**"
                  - key: value
                    key2: value2
                Resource:
                  - "*"`,
		},
		"remove scalar from map": {
			yaml: `
Resources:
  TaskDef:
    Type: AWS::ECS::TaskDefinition
    Description: asdf`,
			overrides: `
- op: remove
  path: /Resources/TaskDef/Description`,
			expected: `
Resources:
  TaskDef:
    Type: AWS::ECS::TaskDefinition`,
		},
		"remove map from map": {
			yaml: `
Resources:
  TaskDef:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Prop1: value
      Prop2: value`,
			overrides: `
- op: remove
  path: /Resources/TaskDef/Properties`,
			expected: `
Resources:
  TaskDef:
    Type: AWS::ECS::TaskDefinition`,
		},
		"remove from beginning of sequence": {
			yaml: `
Resources:
  - obj1: value
    list:
      - item0
      - item1
  - obj2: value
    list:
      - item0
      - item1`,
			overrides: `
- op: remove
  path: /Resources/1/list/0`,
			expected: `
Resources:
  - obj1: value
    list:
      - item0
      - item1
  - obj2: value
    list:
      - item1`,
		},
		"remove from middle of sequence": {
			yaml: `
Resources:
  - obj1: value
    list:
      - item0
      - item1
  - obj2: value
    list:
      - item0
      - item1
      - item2`,
			overrides: `
- op: remove
  path: /Resources/1/list/1`,
			expected: `
Resources:
  - obj1: value
    list:
      - item0
      - item1
  - obj2: value
    list:
      - item0
      - item2`,
		},
		"remove from end of sequence": {
			yaml: `
Resources:
  - obj1: value
    list:
      - item0
      - item1
  - obj2: value
    list:
      - item0
      - item1
      - item2`,
			overrides: `
- op: remove
  path: /Resources/1/list/2`,
			expected: `
Resources:
  - obj1: value
    list:
      - item0
      - item1
  - obj2: value
    list:
      - item0
      - item1`,
		},
		"replace scalar with scalar": {
			yaml: `
Resources:
  TaskDef:
    Type: AWS::ECS::TaskDefinition
    Description: asdf`,
			overrides: `
- op: replace
  path: /Resources/TaskDef/Description
  value: jkl;`,
			expected: `
Resources:
  TaskDef:
    Type: AWS::ECS::TaskDefinition
    Description: jkl;`,
		},
		"replace map with scalar": {
			yaml: `
Resources:
  List:
    - asdf
    - key: value
      key2: value2
    - - very list
      - many item`,
			overrides: `
- op: replace
  path: /Resources/List/1
  value: jkl;`,
			expected: `
Resources:
  List:
    - asdf
    - jkl;
    - - very list
      - many item`,
		},
		"works with special characters": {
			yaml: `
Resources:
  key:
    key~with/weirdchars/: old`,
			overrides: `
- op: replace
  path: /Resources/key/key~0with~1weirdchars~1
  value: new`,
			expected: `
Resources:
  key:
    key~with/weirdchars/: new`,
		},
		"add works with doc selector": {
			yaml: `
Resources:
  key: value`,
			overrides: `
- op: add
  path: ""
  value:
    a: aaa
    b: bbb`,
			expected: `
a: aaa
b: bbb`,
		},
		"replace works with doc selector": {
			yaml: `
Resources:
  key: value`,
			overrides: `
- op: replace
  path: ""
  value:
    a: aaa
    b: bbb`,
			expected: `
a: aaa
b: bbb`,
		},
		"remove works with doc selector": {
			yaml: `
Resources:
  key: value`,
			overrides: `
- op: remove
  path: ""`,
			expected: ``,
		},
		"empty string key works": {
			yaml: `
key: asdf
"": old`,
			overrides: `
- op: replace
  path: /
  value: new`,
			expected: `
key: asdf
"": new`,
		},
		"nothing happens with empty patch file": {
			yaml: `
a:
  b: value`,
			expected: `
a:
  b: value`,
		},
		"error on invalid patch file format": {
			overrides: `
op: add
path: /
value: new`,
			expectedErr: `file at "/cfn.patches.yml" does not conform to the YAML patch document schema: yaml: unmarshal errors:
  line 1: cannot unmarshal !!map into []override.yamlPatch`,
		},
		"error on unsupported operation": {
			overrides: `
- op: unsupported
  path: /
  value: new`,
			expectedErr: `unsupported operation "unsupported": supported operations are "add", "remove", and "replace".`,
		},
		"error in map following path": {
			yaml: `
a:
  b:
    - c
    - d`,
			overrides: `
- op: replace
  path: /a/e/c
  value: val`,
			expectedErr: `unable to apply the "replace" patch at index 0: key "/a": "e" not found in map`,
		},
		"error out of bounds sequence following path": {
			yaml: `
a:
  b:
    - c
    - d`,
			overrides: `
- op: add
  path: /a/b/3
  value: val`,
			expectedErr: `unable to apply the "add" patch at index 0: key "/a/b": index 3 out of bounds for sequence of length 2`,
		},
		"error invalid index sequence following path": {
			yaml: `
a:
  b:
    - c
    - d`,
			overrides: `
- op: add
  path: /a/b/e
  value: val`,
			expectedErr: `unable to apply the "add" patch at index 0: key "/a/b": expected index in sequence, got "e"`,
		},
		"error invalid index sequence - in middle of path": {
			yaml: `
a:
  b:
    - key: abcd
    - key: efgh`,
			overrides: `
- op: add
  path: /a/b/-/key
  value: val`,
			expectedErr: `unable to apply the "add" patch at index 0: key "/a/b": expected index in sequence, got "-"`,
		},
		"error targeting scalar while following path add": {
			yaml: `
a:
  b:
    - c
    - d`,
			overrides: `
- op: add
  path: /a/b/1/e
  value: val`,
			expectedErr: `unable to apply the "add" patch at index 0: key "/a/b/1": invalid node type scalar`,
		},
		"error targeting scalar while following path remove": {
			yaml: `
a:
  b:
    - c
    - d`,
			overrides: `
- op: remove
  path: /a/b/1/e`,
			expectedErr: `unable to apply the "remove" patch at index 0: key "/a/b/1": invalid node type scalar`,
		},
		"error targeting scalar while following path replace": {
			yaml: `
a:
  b:
    - c
    - d`,
			overrides: `
- op: replace
  path: /a/b/1/e
  value: val`,
			expectedErr: `unable to apply the "replace" patch at index 0: key "/a/b/1": invalid node type scalar`,
		},
		"error add with no value": {
			overrides: `
- op: add
  path: /a/b/c`,
			expectedErr: `unable to apply the "add" patch at index 0: value required`,
		},
		"error replace with no value": {
			overrides: `
- op: replace
  path: /a/b/c`,
			expectedErr: `unable to apply the "replace" patch at index 0: value required`,
		},
		"error remove nonexistant value from map": {
			yaml: `
a:
  b: value`,
			overrides: `
- op: remove
  path: /a/c`,
			expectedErr: `unable to apply the "remove" patch at index 0: key "/a": "c" not found in map`,
		},
		"error patch index incrememts": {
			yaml: `
a:
  b: value`,
			overrides: `
- op: remove
  path: /a/b
- op: remove
  path: /a/c`,
			expectedErr: `unable to apply the "remove" patch at index 1: key "/a": "c" not found in map`,
		},
		"updates the Description field of a CloudFormation template with YAML patch metrics": {
			yaml: `
Description: "CloudFormation template that represents a backend service on Amazon ECS."
Resources:
  key: value`,
			overrides: `
- op: replace
  path: /Resources/key
  value: other`,
			expected: `
Description: "CloudFormation template that represents a backend service on Amazon ECS using AWS Copilot with YAML patches."
Resources:
  key: other`,
		},
	}

	for name, tc := range tests {
		t.Run(name, func(t *testing.T) {
			fs := afero.NewMemMapFs()
			file, err := fs.Create("/" + yamlPatchFile)
			require.NoError(t, err)
			_, err = file.WriteString(strings.TrimSpace(tc.overrides))
			require.NoError(t, err)

			p := WithPatch("/", PatchOpts{
				FS: fs,
			})

			out, err := p.Override([]byte(strings.TrimSpace(tc.yaml)))
			if tc.expectedErr != "" {
				require.EqualError(t, err, tc.expectedErr)
				return
			}
			require.NoError(t, err)

			// convert for better comparison output
			// limitation: doesn't test for comments sticking around
			var expected interface{}
			var actual interface{}
			require.NoError(t, yaml.Unmarshal([]byte(tc.expected), &expected))
			require.NoError(t, yaml.Unmarshal(out, &actual))

			require.Equal(t, expected, actual)
		})
	}
}