// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package propertyoverride

import (
	"fmt"
	"strings"
	"testing"

	clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3"
	endpointv3 "github.com/envoyproxy/go-control-plane/envoy/config/endpoint/v3"
	listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
	routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
	"github.com/hashicorp/consul/api"
	"github.com/hashicorp/consul/envoyextensions/extensioncommon"
	"github.com/stretchr/testify/require"
	"google.golang.org/protobuf/proto"
)

// TestConstructor tests raw input to the Constructor function called to initialize
// the property-override extension. This includes implicit validation of the deserialized
// input to the extension.
func TestConstructor(t *testing.T) {
	// These helpers aid in constructing valid raw example input (map[string]any)
	// for the Constructor, with optional overrides for fields under test.
	applyOverrides := func(m map[string]any, overrides map[string]any) map[string]any {
		for k, v := range overrides {
			if v == nil {
				delete(m, k)
			} else {
				m[k] = v
			}
		}
		return m
	}
	makeResourceFilter := func(overrides map[string]any) map[string]any {
		f := map[string]any{
			"ResourceType":     ResourceTypeRoute,
			"TrafficDirection": extensioncommon.TrafficDirectionOutbound,
		}
		return applyOverrides(f, overrides)
	}
	makePatch := func(overrides map[string]any) map[string]any {
		p := map[string]any{
			"ResourceFilter": makeResourceFilter(map[string]any{}),
			"Op":             OpAdd,
			"Path":           "/name",
			"Value":          "foo",
		}
		return applyOverrides(p, overrides)
	}
	makeArguments := func(overrides map[string]any) map[string]any {
		a := map[string]any{
			"Patches": []map[string]any{
				makePatch(map[string]any{}),
			},
			"Debug":     true,
			"ProxyType": api.ServiceKindConnectProxy,
		}
		return applyOverrides(a, overrides)
	}
	type testCase struct {
		extensionName string
		arguments     map[string]any
		expected      propertyOverride
		ok            bool
		errMsg        string
		errFunc       func(*testing.T, error)
	}

	validTestCase := func(o Op, d extensioncommon.TrafficDirection, t ResourceType) testCase {
		var v any = "foo"
		if o != OpAdd {
			v = nil
		}

		// Use a valid field for all resource types.
		path := "/name"
		if t == ResourceTypeClusterLoadAssignment {
			path = "/cluster_name"
		}

		return testCase{
			arguments: makeArguments(map[string]any{
				"Patches": []map[string]any{
					makePatch(map[string]any{
						"ResourceFilter": makeResourceFilter(map[string]any{
							"ResourceType":     t,
							"TrafficDirection": d,
						}),
						"Op":    o,
						"Path":  path,
						"Value": v,
					}),
				},
			}),
			expected: propertyOverride{
				Patches: []Patch{
					{
						ResourceFilter: ResourceFilter{
							ResourceType:     t,
							TrafficDirection: d,
						},
						Op:    o,
						Path:  path,
						Value: v,
					},
				},
				Debug:     true,
				ProxyType: api.ServiceKindConnectProxy,
			},
			ok: true,
		}
	}
	cases := map[string]testCase{
		"with no arguments": {
			arguments: nil,
			ok:        false,
			errMsg:    "at least one patch is required",
		},
		"with an invalid name": {
			arguments:     makeArguments(map[string]any{}),
			extensionName: "bad",
			ok:            false,
			errMsg:        "expected extension name \"builtin/property-override\" but got \"bad\"",
		},
		"empty Patches": {
			arguments: makeArguments(map[string]any{"Patches": []map[string]any{}}),
			ok:        false,
			errMsg:    "at least one patch is required",
		},
		"patch with no ResourceFilter": {
			arguments: makeArguments(map[string]any{"Patches": []map[string]any{
				makePatch(map[string]any{
					"ResourceFilter": nil,
				}),
			}}),
			ok:     false,
			errMsg: "field ResourceFilter is required",
		},
		"patch with no ResourceType": {
			arguments: makeArguments(map[string]any{"Patches": []map[string]any{
				makePatch(map[string]any{
					"ResourceFilter": makeResourceFilter(map[string]any{
						"ResourceType": nil,
					}),
				}),
			}}),
			ok:     false,
			errMsg: "field ResourceType is required",
		},
		"patch with invalid ResourceType": {
			arguments: makeArguments(map[string]any{"Patches": []map[string]any{
				makePatch(map[string]any{
					"ResourceFilter": makeResourceFilter(map[string]any{
						"ResourceType": "foo",
					}),
				}),
			}}),
			ok:     false,
			errMsg: "invalid ResourceType",
		},
		"patch with no TrafficDirection": {
			arguments: makeArguments(map[string]any{"Patches": []map[string]any{
				makePatch(map[string]any{
					"ResourceFilter": makeResourceFilter(map[string]any{
						"TrafficDirection": nil,
					}),
				}),
			}}),
			ok:     false,
			errMsg: "field TrafficDirection is required",
		},
		"patch with invalid TrafficDirection": {
			arguments: makeArguments(map[string]any{"Patches": []map[string]any{
				makePatch(map[string]any{
					"ResourceFilter": makeResourceFilter(map[string]any{
						"TrafficDirection": "foo",
					}),
				}),
			}}),
			ok:     false,
			errMsg: "invalid TrafficDirection",
		},
		"patch with no Op": {
			arguments: makeArguments(map[string]any{"Patches": []map[string]any{
				makePatch(map[string]any{
					"Op": nil,
				}),
			}}),
			ok:     false,
			errMsg: "field Op is required",
		},
		"patch with invalid Op": {
			arguments: makeArguments(map[string]any{"Patches": []map[string]any{
				makePatch(map[string]any{
					"Op": "foo",
				}),
			}}),
			ok:     false,
			errMsg: "invalid Op",
		},
		"patch with invalid Envoy resource Path": {
			arguments: makeArguments(map[string]any{"Patches": []map[string]any{
				makePatch(map[string]any{
					"Path": "/invalid",
				}),
			}}),
			ok:     false,
			errMsg: "no match for field", // this error comes from the patcher dry-run attempt
		},
		"non-Add patch with Value": {
			arguments: makeArguments(map[string]any{"Patches": []map[string]any{
				makePatch(map[string]any{
					"Op":    OpRemove,
					"Value": 1,
				}),
			}}),
			ok:     false,
			errMsg: fmt.Sprintf("field Value is not supported for %s operation", OpRemove),
		},
		"multiple patches includes indexed errors": {
			arguments: makeArguments(map[string]any{"Patches": []map[string]any{
				makePatch(map[string]any{
					"Op":    OpRemove,
					"Value": 0,
				}),
				makePatch(map[string]any{
					"Op":    OpAdd,
					"Value": nil,
				}),
				makePatch(map[string]any{
					"Op":   OpAdd,
					"Path": "/foo",
				}),
			}}),
			ok: false,
			errFunc: func(t *testing.T, err error) {
				require.ErrorContains(t, err, "invalid Patches[0]: field Value is not supported for remove operation")
				require.ErrorContains(t, err, "invalid Patches[1]: non-nil Value is required")
				require.ErrorContains(t, err, "invalid Patches[2]: no match for field 'foo'")
			},
		},
		"multiple patches single error contains correct index": {
			arguments: makeArguments(map[string]any{"Patches": []map[string]any{
				makePatch(map[string]any{
					"Op":    OpAdd,
					"Value": "foo",
				}),
				makePatch(map[string]any{
					"Op":    OpRemove,
					"Value": 1,
				}),
				makePatch(map[string]any{
					"Op":    OpAdd,
					"Value": "bar",
				}),
			}}),
			ok: false,
			errFunc: func(t *testing.T, err error) {
				require.ErrorContains(t, err, "invalid Patches[1]: field Value is not supported for remove operation")
				require.NotContains(t, err.Error(), "invalid Patches[0]")
				require.NotContains(t, err.Error(), "invalid Patches[2]")
			},
		},
		"empty service name": {
			arguments: makeArguments(map[string]any{"Patches": []map[string]any{
				makePatch(map[string]any{
					"ResourceFilter": makeResourceFilter(map[string]any{
						"Services": []map[string]any{
							{},
						},
					}),
				}),
			}}),
			ok:     false,
			errMsg: "service name is required",
		},
		"non-empty services with invalid traffic direction": {
			arguments: makeArguments(map[string]any{"Patches": []map[string]any{
				makePatch(map[string]any{
					"ResourceFilter": makeResourceFilter(map[string]any{
						"TrafficDirection": extensioncommon.TrafficDirectionInbound,
						"Services": []map[string]any{
							{"Name:": "foo"},
						},
					}),
				}),
			}}),
			ok:     false,
			errMsg: "patch contains non-empty ResourceFilter.Services but ResourceFilter.TrafficDirection is not \"outbound\"",
		},
		// See decode.HookWeakDecodeFromSlice for more details. In practice, we can end up
		// with a "Patches" field decoded to the single "Patch" value contained in the
		// serialized slice (raised from the containing slice). Using WeakDecode solves
		// for this. Ideally, we would kill that decoding hook entirely, but this test
		// enforces expected behavior until we do. Multi-member slices should be unaffected
		// by WeakDecode as it is a more-permissive version of the default behavior.
		"single value Patches decoded as map construction succeeds": {
			arguments: makeArguments(map[string]any{"Patches": makePatch(map[string]any{}), "ProxyType": nil}),
			expected:  validTestCase(OpAdd, extensioncommon.TrafficDirectionOutbound, ResourceTypeRoute).expected,
			ok:        true,
		},
		// Ensure that embedded api struct used for Services is parsed correctly.
		// See also above comment on decode.HookWeakDecodeFromSlice.
		"single value Services decoded as map construction succeeds": {
			arguments: makeArguments(map[string]any{"Patches": []map[string]any{
				makePatch(map[string]any{
					"ResourceFilter": makeResourceFilter(map[string]any{
						"Services": []map[string]any{
							{"Name": "foo"},
						},
					}),
				}),
			}}),
			expected: propertyOverride{
				Patches: []Patch{
					{
						ResourceFilter: ResourceFilter{
							ResourceType:     ResourceTypeRoute,
							TrafficDirection: extensioncommon.TrafficDirectionOutbound,
							Services: []*ServiceName{
								{CompoundServiceName: api.CompoundServiceName{
									Name:      "foo",
									Namespace: "default",
									Partition: "default",
								}},
							},
						},
						Op:    OpAdd,
						Path:  "/name",
						Value: "foo",
					},
				},
				Debug:     true,
				ProxyType: api.ServiceKindConnectProxy,
			},
			ok: true,
		},
		"invalid ProxyType": {
			arguments: makeArguments(map[string]any{
				"Patches": []map[string]any{
					makePatch(map[string]any{}),
				},
				"ProxyType": "invalid",
			}),
			ok:     false,
			errMsg: "invalid ProxyType",
		},
		"unsupported ProxyType": {
			arguments: makeArguments(map[string]any{
				"Patches": []map[string]any{
					makePatch(map[string]any{}),
				},
				"ProxyType": api.ServiceKindMeshGateway,
			}),
			ok:     false,
			errMsg: "invalid ProxyType",
		},
	}

	for o := range Ops {
		for d := range extensioncommon.TrafficDirections {
			for t := range ResourceTypes {
				cases["valid everything: "+strings.Join([]string{o, d, t}, ",")] =
					validTestCase(Op(o), extensioncommon.TrafficDirection(d), ResourceType(t))
			}
		}
	}

	for n, tc := range cases {
		t.Run(n, func(t *testing.T) {

			extensionName := api.BuiltinPropertyOverrideExtension
			if tc.extensionName != "" {
				extensionName = tc.extensionName
			}

			// Build the wrapping RuntimeConfig struct, which contains the serialized
			// arguments for constructing the property-override extension.
			svc := api.CompoundServiceName{Name: "svc"}
			ext := extensioncommon.RuntimeConfig{
				ServiceName: svc,
				EnvoyExtension: api.EnvoyExtension{
					Name:      extensionName,
					Arguments: tc.arguments,
				},
			}

			// Construct the actual extension
			e, err := Constructor(ext.EnvoyExtension)

			if tc.ok {
				require.NoError(t, err)
				require.Equal(t, &extensioncommon.BasicEnvoyExtender{Extension: &tc.expected}, e)
			} else {
				require.Error(t, err)
				if tc.errMsg != "" {
					require.ErrorContains(t, err, tc.errMsg)
				}
				if tc.errFunc != nil {
					tc.errFunc(t, err)
				}
			}
		})
	}
}

func Test_patchResourceType(t *testing.T) {
	makeExtension := func(patches ...Patch) *propertyOverride {
		return &propertyOverride{
			Patches: patches,
		}
	}
	makePatchWithPath := func(filter ResourceFilter, p string) Patch {
		return Patch{
			ResourceFilter: filter,
			Op:             OpAdd,
			Path:           p,
			Value:          1,
		}
	}
	makePatch := func(filter ResourceFilter) Patch {
		return makePatchWithPath(filter, "/foo")
	}

	svc1 := ServiceName{
		CompoundServiceName: api.CompoundServiceName{Name: "svc1"},
	}
	svc2 := ServiceName{
		CompoundServiceName: api.CompoundServiceName{Name: "svc2"},
	}

	clusterOutbound := makePatch(ResourceFilter{
		ResourceType:     ResourceTypeCluster,
		TrafficDirection: extensioncommon.TrafficDirectionOutbound,
	})
	clusterInbound := makePatch(ResourceFilter{
		ResourceType:     ResourceTypeCluster,
		TrafficDirection: extensioncommon.TrafficDirectionInbound,
	})
	listenerOutbound := makePatch(ResourceFilter{
		ResourceType:     ResourceTypeListener,
		TrafficDirection: extensioncommon.TrafficDirectionOutbound,
	})
	listenerOutbound2 := makePatchWithPath(ResourceFilter{
		ResourceType:     ResourceTypeListener,
		TrafficDirection: extensioncommon.TrafficDirectionOutbound,
	}, "/bar")
	listenerInbound := makePatch(ResourceFilter{
		ResourceType:     ResourceTypeListener,
		TrafficDirection: extensioncommon.TrafficDirectionInbound,
	})
	routeOutbound := makePatch(ResourceFilter{
		ResourceType:     ResourceTypeRoute,
		TrafficDirection: extensioncommon.TrafficDirectionOutbound,
	})
	routeOutbound2 := makePatchWithPath(ResourceFilter{
		ResourceType:     ResourceTypeRoute,
		TrafficDirection: extensioncommon.TrafficDirectionOutbound,
	}, "/bar")
	routeInbound := makePatch(ResourceFilter{
		ResourceType:     ResourceTypeRoute,
		TrafficDirection: extensioncommon.TrafficDirectionInbound,
	})

	type args struct {
		resourceType ResourceType
		payload      extensioncommon.Payload[proto.Message]
		p            *propertyOverride
	}
	type testCase struct {
		args          args
		expectPatched bool
		wantApplied   []Patch
	}
	cases := map[string]testCase{
		"outbound gets matching patch": {
			args: args{
				resourceType: ResourceTypeCluster,
				payload: extensioncommon.Payload[proto.Message]{
					TrafficDirection: extensioncommon.TrafficDirectionOutbound,
					Message:          &clusterv3.Cluster{},
				},
				p: makeExtension(clusterOutbound),
			},
			expectPatched: true,
			wantApplied:   []Patch{clusterOutbound},
		},
		"inbound gets matching patch": {
			args: args{
				resourceType: ResourceTypeCluster,
				payload: extensioncommon.Payload[proto.Message]{
					TrafficDirection: extensioncommon.TrafficDirectionInbound,
					Message:          &clusterv3.Cluster{},
				},
				p: makeExtension(clusterInbound),
			},
			expectPatched: true,
			wantApplied:   []Patch{clusterInbound},
		},
		"multiple resources same direction only gets matching resource": {
			args: args{
				resourceType: ResourceTypeCluster,
				payload: extensioncommon.Payload[proto.Message]{
					TrafficDirection: extensioncommon.TrafficDirectionOutbound,
					Message:          &clusterv3.Cluster{},
				},
				p: makeExtension(clusterOutbound, listenerOutbound),
			},
			expectPatched: true,
			wantApplied:   []Patch{clusterOutbound},
		},
		"multiple directions same resource only gets matching direction": {
			args: args{
				resourceType: ResourceTypeCluster,
				payload: extensioncommon.Payload[proto.Message]{
					TrafficDirection: extensioncommon.TrafficDirectionOutbound,
					Message:          &clusterv3.Cluster{},
				},
				p: makeExtension(clusterOutbound, clusterInbound),
			},
			expectPatched: true,
			wantApplied:   []Patch{clusterOutbound},
		},
		"multiple directions and resources only gets matching patch": {
			args: args{
				resourceType: ResourceTypeRoute,
				payload: extensioncommon.Payload[proto.Message]{
					TrafficDirection: extensioncommon.TrafficDirectionInbound,
					Message:          &routev3.RouteConfiguration{},
				},
				p: makeExtension(clusterOutbound, clusterInbound, listenerOutbound, listenerInbound, routeOutbound, routeOutbound2, routeInbound),
			},
			expectPatched: true,
			wantApplied:   []Patch{routeInbound},
		},
		"multiple directions and resources multiple matches gets all matching patches": {
			args: args{
				resourceType: ResourceTypeRoute,
				payload: extensioncommon.Payload[proto.Message]{
					TrafficDirection: extensioncommon.TrafficDirectionOutbound,
					Message:          &routev3.RouteConfiguration{},
				},
				p: makeExtension(clusterOutbound, clusterInbound, listenerOutbound, listenerInbound, listenerOutbound2, routeOutbound, routeOutbound2, routeInbound),
			},
			expectPatched: true,
			wantApplied:   []Patch{routeOutbound, routeOutbound2},
		},
		"multiple directions and resources no matches gets no patches": {
			args: args{
				resourceType: ResourceTypeCluster,
				payload: extensioncommon.Payload[proto.Message]{
					TrafficDirection: extensioncommon.TrafficDirectionOutbound,
					Message:          &clusterv3.Cluster{},
				},
				p: makeExtension(clusterInbound, listenerOutbound, listenerInbound, listenerOutbound2, routeInbound, routeOutbound),
			},
			expectPatched: false,
			wantApplied:   nil,
		},
	}

	type resourceTypeServiceMatch struct {
		resourceType ResourceType
		message      proto.Message
	}

	resourceTypeCases := []resourceTypeServiceMatch{
		{
			resourceType: ResourceTypeCluster,
			message:      &clusterv3.Cluster{},
		},
		{
			resourceType: ResourceTypeListener,
			message:      &listenerv3.Listener{},
		},
		{
			resourceType: ResourceTypeRoute,
			message:      &routev3.RouteConfiguration{},
		},
		{
			resourceType: ResourceTypeClusterLoadAssignment,
			message:      &endpointv3.ClusterLoadAssignment{},
		},
	}

	for _, tc := range resourceTypeCases {
		{
			patch := makePatch(ResourceFilter{
				ResourceType:     tc.resourceType,
				TrafficDirection: extensioncommon.TrafficDirectionOutbound,
				Services: []*ServiceName{
					{CompoundServiceName: svc2.CompoundServiceName},
				},
			})

			cases[fmt.Sprintf("%s - no match", tc.resourceType)] = testCase{
				args: args{
					resourceType: tc.resourceType,
					payload: extensioncommon.Payload[proto.Message]{
						TrafficDirection: extensioncommon.TrafficDirectionOutbound,
						ServiceName:      &svc1.CompoundServiceName,
						Message:          tc.message,
						RuntimeConfig: &extensioncommon.RuntimeConfig{
							Upstreams: map[api.CompoundServiceName]*extensioncommon.UpstreamData{
								svc1.CompoundServiceName: {},
							},
						},
					},
					p: makeExtension(patch),
				},
				expectPatched: false,
				wantApplied:   nil,
			}
		}

		{
			patch := makePatch(ResourceFilter{
				ResourceType:     tc.resourceType,
				TrafficDirection: extensioncommon.TrafficDirectionOutbound,
				Services: []*ServiceName{
					{CompoundServiceName: svc2.CompoundServiceName},
					{CompoundServiceName: svc1.CompoundServiceName},
				},
			})

			cases[fmt.Sprintf("%s - match", tc.resourceType)] = testCase{
				args: args{
					resourceType: tc.resourceType,
					payload: extensioncommon.Payload[proto.Message]{
						TrafficDirection: extensioncommon.TrafficDirectionOutbound,
						ServiceName:      &svc1.CompoundServiceName,
						Message:          tc.message,
						RuntimeConfig: &extensioncommon.RuntimeConfig{
							Upstreams: map[api.CompoundServiceName]*extensioncommon.UpstreamData{
								svc1.CompoundServiceName: {},
							},
						},
					},
					p: makeExtension(patch),
				},
				expectPatched: true,
				wantApplied:   []Patch{patch},
			}
		}
	}

	for n, tc := range cases {
		t.Run(n, func(t *testing.T) {
			mockPatcher := MockPatcher[proto.Message]{}
			_, patched, err := patchResourceType[proto.Message](tc.args.p, tc.args.resourceType, tc.args.payload, &mockPatcher)

			require.NoError(t, err, "unexpected error from mock")
			require.Equal(t, tc.expectPatched, patched)
			require.Equal(t, tc.wantApplied, mockPatcher.appliedPatches)
		})
	}
}

type MockPatcher[K proto.Message] struct {
	appliedPatches []Patch
}

//nolint:unparam
func (m *MockPatcher[K]) applyPatch(k K, p Patch, _ bool) (result K, e error) {
	m.appliedPatches = append(m.appliedPatches, p)
	return k, nil
}

func TestCanApply(t *testing.T) {
	cases := map[string]struct {
		ext      *propertyOverride
		conf     *extensioncommon.RuntimeConfig
		canApply bool
	}{
		"valid proxy type": {
			ext: &propertyOverride{
				ProxyType: api.ServiceKindConnectProxy,
			},
			conf: &extensioncommon.RuntimeConfig{
				Kind: api.ServiceKindConnectProxy,
			},
			canApply: true,
		},
		"invalid proxy type": {
			ext: &propertyOverride{
				ProxyType: api.ServiceKindConnectProxy,
			},
			conf: &extensioncommon.RuntimeConfig{
				Kind: api.ServiceKindMeshGateway,
			},
			canApply: false,
		},
	}
	for n, tc := range cases {
		t.Run(n, func(t *testing.T) {
			require.Equal(t, tc.canApply, tc.ext.CanApply(tc.conf))
		})
	}
}
