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

package yaml

import (
	"fmt"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	yaml "go.yaml.in/yaml/v3"
)

func TestPathMatcher_Filter(t *testing.T) {
	node := MustParse(`apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9
        args: [-c, conf.yaml]
        ports:
        - containerPort: 80
      - name: sidecar
        image: sidecar:1.0.0
        ports:
        - containerPort: 8081
        - containerPort: 9090
`)

	updates := []struct {
		path  []string
		value string
	}{
		{[]string{
			"spec", "template", "spec", "containers", "[name=.*]"},
			"- name: nginx\n  image: nginx:1.7.9\n  args: [-c, conf.yaml]\n  ports:\n  - containerPort: 80\n" +
				"- name: sidecar\n  image: sidecar:1.0.0\n  ports:\n  - containerPort: 8081\n  - containerPort: 9090\n"},
		{[]string{
			"spec", "template", "spec", "containers", "[name=.*]", "image"},
			"- nginx:1.7.9\n- sidecar:1.0.0\n"},
		{[]string{
			"spec", "template", "spec", "containers", "[name=n.*]", "image"},
			"- nginx:1.7.9\n"},
		{[]string{
			"spec", "template", "spec", "containers", "[name=s.*]", "image"},
			"- sidecar:1.0.0\n"},
		{[]string{
			"spec", "template", "spec", "containers", "[name=.*x]", "image"},
			"- nginx:1.7.9\n"},
		{[]string{
			"spec", "template", "spec", "containers", "[name=.*]", "ports"},
			"- - containerPort: 80\n- - containerPort: 8081\n  - containerPort: 9090\n"},
		{[]string{
			"spec", "template", "spec", "containers", "[name=.*]", "ports", "[containerPort=8.*]"},
			"- containerPort: 80\n- containerPort: 8081\n"},
		{[]string{
			"spec", "template", "spec", "containers", "[name=.*]", "ports", "[containerPort=.*1]"},
			"- containerPort: 8081\n"},
		{[]string{
			"spec", "template", "spec", "containers", "[name=.*]", "ports", "[containerPort=9.*]"},
			"- containerPort: 9090\n"},
		{[]string{
			"spec", "template", "spec", "containers", "[name=s.*]", "ports", "[containerPort=8.*]"},
			"- containerPort: 8081\n"},
		{[]string{
			"spec", "template", "spec", "containers", "[name=s.*]", "ports", "[containerPort=.*2]"},
			""},
		{[]string{
			"spec", "template", "spec", "containers", "*", "image"},
			"- nginx:1.7.9\n- sidecar:1.0.0\n"},
		{[]string{
			"spec", "template", "spec", "containers", "*", "ports", "*"},
			"- containerPort: 80\n- containerPort: 8081\n- containerPort: 9090\n"},
		{[]string{
			"spec", "template", "spec", "containers", "[name=.*]", "args", "1"},
			"- conf.yaml\n"},
	}
	for i, u := range updates {
		result, err := node.Pipe(&PathMatcher{Path: u.path})
		if !assert.NoError(t, err) {
			return
		}
		assert.Equal(t, u.value, result.MustString(), fmt.Sprintf("%d", i))
	}
}

func TestPathMatcher_Filter_Create(t *testing.T) {
	testCases := map[string]struct {
		path                    []string
		matches                 []string
		modifiedNodeMustContain string
		create                  yaml.Kind
		expectErr               string
	}{
		"create non-primitive sequence item that does not exist": {
			path: []string{"spec", "template", "spec", "containers", "[name=please-create-me]"},
			matches: []string{
				"name: please-create-me\n",
			},
			modifiedNodeMustContain: "- name: please-create-me",
			create:                  yaml.MappingNode,
		},
		"create non-primitive item in empty sequence by index": {
			path:                    []string{"spec", "template", "spec", "containers", "[name=nginx]", "envFrom", "0"},
			matches:                 []string{"{}\n"},
			modifiedNodeMustContain: "envFrom:\n        - {}\n",
			create:                  yaml.MappingNode,
		},
		"create primitive item in empty sequence by index": {
			path:                    []string{"spec", "template", "spec", "containers", "[name=sidecar]", "args", "0"},
			matches:                 []string{"\n"},
			modifiedNodeMustContain: "args:\n        -\n",
			create:                  yaml.ScalarNode,
		},
		"append primitive item to sequence by index": {
			path:                    []string{"spec", "template", "spec", "containers", "[name=nginx]", "args", "2"},
			matches:                 []string{"\n"},
			create:                  yaml.ScalarNode,
			modifiedNodeMustContain: "args: [-c, conf.yaml, '']",
		},
		"append non-primitive item to sequence by index": {
			path:                    []string{"spec", "template", "spec", "containers", "[name=nginx]", "ports", "1"},
			matches:                 []string{"{}\n"},
			modifiedNodeMustContain: "ports: [{containerPort: 80}, {}]",
			create:                  yaml.MappingNode,
		},
		"appending non-primitive element in middle of sequence": {
			path:                    []string{"spec", "template", "spec", "containers", "2", "imagePullPolicy"},
			matches:                 []string{"\n"},
			create:                  yaml.ScalarNode,
			modifiedNodeMustContain: "\n      - imagePullPolicy:\n",
		},
		"fail to create non-primitive item by non-zero index in created sequence": {
			path:      []string{"spec", "template", "spec", "containers", "[name=nginx]", "envFrom", "1"},
			matches:   []string{},
			create:    yaml.MappingNode,
			expectErr: "index 1 specified but only 0 elements found",
		},
		"fail to create primitive item by non-zero index in created sequence": {
			path:      []string{"spec", "template", "spec", "containers", "[name=sidecar]", "args", "1"},
			matches:   []string{},
			create:    yaml.ScalarNode,
			expectErr: "index 1 specified but only 0 elements found",
		},
		"fail to create non-primitive item by distant index in existing sequence": {
			path:      []string{"spec", "template", "spec", "containers", "3"},
			matches:   []string{},
			create:    yaml.MappingNode,
			expectErr: "index 3 specified but only 2 elements found",
		},
		"fail to create primitive item by distant index in existing sequence": {
			path:      []string{"spec", "template", "spec", "containers", "[name=nginx]", "args", "3"},
			matches:   []string{},
			create:    yaml.ScalarNode,
			expectErr: "index 3 specified but only 2 elements found",
		},
		"create primitive sequence item that does not exist": {
			path: []string{"metadata", "finalizers", "[=create-me]"},
			matches: []string{
				"create-me\n",
			},
			modifiedNodeMustContain: "finalizers:\n  - create-me\n",
			create:                  yaml.ScalarNode,
		},
		"create series of maps that do not exist": {
			path: []string{"spec", "selector", "matchLabels", "does-not-exist"},
			matches: []string{
				"{}\n",
			},
			modifiedNodeMustContain: "selector:\n    matchLabels:\n      app: nginx\n      does-not-exist: {}\n",
			create:                  yaml.MappingNode,
		},
		"create scalar below series of maps and sequences that do not exist": {
			path: []string{"spec", "template", "spec", "containers", "[name=please-create-me]", "env", "[key=please-create-me]", "value"},
			matches: []string{
				"\n",
			},
			modifiedNodeMustContain: "- name: please-create-me\n        env:\n        - key: please-create-me\n          value:\n",
			create:                  yaml.ScalarNode,
		},
		"find sequence items that already exist": {
			path: []string{"spec", "template", "spec", "containers", "[name=.*]"},
			matches: []string{
				"name: nginx\nimage: nginx:1.7.9\nargs: [-c, conf.yaml]\nports: [{containerPort: 80}]\nenv:\n- key: CONTAINER_NAME\n  value: nginx\n",
				"name: sidecar\nimage: sidecar:1.0.0\nports:\n- containerPort: 8081\n- containerPort: 9090\n",
			},
			create: yaml.MappingNode,
		},
		"find and create sequence below wildcard that exists on some sequence items": {
			path: []string{"spec", "template", "spec", "containers", "[name=.*]", "env"},
			matches: []string{
				"- key: CONTAINER_NAME\n  value: nginx\n",
				"[]\n",
			},
			create: yaml.SequenceNode,
		},
		"find field below wildcard that exists on all sequence items": {
			path: []string{"spec", "template", "spec", "containers", "[name=.*]", "ports"},
			matches: []string{
				"[{containerPort: 80}]\n",
				"- containerPort: 8081\n- containerPort: 9090\n",
			},
			create: yaml.SequenceNode,
		},
		"find field below query that targets a specific item": {
			path: []string{"spec", "template", "spec", "containers", "[name=nginx]", "env"},
			matches: []string{
				"- key: CONTAINER_NAME\n  value: nginx\n",
			},
			create: yaml.SequenceNode,
		},
		"create field below query that targets any value of a field that does not exist": {
			path: []string{"spec", "template", "spec", "containers", "[foo=.*]", "env"},
			matches: []string{
				"[]\n",
			},
			// This is kinda weird. The query doesn't match anything, and we can't tell that it is a
			// wildcard rather than a literal, so we use the value to create the field.
			modifiedNodeMustContain: "- foo: .*\n        env: []\n",
			create:                  yaml.SequenceNode,
		},
	}
	nodeStr := `apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9
        args: [-c, conf.yaml]
        ports: [{containerPort: 80}]
        env:
        - key: CONTAINER_NAME
          value: nginx
      - name: sidecar
        image: sidecar:1.0.0
        ports:
        - containerPort: 8081
        - containerPort: 9090
`
	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			node := MustParse(nodeStr)
			result, err := node.Pipe(&PathMatcher{Path: tc.path, Create: tc.create})
			if tc.expectErr != "" {
				require.EqualError(t, err, tc.expectErr)
				return
			}
			require.NoError(t, err)
			matches, err := result.Elements()
			require.NoError(t, err)
			require.Equalf(t, len(tc.matches), len(matches), "Full sequence wrapper of result:\n%s", result.MustString())

			modifiedNode := node.MustString()
			for i, expected := range tc.matches {
				assert.Equal(t, tc.create, matches[i].YNode().Kind)
				assert.Equal(t, expected, matches[i].MustString())
				assert.Contains(t, modifiedNode, tc.modifiedNodeMustContain)
			}
		})
	}
}

func TestPathMatcher_StructuredDataInScalar(t *testing.T) {
	testCases := map[string]struct {
		input       string
		path        []string
		expected    string
		expectError bool
	}{
		"json field access": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: test-config
data:
  config.json: |-
    {
      "database": {
        "host": "localhost",
        "port": 5432
      },
      "app": {
        "name": "myapp",
        "version": "1.0.0"
      }
    }`,
			path:     []string{"data", "config.json", "database", "host"},
			expected: "- \"localhost\"\n",
		},
		"json nested field access": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: test-config
data:
  config.json: |-
    {
      "database": {
        "host": "localhost",
        "port": 5432
      },
      "app": {
        "name": "myapp",
        "version": "1.0.0"
      }
    }`,
			path:     []string{"data", "config.json", "app", "name"},
			expected: "- \"myapp\"\n",
		},
		"yaml field access": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: prometheus-config
data:
  prometheus.yml: |-
    global:
      external_labels:
        prometheus_env: dev
        cluster: local
    scrape_configs:
      - job_name: "prometheus"
        static_configs:
          - targets: ["localhost:9090"]`,
			path:     []string{"data", "prometheus.yml", "global", "external_labels", "prometheus_env"},
			expected: "- dev\n",
		},
		"yaml array access": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: deployment-config
data:
  deployment.yaml: |-
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: my-app
    spec:
      replicas: 3
      template:
        spec:
          containers:
          - name: web
            image: nginx:1.18
          - name: sidecar
            image: busybox:latest`,
			path:     []string{"data", "deployment.yaml", "spec", "replicas"},
			expected: "- 3\n",
		},
		"yaml container array with selector": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: deployment-config
data:
  deployment.yaml: |-
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: my-app
    spec:
      template:
        spec:
          containers:
          - name: web
            image: nginx:1.18
          - name: sidecar
            image: busybox:latest`,
			path:     []string{"data", "deployment.yaml", "spec", "template", "spec", "containers", "[name=web]", "image"},
			expected: "- nginx:1.18\n",
		},
		"json complex nested structure": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: complex-config
data:
  config.json: |-
    {
      "database": {
        "connections": {
          "primary": {
            "host": "primary-db.example.com",
            "port": 5432,
            "ssl": true
          },
          "secondary": {
            "host": "secondary-db.example.com",
            "port": 5433
          }
        }
      }
    }`,
			path:     []string{"data", "config.json", "database", "connections", "primary", "host"},
			expected: "- \"primary-db.example.com\"\n",
		},
		"yaml flow style (kyaml) field access": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: test-config
data:
  config.yaml: |-
    labels: {
      app: "foobar",
      foo: "bar",
      something: "12345",
    }
    spec: {
      replicas: 3,
      selector: {
        matchLabels: {
          app: "foobar"
        }
      }
    }`,
			path:     []string{"data", "config.yaml", "labels", "app"},
			expected: "- \"foobar\"\n",
		},
		"yaml flow style nested field access": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: test-config
data:
  config.yaml: |-
    labels: {
      app: "foobar",
      foo: "bar",
      something: "12345",
    }
    spec: {
      replicas: 3,
      selector: {
        matchLabels: {
          app: "foobar"
        }
      }
    }`,
			path:     []string{"data", "config.yaml", "spec", "selector", "matchLabels", "app"},
			expected: "- \"foobar\"\n",
		},
		"invalid json returns field value as-is": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: bad-config
data:
  config.json: |-
    {
      "invalid": json
    }`,
			path:     []string{"data", "config.json"},
			expected: "- |-\n  {\n    \"invalid\": json\n  }\n",
		},
		"access non-existent field": {
			input: `apiVersion: v1
kind: ConfigMap
metadata:
  name: test-config
data:
  config.json: |-
    {
      "existing": "value"
    }`,
			path:     []string{"data", "config.json", "nonexistent"},
			expected: "",
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			node := MustParse(tc.input)
			result, err := node.Pipe(&PathMatcher{Path: tc.path})

			if tc.expectError {
				assert.Error(t, err)
				return
			}

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

			if tc.expected == "" {
				assert.True(t, result == nil || result.IsNil() || result.MustString() == "")
				return
			}

			assert.Equal(t, tc.expected, result.MustString())
		})
	}
}
