// Copyright 2019 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0

package kio_test

import (
	"bytes"
	"reflect"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
	"sigs.k8s.io/kustomize/kyaml/kio/kioutil"
	"sigs.k8s.io/kustomize/kyaml/yaml"

	. "sigs.k8s.io/kustomize/kyaml/kio"
)

func TestPipe(t *testing.T) {
	p := Pipeline{
		Inputs:  []Reader{},
		Filters: []Filter{},
		Outputs: []Writer{},
	}

	err := p.Execute()
	if !assert.NoError(t, err) {
		assert.FailNow(t, err.Error())
	}
}

type mockCallback struct {
	mock.Mock
}

func (c *mockCallback) Callback(op Filter) {
	c.Called(op)
}

func TestPipelineWithCallback(t *testing.T) {
	input := ResourceNodeSlice{yaml.MakeNullNode()}
	noopFilter1 := func(nodes []*yaml.RNode) ([]*yaml.RNode, error) {
		return nodes, nil
	}
	noopFilter2 := func(nodes []*yaml.RNode) ([]*yaml.RNode, error) {
		return nodes, nil
	}
	filters := []Filter{
		FilterFunc(noopFilter1),
		FilterFunc(noopFilter2),
	}
	p := Pipeline{
		Inputs:  []Reader{input},
		Filters: filters,
		Outputs: []Writer{},
	}

	callback := mockCallback{}
	// setup expectations. `Times` means the function is called no more than `times`.
	callback.On("Callback", mock.Anything).Times(len(filters))

	err := p.ExecuteWithCallback(callback.Callback)

	if !assert.NoError(t, err) {
		assert.FailNow(t, err.Error())
	}

	callback.AssertNumberOfCalls(t, "Callback", len(filters))

	// assert filters are called in the order they are defined.
	for i, filter := range filters {
		assert.Equal(
			t,
			reflect.ValueOf(callback.Calls[i].Arguments[0]).Pointer(),
			reflect.ValueOf(filter).Pointer(),
		)
	}
}

func TestEmptyInput(t *testing.T) {
	actual := &bytes.Buffer{}
	output := ByteWriter{
		Sort:               true,
		WrappingKind:       ResourceListKind,
		WrappingAPIVersion: ResourceListAPIVersion,
	}
	output.Writer = actual

	p := Pipeline{
		Outputs: []Writer{output},
	}

	if err := p.Execute(); err != nil {
		t.Fatal(err)
	}

	expected := `
apiVersion: config.kubernetes.io/v1
kind: ResourceList
items: []
`

	if !assert.Equal(t,
		strings.TrimSpace(expected), strings.TrimSpace(actual.String())) {
		t.FailNow()
	}
}

func TestEmptyInputWithFilter(t *testing.T) {
	actual := &bytes.Buffer{}
	output := ByteWriter{
		Sort:               true,
		WrappingKind:       ResourceListKind,
		WrappingAPIVersion: ResourceListAPIVersion,
	}
	output.Writer = actual

	filters := []Filter{
		FilterFunc(func(nodes []*yaml.RNode) ([]*yaml.RNode, error) {
			nodes = append(nodes, yaml.NewMapRNode(&map[string]string{
				"foo": "bar",
			}))
			return nodes, nil
		}),
		FilterFunc(func(nodes []*yaml.RNode) ([]*yaml.RNode, error) { return nodes, nil }),
	}

	p := Pipeline{
		Outputs: []Writer{output},
		Filters: filters,
	}

	if err := p.Execute(); err != nil {
		t.Fatal(err)
	}

	expected := `
apiVersion: config.kubernetes.io/v1
kind: ResourceList
items:
- foo: bar
`

	if !assert.Equal(t,
		strings.TrimSpace(expected), strings.TrimSpace(actual.String())) {
		t.FailNow()
	}
}

func TestContinueOnEmptyBehavior(t *testing.T) {
	cases := map[string]struct {
		continueOnEmptyResult bool
		expected              string
	}{
		"quit on empty":     {continueOnEmptyResult: false, expected: ""},
		"continue on empty": {continueOnEmptyResult: true, expected: "foo: bar"},
	}
	for _, tc := range cases {
		actual := &bytes.Buffer{}
		output := ByteWriter{Writer: actual}

		generatorFunc := FilterFunc(func(nodes []*yaml.RNode) ([]*yaml.RNode, error) {
			nodes = append(nodes, yaml.NewMapRNode(&map[string]string{
				"foo": "bar",
			}))
			return nodes, nil
		})
		emptyFunc := FilterFunc(func(nodes []*yaml.RNode) ([]*yaml.RNode, error) { return nodes, nil })

		p := Pipeline{
			Outputs:               []Writer{output},
			Filters:               []Filter{emptyFunc, generatorFunc},
			ContinueOnEmptyResult: tc.continueOnEmptyResult,
		}

		err := p.Execute()
		if err != nil {
			t.Fatal(err)
		}

		if !assert.Equal(t,
			tc.expected, strings.TrimSpace(actual.String())) {
			t.Fail()
		}
	}
}

func TestLegacyAnnotationReconciliation(t *testing.T) {
	noopFilter1 := func(nodes []*yaml.RNode) ([]*yaml.RNode, error) {
		return nodes, nil
	}
	noopFilter2 := func(nodes []*yaml.RNode) ([]*yaml.RNode, error) {
		return nodes, nil
	}
	changeInternalAnnos := func(nodes []*yaml.RNode) ([]*yaml.RNode, error) {
		for _, rn := range nodes {
			if err := rn.PipeE(yaml.SetAnnotation(kioutil.PathAnnotation, "new")); err != nil {
				return nil, err
			}
			if err := rn.PipeE(yaml.SetAnnotation(kioutil.IndexAnnotation, "new")); err != nil {
				return nil, err
			}
		}
		return nodes, nil
	}
	changeLegacyAnnos := func(nodes []*yaml.RNode) ([]*yaml.RNode, error) {
		for _, rn := range nodes {
			if err := rn.PipeE(yaml.SetAnnotation(kioutil.LegacyPathAnnotation, "new")); err != nil {
				return nil, err
			}
			if err := rn.PipeE(yaml.SetAnnotation(kioutil.LegacyIndexAnnotation, "new")); err != nil {
				return nil, err
			}
		}
		return nodes, nil
	}
	changeBothPathAnnosMatch := func(nodes []*yaml.RNode) ([]*yaml.RNode, error) {
		for _, rn := range nodes {
			if err := rn.PipeE(yaml.SetAnnotation(kioutil.LegacyPathAnnotation, "new")); err != nil {
				return nil, err
			}
			if err := rn.PipeE(yaml.SetAnnotation(kioutil.PathAnnotation, "new")); err != nil {
				return nil, err
			}
		}
		return nodes, nil
	}
	changeBothPathAnnosMismatch := func(nodes []*yaml.RNode) ([]*yaml.RNode, error) {
		for _, rn := range nodes {
			if err := rn.PipeE(yaml.SetAnnotation(kioutil.LegacyPathAnnotation, "foo")); err != nil {
				return nil, err
			}
			if err := rn.PipeE(yaml.SetAnnotation(kioutil.PathAnnotation, "bar")); err != nil {
				return nil, err
			}
		}
		return nodes, nil
	}

	noops := []Filter{
		FilterFunc(noopFilter1),
		FilterFunc(noopFilter2),
	}
	internal := []Filter{FilterFunc(changeInternalAnnos)}
	legacy := []Filter{FilterFunc(changeLegacyAnnos)}
	changeBothMatch := []Filter{FilterFunc(changeBothPathAnnosMatch), FilterFunc(noopFilter1)}
	changeBothMismatch := []Filter{FilterFunc(changeBothPathAnnosMismatch), FilterFunc(noopFilter1)}

	testCases := map[string]struct {
		input       string
		filters     []Filter
		expected    string
		expectedErr string
	}{
		// the orchestrator should copy the legacy annotations to the new
		// annotations
		"legacy annotations only": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    config.kubernetes.io/path: 'configmap.yaml'
    config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    config.kubernetes.io/path: "configmap.yaml"
    config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
			filters: noops,
			expected: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    config.kubernetes.io/path: 'configmap.yaml'
    config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    config.kubernetes.io/path: "configmap.yaml"
    config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
		},
		// the orchestrator should copy the new annotations to the
		// legacy annotations
		"new annotations only": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    internal.config.kubernetes.io/path: 'configmap.yaml'
    internal.config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    internal.config.kubernetes.io/path: "configmap.yaml"
    internal.config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
			filters: noops,
			expected: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    internal.config.kubernetes.io/path: 'configmap.yaml'
    internal.config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    internal.config.kubernetes.io/path: "configmap.yaml"
    internal.config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
		},
		// the orchestrator should detect that the legacy annotations
		// have been changed by the function
		"change only legacy annotations": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    config.kubernetes.io/path: 'configmap.yaml'
    config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    config.kubernetes.io/path: "configmap.yaml"
    config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
			filters: legacy,
			expected: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    config.kubernetes.io/path: 'new'
    config.kubernetes.io/index: 'new'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    config.kubernetes.io/path: "new"
    config.kubernetes.io/index: 'new'
data:
  grpcPort: 8081
`,
		},
		// the orchestrator should detect that the new internal annotations
		// have been changed by the function
		"change only internal annotations": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    internal.config.kubernetes.io/path: 'configmap.yaml'
    internal.config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    internal.config.kubernetes.io/path: "configmap.yaml"
    internal.config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
			filters: internal,
			expected: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    internal.config.kubernetes.io/path: 'new'
    internal.config.kubernetes.io/index: 'new'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    internal.config.kubernetes.io/path: "new"
    internal.config.kubernetes.io/index: 'new'
data:
  grpcPort: 8081
`,
		},
		// the orchestrator should detect that the legacy annotations
		// have been changed by the function
		"change only internal annotations while input is legacy annotations": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    config.kubernetes.io/path: 'configmap.yaml'
    config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    config.kubernetes.io/path: "configmap.yaml"
    config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
			filters: internal,
			expected: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    config.kubernetes.io/path: 'new'
    config.kubernetes.io/index: 'new'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    config.kubernetes.io/path: "new"
    config.kubernetes.io/index: 'new'
data:
  grpcPort: 8081
`,
		},
		// the orchestrator should detect that the new internal annotations
		// have been changed by the function
		"change only legacy annotations while input is internal annotations": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    internal.config.kubernetes.io/path: 'configmap.yaml'
    internal.config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    internal.config.kubernetes.io/path: "configmap.yaml"
    internal.config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
			filters: legacy,
			expected: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    internal.config.kubernetes.io/path: 'new'
    internal.config.kubernetes.io/index: 'new'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    internal.config.kubernetes.io/path: "new"
    internal.config.kubernetes.io/index: 'new'
data:
  grpcPort: 8081
`,
		},
		// the orchestrator should detect that the legacy annotations
		// have been changed by the function
		"change only legacy annotations while input has both": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    config.kubernetes.io/path: 'configmap.yaml'
    config.kubernetes.io/index: '0'
    internal.config.kubernetes.io/path: 'configmap.yaml'
    internal.config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    config.kubernetes.io/path: "configmap.yaml"
    config.kubernetes.io/index: '1'
    internal.config.kubernetes.io/path: 'configmap.yaml'
    internal.config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
			filters: legacy,
			expected: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    config.kubernetes.io/path: 'new'
    config.kubernetes.io/index: 'new'
    internal.config.kubernetes.io/path: 'new'
    internal.config.kubernetes.io/index: 'new'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    config.kubernetes.io/path: "new"
    config.kubernetes.io/index: 'new'
    internal.config.kubernetes.io/path: 'new'
    internal.config.kubernetes.io/index: 'new'
data:
  grpcPort: 8081
`,
		},
		// the orchestrator should detect that the new internal annotations
		// have been changed by the function
		"change only internal annotations while input has both": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    config.kubernetes.io/path: "configmap.yaml"
    config.kubernetes.io/index: '0'
    internal.config.kubernetes.io/path: 'configmap.yaml'
    internal.config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    config.kubernetes.io/path: "configmap.yaml"
    config.kubernetes.io/index: '1'
    internal.config.kubernetes.io/path: "configmap.yaml"
    internal.config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
			filters: internal,
			expected: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    config.kubernetes.io/path: "new"
    config.kubernetes.io/index: 'new'
    internal.config.kubernetes.io/path: 'new'
    internal.config.kubernetes.io/index: 'new'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    config.kubernetes.io/path: "new"
    config.kubernetes.io/index: 'new'
    internal.config.kubernetes.io/path: "new"
    internal.config.kubernetes.io/index: 'new'
data:
  grpcPort: 8081
`,
		},
		// the orchestrator should detect that the new internal annotations
		// have been changed by the function
		"change both to matching value while input has both": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    config.kubernetes.io/path: "configmap.yaml"
    config.kubernetes.io/index: '0'
    internal.config.kubernetes.io/path: 'configmap.yaml'
    internal.config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    config.kubernetes.io/path: "configmap.yaml"
    config.kubernetes.io/index: '1'
    internal.config.kubernetes.io/path: "configmap.yaml"
    internal.config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
			filters: changeBothMatch,
			expected: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    config.kubernetes.io/path: "new"
    config.kubernetes.io/index: '0'
    internal.config.kubernetes.io/path: 'new'
    internal.config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    config.kubernetes.io/path: "new"
    config.kubernetes.io/index: '1'
    internal.config.kubernetes.io/path: "new"
    internal.config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
		},
		// the orchestrator should detect that the new internal annotations
		// have been changed by the function
		"change both to matching value while input is legacy": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    config.kubernetes.io/path: "configmap.yaml"
    config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    config.kubernetes.io/path: "configmap.yaml"
    config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
			filters: changeBothMatch,
			expected: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    config.kubernetes.io/path: "new"
    config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    config.kubernetes.io/path: "new"
    config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
		},
		// the orchestrator should detect that the new internal annotations
		// have been changed by the function
		"change both to matching value while input is internal": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    internal.config.kubernetes.io/path: 'configmap.yaml'
    internal.config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    internal.config.kubernetes.io/path: "configmap.yaml"
    internal.config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
			filters: changeBothMatch,
			expected: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    internal.config.kubernetes.io/path: 'new'
    internal.config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    internal.config.kubernetes.io/path: "new"
    internal.config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
		},
		// the function changes both the legacy and new path annotation, and they mismatch,
		// so we should get an error
		"change both but mismatch while input is legacy": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    config.kubernetes.io/path: 'configmap.yaml'
    config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    config.kubernetes.io/path: "configmap.yaml"
    config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
			filters:     changeBothMismatch,
			expectedErr: "resource input to function has mismatched legacy and internal path annotations",
		},
		// the function changes both the legacy and new path annotation, and they mismatch,
		// so we should get an error
		"change both but mismatch while input is internal": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    internal.config.kubernetes.io/path: "configmap.yaml"
    internal.config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    internal.config.kubernetes.io/path: "configmap.yaml"
    internal.config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
			filters:     changeBothMismatch,
			expectedErr: "resource input to function has mismatched legacy and internal path annotations",
		},
		// the function changes both the legacy and new path annotation, and they mismatch,
		// so we should get an error
		"change both but mismatch while input has both": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-from
  annotations:
    config.kubernetes.io/path: 'configmap.yaml'
    config.kubernetes.io/index: '0'
    config.k8s.io/id: '1'
    internal.config.kubernetes.io/path: "configmap.yaml"
    internal.config.kubernetes.io/index: '0'
data:
  grpcPort: 8080
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: ports-to
  annotations:
    config.kubernetes.io/path: "configmap.yaml"
    config.kubernetes.io/index: '1'
    config.k8s.io/id: '2'
    internal.config.kubernetes.io/path: "configmap.yaml"
    internal.config.kubernetes.io/index: '1'
data:
  grpcPort: 8081
`,
			filters:     changeBothMismatch,
			expectedErr: "resource input to function has mismatched legacy and internal path annotations",
		},
	}

	for tn, tc := range testCases {
		t.Run(tn, func(t *testing.T) {
			var out bytes.Buffer
			input := ByteReadWriter{
				Reader:                bytes.NewBufferString(tc.input),
				Writer:                &out,
				OmitReaderAnnotations: true,
				KeepReaderAnnotations: true,
			}
			p := Pipeline{
				Inputs:  []Reader{&input},
				Filters: tc.filters,
				Outputs: []Writer{&input},
			}

			err := p.Execute()
			if tc.expectedErr == "" {
				require.NoError(t, err)
				assert.Equal(t, tc.expected, out.String())
			} else {
				require.Error(t, err)
				assert.Equal(t, tc.expectedErr, err.Error())
			}
		})
	}
}
