// Copyright Istio Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package validate

import (
	"bytes"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"testing"

	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"sigs.k8s.io/yaml"

	"istio.io/istio/istioctl/pkg/cli"
	"istio.io/istio/pkg/test/util/assert"
)

const (
	validDeploymentList = `
apiVersion: v1
items:
- apiVersion: apps/v1
  kind: Deployment
  metadata:
    labels:
      app: hello
      version: v1
    name: hello-v1
  spec:
    replicas: 1
    template:
      metadata:
        labels:
          app: hello
          version: v1
      spec:
        containers:
        - name: hello
          image: istio/examples-hello
          imagePullPolicy: IfNotPresent
          ports:
          - containerPort: 9080
- apiVersion: apps/v1
  kind: Deployment
  metadata:
    labels:
      app: details
      version: v2
    name: details
  spec:
    replicas: 1
    template:
      metadata:
        labels:
          app: details
          version: v1
      spec:
        containers:
        - name: details
          image: istio/examples-bookinfo-details-v1:1.10.1
          imagePullPolicy: IfNotPresent
          ports:
          - containerPort: 9080
kind: List
metadata:
  resourceVersion: ""
  selfLink: ""`
	invalidSvcList = `
apiVersion: v1
items:
  -
    apiVersion: v1
    kind: Service
    metadata:
      name: details
    spec:
      ports:
        -
          name: details
          port: 9080
  -
    apiVersion: v1
    kind: Service
    metadata:
      name: hello
    spec:
      ports:
        -
          port: 80
          protocol: TCP
kind: List
metadata:
  resourceVersion: ""`
	udpService = `
kind: Service
metadata:
  name: hello
spec:
  ports:
    -
      protocol: udp`
	skippedService = `
kind: Service
metadata:
  name: hello
  namespace: istio-system
spec:
  ports:
    -
      name: http
      port: 9080`
	validPortNamingSvc = `
apiVersion: v1
kind: Service
metadata:
  name: hello
spec:
  ports:
    - name: http
      port: 9080`
	validPortNamingWithSuffixSvc = `
apiVersion: v1
kind: Service
metadata:
  name: hello
spec:
  ports:
    - name: http-hello
      port: 9080`
	invalidPortNamingSvc = `
apiVersion: v1
kind: Service
metadata:
  name: hello
spec:
  ports:
    - name: hello
      port: 9080`
	portNameMissingSvc = `
apiVersion: v1
kind: Service
metadata:
  name: hello
spec:
  ports:
  - protocol: TCP`
	validVirtualServiceYAML = `
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: valid-virtual-service
spec:
  hosts:
    - c
  http:
    - route:
      - destination:
          host: c
          subset: v1
        weight: 75
      - destination:
          host: c
          subset: v2
        weight: 25`
	validVirtualServiceJSON = `{
"apiVersion": "networking.istio.io/v1alpha3",
"kind": "VirtualService",
"metadata": {
	"name": "valid-virtual-service"
},
"spec": {
	"hosts": [
	"c"
	],
	"http": [
	{
		"route": [
		{
			"destination": {
			"host": "c",
			"subset": "v1"
			},
			"weight": 75
		},
		{
			"destination": {
			"host": "c",
			"subset": "v2"
			},
			"weight": 25
		}
		]
	}
	]
}
}`
	validVirtualService1YAML = `
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: valid-virtual-service1
spec:
  hosts:
    - d
  http:
    - route:
      - destination:
          host: c
          subset: v1
        weight: 75
      - destination:
          host: c
          subset: v2
        weight: 25`
	validVirtualService1JSON = `{
"apiVersion": "networking.istio.io/v1alpha3",
"kind": "VirtualService",
"metadata": {
	"name": "valid-virtual-service1"
},
"spec": {
	"hosts": [
	"d"
	],
	"http": [
	{
		"route": [
		{
			"destination": {
			"host": "c",
			"subset": "v1"
			},
			"weight": 75
		},
		{
			"destination": {
			"host": "c",
			"subset": "v2"
			},
			"weight": 25
		}
		]
	}
	]
}
}`
	validVirtualService2YAML = `
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: valid-virtual-service2
spec:
  exportTo:
  - '.'
  hosts:
  - d
  http:
  - route:
    - destination:
        host: c
        subset: v1`
	validVirtualService2JSON = `{
"apiVersion": "networking.istio.io/v1alpha3",
"kind": "VirtualService",
"metadata": {
	"name": "valid-virtual-service2"
},
"spec": {
	"exportTo": [
	"."
	],
	"hosts": [
	"d"
	],
	"http": [
	{
		"route": [
		{
			"destination": {
			"host": "c",
			"subset": "v1"
			}
		}
		]
	}
	]
}
}`
	invalidVirtualServiceYAML = `
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: invalid-virtual-service
spec:
  http:
    - route:
      - destination:
          host: c
          subset: v1
        weight: 75
      - destination:
          host: c
          subset: v2
        weight: -15`
	invalidVirtualServiceJSON = `{
"apiVersion": "networking.istio.io/v1alpha3",
"kind": "VirtualService",
"metadata": {
	"name": "invalid-virtual-service"
},
"spec": {
	"http": [
	{
		"route": [
		{
			"destination": {
			"host": "c",
			"subset": "v1"
			},
			"weight": 75
		},
		{
			"destination": {
			"host": "c",
			"subset": "v2"
			},
			"weight": -15
		}
		]
	}
	]
}
}
`
	invalidVirtualServiceV1Beta1 = `
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: invalid-virtual-service
spec:
  http:
`
	warnDestinationRule = `apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
  name: reviews-cb-policy
spec:
  host: reviews.prod.svc.cluster.local
  trafficPolicy:
    outlierDetection:
      consecutiveErrors: 7
`
	invalidYAML = `
(...!)`
	validKubernetesYAML = `
apiVersion: v1
kind: Namespace
metadata:
  name: istio-system
`
	validKubernetesJSON = `{
"apiVersion": "v1",
"kind": "Namespace",
"metadata": {
	"name": "istio-system"
}
}`
	invalidUnsupportedKey = `
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
  name: productpage
unexpected_junk:
   still_more_junk:
spec:
  host: productpage`
	versionLabelMissingDeployment = `
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello
spec:`
	skippedDeployment = `
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello
  namespace: istio-system
spec: ~`
	invalidIstioConfig = `
apiVersion: install.istio.io/v1alpha1
kind: IstioOperator
metadata:
  namespace: istio-system
  name: example-istiocontrolplane
spec:
  dummy:
  traffic_management:
    components:
    namespace: istio-traffic-management
`
	validIstioConfig = `
apiVersion: install.istio.io/v1alpha1
kind: IstioOperator
metadata:
  namespace: istio-system
  name: example-istiocontrolplane
spec:
  components:
    pilot:
      enabled: true
`
	invalidDuplicateKey = `
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
  name: productpage
spec:
trafficPolicy: {}
trafficPolicy:
  tls:
    mode: ISTIO_MUTUAL
`
	validDeployment = `
apiVersion: apps/v1
kind: Deployment
metadata:
  name: helloworld-v1
  labels:
    app: helloworld
    version: v1
spec:
  replicas: 1
  selector:
    matchLabels:
      app: helloworld
      version: v1
  template:
    metadata:
      annotations:
        sidecar.istio.io/bootstrapOverride: "istio-custom-bootstrap-config"
      labels:
        app: helloworld
        version: v1
    spec:
      containers:
        - name: helloworld
          image: registry.istio.io/release/examples-helloworld-v1
          resources:
            requests:
              cpu: "100m"
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 5000
`
	validPortNamingSvcWithAppProtocol = `
apiVersion: v1
kind: Service
metadata:
  name: hello
spec:
  ports:
    - appProtocol: http
      port: 9080`
	validPortNamingSvcWithAppProtocol2 = `
apiVersion: v1
kind: Service
metadata:
  name: hello
spec:
  ports:
    - appProtocol: http
      name: fake # should not have error since the appProtocol field
      port: 9080`
	inValidPortNamingSvcWithAppProtocol = `
apiVersion: v1
kind: Service
metadata:
  name: hello
spec:
  ports:
    - appProtocol: fake
      port: 9080`
	validK8sRecommendedLabels = `
apiVersion: v1
kind: Deployment
metadata:
  name: helloworld-v1
  labels:
    app.kubernetes.io/name: helloworld
    app.kubernetes.io/version: v1
spec:
  replicas: 1
`
	validIstioCanonical = `
apiVersion: v1
kind: Deployment
metadata:
  name: helloworld-v1
  labels:
    service.istio.io/canonical-name: helloworld
    service.istio.io/canonical-revision: v1
spec:
  replicas: 1
`
)

func fromYAML(in string) *unstructured.Unstructured {
	var un unstructured.Unstructured
	if err := yaml.Unmarshal([]byte(in), &un); err != nil {
		panic(err)
	}
	return &un
}

func TestValidateResource(t *testing.T) {
	cases := []struct {
		name  string
		in    string
		valid bool
		warn  bool
	}{
		{
			name:  "valid pilot configuration",
			in:    validVirtualServiceYAML,
			valid: true,
		},
		{
			name:  "invalid pilot configuration",
			in:    invalidVirtualServiceYAML,
			valid: false,
		},
		{
			name:  "invalid pilot configuration v1beta1",
			in:    invalidVirtualServiceV1Beta1,
			valid: false,
		},
		{
			name:  "port name missing service",
			in:    portNameMissingSvc,
			valid: false,
		},
		{
			name:  "version label missing deployment",
			in:    versionLabelMissingDeployment,
			valid: true,
		},
		{
			name:  "valid port naming service",
			in:    validPortNamingSvc,
			valid: true,
		},
		{
			name:  "valid port naming with suffix service",
			in:    validPortNamingWithSuffixSvc,
			valid: true,
		},
		{
			name:  "invalid port naming service",
			in:    invalidPortNamingSvc,
			valid: false,
		},
		{
			name:  "invalid service list",
			in:    invalidSvcList,
			valid: false,
		},
		{
			name:  "valid deployment list",
			in:    validDeploymentList,
			valid: true,
		},
		{
			name:  "skip validating deployment",
			in:    skippedDeployment,
			valid: true,
		},
		{
			name:  "skip validating service",
			in:    skippedService,
			valid: true,
		},
		{
			name:  "service with udp port",
			in:    udpService,
			valid: true,
		},
		{
			name:  "invalid Istio Operator config",
			in:    invalidIstioConfig,
			valid: false,
		},
		{
			name:  "valid Istio Operator config",
			in:    validIstioConfig,
			valid: true,
		},
		{
			name:  "warning",
			in:    warnDestinationRule,
			valid: true,
			warn:  true,
		},
		{
			name:  "exportTo=.",
			in:    validVirtualService2YAML,
			valid: true,
		},
		{
			name:  "appProtocol=http",
			in:    validPortNamingSvcWithAppProtocol,
			valid: true,
		},
		{
			name:  "appProtocol=http,name=fake",
			in:    validPortNamingSvcWithAppProtocol2,
			valid: true,
		},
		{
			name:  "appProtocol=fake",
			in:    inValidPortNamingSvcWithAppProtocol,
			valid: false,
		},
		{
			name:  "metric labels k8s recommended",
			in:    validK8sRecommendedLabels,
			valid: true,
		},
		{
			name:  "metric labels istio canonical",
			in:    validIstioCanonical,
			valid: true,
		},
	}

	for i, c := range cases {
		t.Run(fmt.Sprintf("[%v] %v ", i, c.name), func(tt *testing.T) {
			defer func() { recover() }()
			v := &validator{}
			var writer io.Writer
			warn, err := v.validateResource("istio-system", "", fromYAML(c.in), writer)
			if (err == nil) != c.valid {
				tt.Fatalf("unexpected validation result: got %v want %v: err=%v", err == nil, c.valid, err)
			}
			if (warn != nil) != c.warn {
				tt.Fatalf("unexpected validation warning result: got %v want %v: warn=%v", warn != nil, c.warn, warn)
			}
		})
	}
}

func buildMultiDocConfig(docs []string) string {
	var b strings.Builder
	for _, r := range docs {
		if r != "" {
			b.WriteString(strings.Trim(r, " \t\n"))
		}
		b.WriteString("\n---\n")
	}
	return b.String()
}

func createTestFile(t *testing.T, data string) (string, io.Closer) {
	t.Helper()
	validFile, err := os.CreateTemp("", "TestValidateCommand")
	if err != nil {
		t.Fatal(err)
	}
	if _, err := validFile.WriteString(data); err != nil {
		t.Fatal(err)
	}
	return validFile.Name(), validFile
}

func createTestDirectory(t *testing.T, files map[string]string) string {
	t.Helper()
	tempDir, err := os.MkdirTemp("", "TestValidateCommand")
	if err != nil {
		t.Fatal(err)
	}

	for name, content := range files {
		filePath := filepath.Join(tempDir, name)
		if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
			t.Fatal(err)
		}
	}

	return tempDir
}

func TestValidateCommand(t *testing.T) {
	validYAML := buildMultiDocConfig([]string{validVirtualServiceYAML, validVirtualService1YAML})
	invalidYAML := buildMultiDocConfig([]string{invalidVirtualServiceYAML, validVirtualService1YAML})
	warningsYAML := buildMultiDocConfig([]string{invalidVirtualServiceYAML, validVirtualService1YAML, warnDestinationRule})

	validJSON := buildMultiDocConfig([]string{validVirtualServiceJSON, validVirtualService1JSON})
	invalidJSON := buildMultiDocConfig([]string{invalidVirtualServiceJSON, validVirtualService1JSON})
	warningsJSON := buildMultiDocConfig([]string{invalidVirtualServiceYAML, validVirtualService1YAML, warnDestinationRule})

	validFilenameYAML, closeValidYAMLFile := createTestFile(t, validYAML)
	defer closeValidYAMLFile.Close()

	validFilenameJSON, closeValidJSONFile := createTestFile(t, validJSON)
	defer closeValidJSONFile.Close()

	invalidFilenameYAML, closeInvalidYAMLFile := createTestFile(t, invalidYAML)
	defer closeInvalidYAMLFile.Close()

	warningFilename, closeWarningFile := createTestFile(t, warningsYAML)
	defer closeWarningFile.Close()

	invalidYAMLFile, closeInvalidYAMLFile := createTestFile(t, invalidYAML)
	defer closeInvalidYAMLFile.Close()

	validKubernetesYAMLFile, closeKubernetesYAMLFile := createTestFile(t, validKubernetesYAML)
	defer closeKubernetesYAMLFile.Close()

	validKubernetesJSONFile, closeKubernetesJSONFile := createTestFile(t, validKubernetesYAML)
	defer closeKubernetesJSONFile.Close()

	versionLabelMissingDeploymentFile, closeVersionLabelMissingDeploymentFile := createTestFile(t, versionLabelMissingDeployment)
	defer closeVersionLabelMissingDeploymentFile.Close()

	portNameMissingSvcFile, closePortNameMissingSvcFile := createTestFile(t, portNameMissingSvc)
	defer closePortNameMissingSvcFile.Close()

	unsupportedKeyFilename, closeUnsupportedKeyFile := createTestFile(t, invalidUnsupportedKey)
	defer closeUnsupportedKeyFile.Close()

	duplicateKeyFilename, closeUnsupportedKeyFile := createTestFile(t, invalidDuplicateKey)
	defer closeUnsupportedKeyFile.Close()

	validPortNamingSvcFile, closeValidPortNamingSvcFile := createTestFile(t, validPortNamingSvc)
	defer closeValidPortNamingSvcFile.Close()

	validPortNamingWithSuffixSvcFile, closeValidPortNamingWithSuffixSvcFile := createTestFile(t, validPortNamingWithSuffixSvc)
	defer closeValidPortNamingWithSuffixSvcFile.Close()

	invalidPortNamingSvcFile, closeInvalidPortNamingSvcFile := createTestFile(t, invalidPortNamingSvc)
	defer closeInvalidPortNamingSvcFile.Close()

	tempDirYAML := createTestDirectory(t, map[string]string{
		"valid.yaml":       validYAML,
		"invalid.yaml":     invalidYAML,
		"warning.yaml":     warningsYAML,
		"invalidYAML.yaml": invalidYAML,
	})
	validTempDirYAML := createTestDirectory(t, map[string]string{
		"valid.yaml": validYAML,
	})

	tempDirJSON := createTestDirectory(t, map[string]string{
		"valid.json":       validJSON,
		"invalid.json":     invalidJSON,
		"warning.json":     warningsJSON,
		"invalidYAML.json": invalidJSON,
	})
	validTempDirJSON := createTestDirectory(t, map[string]string{
		"valid.json": validJSON,
	})

	t.Cleanup(func() {
		os.RemoveAll(tempDirYAML)
		os.RemoveAll(validTempDirYAML)
		os.RemoveAll(tempDirJSON)
		os.RemoveAll(validTempDirJSON)
	})

	cases := []struct {
		name           string
		args           []string
		wantError      bool
		expectedRegexp *regexp.Regexp // Expected regexp output
	}{
		{
			name:      "valid port naming service",
			args:      []string{"--filename", validPortNamingSvcFile},
			wantError: false,
		},
		{
			name:      "valid port naming with suffix service",
			args:      []string{"--filename", validPortNamingWithSuffixSvcFile},
			wantError: false,
		},
		{
			name:      "invalid port naming service",
			args:      []string{"--filename", invalidPortNamingSvcFile},
			wantError: true,
		},
		{
			name:      "filename missing",
			wantError: true,
		},
		{
			name: "valid resources from file",
			args: []string{"--filename", validFilenameYAML},
		},
		{
			name:      "extra args",
			args:      []string{"--filename", validFilenameYAML, "extra-arg"},
			wantError: true,
		},
		{
			name:      "invalid resources from file",
			args:      []string{"--filename", invalidFilenameYAML},
			wantError: true,
		},
		{
			name:      "invalid filename",
			args:      []string{"--filename", "INVALID_FILE_NAME"},
			wantError: true,
		},
		{
			name:      "invalid YAML",
			args:      []string{"--filename", invalidYAMLFile},
			wantError: true,
		},
		{
			name: "valid Kubernetes YAML",
			args: []string{"--filename", validKubernetesYAMLFile},
			expectedRegexp: regexp.MustCompile(`^".*" is valid
$`),
			wantError: false,
		},
		{
			name: "valid Kubernetes JSON",
			args: []string{"--filename", validKubernetesJSONFile},
			expectedRegexp: regexp.MustCompile(`^".*" is valid
$`),
			wantError: false,
		},
		{
			name:           "invalid top-level key",
			args:           []string{"--filename", unsupportedKeyFilename},
			expectedRegexp: regexp.MustCompile(`.*unknown field "unexpected_junk"`),
			wantError:      true,
		},
		{
			name:      "version label missing deployment",
			args:      []string{"--filename", versionLabelMissingDeploymentFile},
			wantError: false,
		},
		{
			name:      "port name missing service",
			args:      []string{"--filename", portNameMissingSvcFile},
			wantError: true,
		},
		{
			name:           "duplicate key",
			args:           []string{"--filename", duplicateKeyFilename},
			expectedRegexp: regexp.MustCompile(`.*key ".*" already set`),
			wantError:      true,
		},
		{
			name: "warning",
			args: []string{"--filename", warningFilename},
			expectedRegexp: regexp.MustCompile(`(?m)".*" has warnings: 
	\* DestinationRule//reviews-cb-policy: outlier detection consecutive errors is deprecated, use consecutiveGatewayErrors or consecutive5xxErrors instead

Error: 1 error occurred:
	\* VirtualService//invalid-virtual-service: weight -15 < 0`),
			wantError: true,
		},
		{
			name:      "validate all yaml files in a directory",
			args:      []string{"--filename", tempDirYAML},
			wantError: true, // Since the directory has invalid files
		},
		{
			name:      "validate valid yaml files in a directory",
			args:      []string{"--filename", validTempDirYAML},
			wantError: false,
		},
		{
			name:      "validate combination of yaml files and directories with valid files",
			args:      []string{"--filename", validFilenameYAML, "--filename", validTempDirYAML},
			wantError: false,
		},
		{
			name:      "validate combination of yaml files and directories with valid files and invalid files",
			args:      []string{"--filename", validFilenameYAML, "--filename", tempDirYAML, "--filename", validTempDirYAML},
			wantError: true, // Since the directory has invalid files
		},
		{
			name:      "validate all json files in a directory",
			args:      []string{"--filename", tempDirJSON},
			wantError: true, // Since the directory has invalid files
		},
		{
			name:      "validate valid json files in a directory",
			args:      []string{"--filename", validTempDirJSON},
			wantError: false,
		},
		{
			name:      "validate combination of json files and directories with valid files",
			args:      []string{"--filename", validFilenameJSON, "--filename", validTempDirJSON},
			wantError: false,
		},
		{
			name:      "validate combination of json files and directories with valid files and invalid files",
			args:      []string{"--filename", validFilenameJSON, "--filename", tempDirJSON, "--filename", validTempDirJSON},
			wantError: true, // Since the directory has invalid files
		},
		{
			name:      "validate mix of yaml and json directories with valid files",
			args:      []string{"--filename", validTempDirYAML, "--filename", validTempDirJSON},
			wantError: false,
		},
		{
			name:      "validate combination of yaml and json and yaml directories with invalid files",
			args:      []string{"--filename", tempDirYAML, "--filename", tempDirJSON},
			wantError: true, // Since the directory has invalid files
		},
	}
	for i, c := range cases {
		t.Run(fmt.Sprintf("[%v] %v", i, c.name), func(t *testing.T) {
			ctx := cli.NewFakeContext(&cli.NewFakeContextOption{
				IstioNamespace: "istio-system",
			})
			validateCmd := NewValidateCommand(ctx)
			validateCmd.SilenceUsage = true
			validateCmd.SetArgs(c.args)

			// capture output to keep test logs clean
			var out bytes.Buffer
			validateCmd.SetOut(&out)
			validateCmd.SetErr(&out)

			err := validateCmd.Execute()
			if (err != nil) != c.wantError {
				t.Errorf("unexpected validate return status: got %v want %v: \nerr=%v",
					err != nil, c.wantError, err)
			}
			output := out.String()
			if c.expectedRegexp != nil && !c.expectedRegexp.MatchString(output) {
				t.Errorf("Output didn't match for 'istioctl %s'\n got %v\nwant: %v",
					strings.Join(c.args, " "), output, c.expectedRegexp)
			}
		})
	}
}

func TestGetTemplateLabels(t *testing.T) {
	un := fromYAML(validDeployment)

	labels, err := GetTemplateLabels(un)
	if err != nil {
		t.Fatal(err)
	}
	assert.Equal(t, labels, map[string]string{
		"app":     "helloworld",
		"version": "v1",
	})
}
