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

import (
	"context"
	"fmt"
	"net/http"
	"strconv"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"

	pbauth "github.com/hashicorp/consul/proto-public/pbauth/v2beta1"
	"github.com/hashicorp/consul/proto-public/pbresource"
	"github.com/hashicorp/consul/sdk/testutil/retry"
	"github.com/hashicorp/consul/test-integ/topoutil"
	libassert "github.com/hashicorp/consul/test/integration/consul-container/libs/assert"
	"github.com/hashicorp/consul/test/integration/consul-container/libs/utils"
	"github.com/hashicorp/consul/testing/deployer/sprawl/sprawltest"
	"github.com/hashicorp/consul/testing/deployer/topology"
)

type testCase struct {
	permissions []*permission
	result      []*testResult
}

type permission struct {
	allow                bool
	excludeSource        bool
	includeSourceTenancy bool
	excludeSourceTenancy bool
	destRules            []*destRules
}

type destRules struct {
	values   *ruleValues
	excludes []*ruleValues
}

type ruleValues struct {
	portNames []string
	path      string
	pathPref  string
	pathReg   string
	headers   []string
	methods   []string
}

type testResult struct {
	fail    bool
	port    string
	path    string
	headers map[string]string
}

func newTrafficPermissions(p *permission, srcTenancy *pbresource.Tenancy) *pbauth.TrafficPermissions {
	sources := []*pbauth.Source{{
		IdentityName: "static-client",
		Namespace:    srcTenancy.Namespace,
		Partition:    srcTenancy.Partition,
	}}
	destinationRules := []*pbauth.DestinationRule{}
	if p != nil {
		srcId := "static-client"
		if p.includeSourceTenancy {
			srcId = ""
		}
		if p.excludeSource {
			sources = []*pbauth.Source{{
				IdentityName: srcId,
				Namespace:    srcTenancy.Namespace,
				Partition:    srcTenancy.Partition,
				Exclude: []*pbauth.ExcludeSource{{
					IdentityName: "static-client",
					Namespace:    srcTenancy.Namespace,
					Partition:    srcTenancy.Partition,
				}},
			}}
		} else {
			sources = []*pbauth.Source{{
				IdentityName: srcId,
				Namespace:    srcTenancy.Namespace,
				Partition:    srcTenancy.Partition,
			}}
		}
		for _, dr := range p.destRules {
			destRule := &pbauth.DestinationRule{}
			if dr.values != nil {
				destRule.PathExact = dr.values.path
				destRule.PathPrefix = dr.values.pathPref
				destRule.PathRegex = dr.values.pathReg
				destRule.Methods = dr.values.methods
				destRule.PortNames = dr.values.portNames
				destRule.Headers = []*pbauth.DestinationRuleHeader{}
				for _, h := range dr.values.headers {
					destRule.Headers = append(destRule.Headers, &pbauth.DestinationRuleHeader{
						Name:    h,
						Present: true,
					})
				}
			}
			var excludePermissions []*pbauth.ExcludePermissionRule
			for _, e := range dr.excludes {
				eRule := &pbauth.ExcludePermissionRule{
					PathExact:  e.path,
					PathPrefix: e.pathPref,
					PathRegex:  e.pathReg,
					Methods:    e.methods,
					PortNames:  e.portNames,
				}
				eRule.Headers = []*pbauth.DestinationRuleHeader{}
				for _, h := range e.headers {
					eRule.Headers = append(eRule.Headers, &pbauth.DestinationRuleHeader{
						Name:    h,
						Present: true,
					})
				}
				excludePermissions = append(excludePermissions, eRule)
			}
			destRule.Exclude = excludePermissions
			destinationRules = append(destinationRules, destRule)
		}
	}
	action := pbauth.Action_ACTION_ALLOW
	if !p.allow {
		action = pbauth.Action_ACTION_DENY
	}
	return &pbauth.TrafficPermissions{
		Destination: &pbauth.Destination{
			IdentityName: "static-server",
		},
		Action: action,
		Permissions: []*pbauth.Permission{{
			Sources:          sources,
			DestinationRules: destinationRules,
		}},
	}

}

// This tests runs a gauntlet of traffic permissions updates and validates that the request status codes match the intended rules
func TestL7TrafficPermissions(t *testing.T) {
	testcases := map[string]testCase{
		// L4 permissions
		"basic":                       {permissions: []*permission{{allow: true}}, result: []*testResult{{fail: false}}},
		"client-exclude":              {permissions: []*permission{{allow: true, includeSourceTenancy: true, excludeSource: true}}, result: []*testResult{{fail: true}}},
		"allow-all-client-in-tenancy": {permissions: []*permission{{allow: true, includeSourceTenancy: true}}, result: []*testResult{{fail: false}}},
		"only-one-port":               {permissions: []*permission{{allow: true, destRules: []*destRules{{values: &ruleValues{portNames: []string{"http"}}}}}}, result: []*testResult{{fail: true, port: "http2"}}},
		"exclude-port":                {permissions: []*permission{{allow: true, destRules: []*destRules{{excludes: []*ruleValues{{portNames: []string{"http"}}}}}}}, result: []*testResult{{fail: true, port: "http"}}},
		// L7 permissions
		"methods": {permissions: []*permission{{allow: true, destRules: []*destRules{{values: &ruleValues{methods: []string{"POST", "PUT", "PATCH", "DELETE", "CONNECT", "HEAD", "OPTIONS", "TRACE"}, pathPref: "/"}}}}},
			// fortio fetch2 is configured to GET
			result: []*testResult{{fail: true}}},
		"headers": {permissions: []*permission{{allow: true, destRules: []*destRules{{values: &ruleValues{headers: []string{"a", "b"}, pathPref: "/"}}}}},
			result: []*testResult{{fail: true}, {fail: true, headers: map[string]string{"a": "1"}}, {fail: false, headers: map[string]string{"a": "1", "b": "2"}}}},
		"path-prefix-all": {permissions: []*permission{{allow: true, destRules: []*destRules{{values: &ruleValues{pathPref: "/", methods: []string{"GET"}}}}}}, result: []*testResult{{fail: false}}},
		"method-exclude": {permissions: []*permission{{allow: true, destRules: []*destRules{{values: &ruleValues{pathPref: "/"}, excludes: []*ruleValues{{methods: []string{"GET"}}}}}}},
			// fortio fetch2 is configured to GET
			result: []*testResult{{fail: true}}},
		"exclude-paths-and-headers": {permissions: []*permission{{allow: true, destRules: []*destRules{
			{
				values:   &ruleValues{pathPref: "/f", headers: []string{"a"}},
				excludes: []*ruleValues{{headers: []string{"b"}, path: "/foobar"}},
			}}}},
			result: []*testResult{
				{fail: false, path: "foobar", headers: map[string]string{"a": "1"}},
				{fail: false, path: "foo", headers: map[string]string{"a": "1", "b": "2"}},
				{fail: true, path: "foobar", headers: map[string]string{"a": "1", "b": "2"}},
				{fail: false, path: "foo", headers: map[string]string{"a": "1"}},
				{fail: true, path: "foo", headers: map[string]string{"b": "2"}},
				{fail: true, path: "baz", headers: map[string]string{"a": "1"}},
			}},
		"exclude-paths-or-headers": {permissions: []*permission{{allow: true, destRules: []*destRules{
			{values: &ruleValues{pathPref: "/f", headers: []string{"a"}}, excludes: []*ruleValues{{headers: []string{"b"}}, {path: "/foobar"}}}}}},
			result: []*testResult{
				{fail: true, path: "foobar", headers: map[string]string{"a": "1"}},
				{fail: true, path: "foo", headers: map[string]string{"a": "1", "b": "2"}},
				{fail: true, path: "foobar", headers: map[string]string{"a": "1", "b": "2"}},
				{fail: false, path: "foo", headers: map[string]string{"a": "1"}},
				{fail: false, path: "foo", headers: map[string]string{"a": "1"}},
				{fail: true, path: "baz", port: "http", headers: map[string]string{"a": "1"}},
			}},
		"path-or-header": {permissions: []*permission{{allow: true, destRules: []*destRules{{values: &ruleValues{pathPref: "/bar"}}, {values: &ruleValues{headers: []string{"b"}}}}}},
			result: []*testResult{
				{fail: false, path: "bar"},
				{fail: false, path: "foo", headers: map[string]string{"a": "1", "b": "2"}},
				{fail: false, path: "bar", headers: map[string]string{"b": "2"}},
				{fail: true, path: "foo", headers: map[string]string{"a": "1"}},
			}},
		"path-and-header": {permissions: []*permission{{allow: true, destRules: []*destRules{{values: &ruleValues{pathPref: "/bar", headers: []string{"b"}}}}}},
			result: []*testResult{
				{fail: true, path: "bar"},
				{fail: true, path: "foo", headers: map[string]string{"a": "1", "b": "2"}},
				{fail: false, path: "bar", headers: map[string]string{"b": "2"}},
				{fail: true, path: "foo", headers: map[string]string{"a": "1"}},
			}},
		"path-regex-exclude": {permissions: []*permission{{allow: true, destRules: []*destRules{{values: &ruleValues{pathPref: "/"}, excludes: []*ruleValues{{pathReg: ".*dns.*"}}}}}},
			result: []*testResult{{fail: true, path: "fortio/rest/dns"}, {fail: false, path: "fortio/rest/status"}}},
		"header-include-exclude-by-port": {permissions: []*permission{{allow: true, destRules: []*destRules{{values: &ruleValues{pathPref: "/", headers: []string{"experiment1", "experiment2"}}, excludes: []*ruleValues{{portNames: []string{"http2"}, headers: []string{"experiment1"}}}}}}},
			result: []*testResult{{fail: true, port: "http2", headers: map[string]string{"experiment1": "a", "experiment2": "b"}},
				{fail: false, port: "http", headers: map[string]string{"experiment1": "a", "experiment2": "b"}},
				{fail: true, port: "http2", headers: map[string]string{"experiment2": "b"}},
				{fail: true, port: "http", headers: map[string]string{"experiment3": "c"}},
			}},
		"two-tp-or": {permissions: []*permission{{allow: true, destRules: []*destRules{{values: &ruleValues{pathPref: "/bar"}}}}, {allow: true, destRules: []*destRules{{values: &ruleValues{headers: []string{"b"}}}}}},
			result: []*testResult{
				{fail: false, path: "bar"},
				{fail: false, path: "foo", headers: map[string]string{"a": "1", "b": "2"}},
				{fail: false, path: "bar", headers: map[string]string{"b": "2"}},
				{fail: true, path: "foo", headers: map[string]string{"a": "1"}},
			}},
	}
	if utils.IsEnterprise() {
		// DENY and ALLOW permissions
		testcases["deny-cancel-allow"] = testCase{permissions: []*permission{{allow: true}, {allow: false}}, result: []*testResult{{fail: true}}}
		testcases["l4-deny-l7-allow"] = testCase{permissions: []*permission{{allow: false}, {allow: true, destRules: []*destRules{{values: &ruleValues{pathPref: "/"}}}}}, result: []*testResult{{fail: true}, {fail: true, path: "test"}}}
		testcases["l7-deny-l4-allow"] = testCase{permissions: []*permission{{allow: true}, {allow: true, destRules: []*destRules{{values: &ruleValues{pathPref: "/"}}}}, {allow: false, destRules: []*destRules{{values: &ruleValues{pathPref: "/foo"}}}}},
			result: []*testResult{{fail: false}, {fail: false, path: "test"}, {fail: true, path: "foo-bar"}}}
	}

	tenancies := []*pbresource.Tenancy{
		{
			Partition: "default",
			Namespace: "default",
		},
	}
	if utils.IsEnterprise() {
		tenancies = append(tenancies, &pbresource.Tenancy{
			Partition: "ap1",
			Namespace: "ns1",
		})
	}
	cfg := testL7TrafficPermissionsCreator{tenancies}.NewConfig(t)
	targetImage := utils.TargetImages()
	imageName := targetImage.Consul
	if utils.IsEnterprise() {
		imageName = targetImage.ConsulEnterprise
	}
	t.Log("running with target image: " + imageName)

	sp := sprawltest.Launch(t, cfg)

	asserter := topoutil.NewAsserter(sp)

	topo := sp.Topology()
	cluster := topo.Clusters["dc1"]
	ships := topo.ComputeRelationships()

	clientV2 := sp.ResourceServiceClientForCluster(cluster.Name)

	// Make sure services exist
	for _, tenancy := range tenancies {
		for _, name := range []string{
			"static-server",
			"static-client",
		} {
			libassert.CatalogV2ServiceHasEndpointCount(t, clientV2, name, tenancy, len(tenancies))
		}
	}
	var initialTrafficPerms []*pbresource.Resource
	for testName, tc := range testcases {
		// Delete old TP and write new one for a new test case
		mustDeleteTestResources(t, clientV2, initialTrafficPerms)
		initialTrafficPerms = []*pbresource.Resource{}
		for _, st := range tenancies {
			for _, dt := range tenancies {
				for i, p := range tc.permissions {
					newTrafficPerms := sprawltest.MustSetResourceData(t, &pbresource.Resource{
						Id: &pbresource.ID{
							Type:    pbauth.TrafficPermissionsType,
							Name:    "static-server-perms" + strconv.Itoa(i) + "-" + st.Namespace + "-" + st.Partition,
							Tenancy: dt,
						},
					}, newTrafficPermissions(p, st))
					mustWriteTestResource(t, clientV2, newTrafficPerms)
					initialTrafficPerms = append(initialTrafficPerms, newTrafficPerms)
				}
			}
		}
		t.Log(initialTrafficPerms)
		// Wait for the resource updates to go through and Envoy to be ready
		time.Sleep(1 * time.Second)
		// Check the default server workload envoy config for RBAC filters matching testcase criteria
		serverWorkload := cluster.WorkloadsByID(topology.ID{
			Partition: "default",
			Namespace: "default",
			Name:      "static-server",
		})
		asserter.AssertEnvoyHTTPrbacFiltersContainIntentions(t, serverWorkload[0])
		// Check relationships
		for _, ship := range ships {
			t.Run("case: "+testName+":"+ship.Destination.PortName+":("+ship.Caller.ID.Partition+"/"+ship.Caller.ID.Namespace+
				")("+ship.Destination.ID.Partition+"/"+ship.Destination.ID.Namespace+")", func(t *testing.T) {
				var (
					wrk  = ship.Caller
					dest = ship.Destination
				)
				for _, res := range tc.result {
					if res.port != "" && res.port != ship.Destination.PortName {
						continue
					}
					dest.ID.Name = "static-server"
					destClusterPrefix := clusterPrefix(dest.PortName, dest.ID, dest.Cluster)
					asserter.DestinationEndpointStatus(t, wrk, destClusterPrefix+".", "HEALTHY", len(tenancies))
					status := http.StatusForbidden
					if res.fail == false {
						status = http.StatusOK
					}
					t.Log("Test request:"+res.path, res.headers, status)
					asserter.FortioFetch2ServiceStatusCodes(t, wrk, dest, res.path, res.headers, []int{status})
				}
			})
		}
	}
}

func mustWriteTestResource(t *testing.T, client pbresource.ResourceServiceClient, res *pbresource.Resource) {
	retryer := &retry.Timer{Timeout: time.Minute, Wait: time.Second}
	rsp, err := client.Write(context.Background(), &pbresource.WriteRequest{Resource: res})
	require.NoError(t, err)
	retry.RunWith(retryer, t, func(r *retry.R) {
		readRsp, err := client.Read(context.Background(), &pbresource.ReadRequest{Id: rsp.Resource.Id})
		require.NoError(r, err, "error reading %s", rsp.Resource.Id.Name)
		require.NotNil(r, readRsp)
	})

}

func mustDeleteTestResources(t *testing.T, client pbresource.ResourceServiceClient, resources []*pbresource.Resource) {
	if len(resources) == 0 {
		return
	}
	retryer := &retry.Timer{Timeout: time.Minute, Wait: time.Second}
	for _, res := range resources {
		retry.RunWith(retryer, t, func(r *retry.R) {
			_, err := client.Delete(context.Background(), &pbresource.DeleteRequest{Id: res.Id})
			if status.Code(err) == codes.NotFound {
				return
			}
			if err != nil && status.Code(err) != codes.Aborted {
				r.Stop(fmt.Errorf("failed to delete the resource: %w", err))
				return
			}
			require.NoError(r, err)
		})
	}
}

type testL7TrafficPermissionsCreator struct {
	tenancies []*pbresource.Tenancy
}

func (c testL7TrafficPermissionsCreator) NewConfig(t *testing.T) *topology.Config {
	const clusterName = "dc1"

	servers := topoutil.NewTopologyServerSet(clusterName+"-server", 1, []string{clusterName, "wan"}, nil)

	cluster := &topology.Cluster{
		Enterprise: utils.IsEnterprise(),
		Name:       clusterName,
		Nodes:      servers,
	}

	lastNode := 0
	nodeName := func() string {
		lastNode++
		return fmt.Sprintf("%s-box%d", clusterName, lastNode)
	}

	for _, st := range c.tenancies {
		for _, dt := range c.tenancies {
			c.topologyConfigAddNodes(cluster, nodeName, st, dt)

		}
	}

	return &topology.Config{
		Images: utils.TargetImages(),
		Networks: []*topology.Network{
			{Name: clusterName},
			{Name: "wan", Type: "wan"},
		},
		Clusters: []*topology.Cluster{
			cluster,
		},
	}
}

func (c testL7TrafficPermissionsCreator) topologyConfigAddNodes(
	cluster *topology.Cluster,
	nodeName func() string,
	sourceTenancy *pbresource.Tenancy,
	destinationTenancy *pbresource.Tenancy,
) {
	clusterName := cluster.Name

	newID := func(name string, tenancy *pbresource.Tenancy) topology.ID {
		return topology.ID{
			Partition: tenancy.Partition,
			Namespace: tenancy.Namespace,
			Name:      name,
		}
	}

	serverNode := &topology.Node{
		Kind:      topology.NodeKindDataplane,
		Version:   topology.NodeVersionV2,
		Partition: destinationTenancy.Partition,
		Name:      nodeName(),
		Workloads: []*topology.Workload{
			topoutil.NewFortioWorkloadWithDefaults(
				clusterName,
				newID("static-server", destinationTenancy),
				topology.NodeVersionV2,
				nil,
			),
		},
	}

	clientNode := &topology.Node{
		Kind:      topology.NodeKindDataplane,
		Version:   topology.NodeVersionV2,
		Partition: sourceTenancy.Partition,
		Name:      nodeName(),
		Workloads: []*topology.Workload{
			topoutil.NewFortioWorkloadWithDefaults(
				clusterName,
				newID("static-client", sourceTenancy),
				topology.NodeVersionV2,
				func(wrk *topology.Workload) {
					wrk.Destinations = append(wrk.Destinations, &topology.Destination{
						ID:           newID("static-server", destinationTenancy),
						PortName:     "http",
						LocalAddress: "0.0.0.0", // needed for an assertion
						LocalPort:    5000,
					},
						&topology.Destination{
							ID:           newID("static-server", destinationTenancy),
							PortName:     "http2",
							LocalAddress: "0.0.0.0", // needed for an assertion
							LocalPort:    5001,
						},
					)
					wrk.WorkloadIdentity = "static-client"
				},
			),
		},
	}

	cluster.Nodes = append(cluster.Nodes,
		clientNode,
		serverNode,
	)
}
