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

package topoutil

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

	"github.com/stretchr/testify/require"

	"github.com/hashicorp/consul/sdk/testutil/retry"
	"github.com/hashicorp/consul/testing/deployer/topology"
)

// CheckBlankspaceNameViaHTTP calls a copy of blankspace and asserts it arrived
// on the correct instance using HTTP1 or HTTP2.
func (a *Asserter) CheckBlankspaceNameViaHTTP(
	t *testing.T,
	workload *topology.Workload,
	us *topology.Upstream,
	useHTTP2 bool,
	path string,
	clusterName string,
	sid topology.ID,
) {
	t.Helper()

	a.checkBlankspaceNameViaHTTPWithCallback(t, workload, us, useHTTP2, path, 1, func(_ *retry.R) {}, func(r *retry.R, remoteName string) {
		require.Equal(r, fmt.Sprintf("%s::%s", clusterName, sid.String()), remoteName)
	}, func(r *retry.R) {})
}

// CheckBlankspaceNameTrafficSplitViaHTTP is like CheckBlankspaceNameViaHTTP
// but it is verifying a relative traffic split.
func (a *Asserter) CheckBlankspaceNameTrafficSplitViaHTTP(
	t *testing.T,
	workload *topology.Workload,
	us *topology.Upstream,
	useHTTP2 bool,
	path string,
	expect map[string]int,
) {
	t.Helper()

	got := make(map[string]int)
	a.checkBlankspaceNameViaHTTPWithCallback(t, workload, us, useHTTP2, path, 100, func(_ *retry.R) {
		got = make(map[string]int)
	}, func(_ *retry.R, name string) {
		got[name]++
	}, func(r *retry.R) {
		assertTrafficSplitFor100Requests(r, got, expect)
	})
}

func (a *Asserter) checkBlankspaceNameViaHTTPWithCallback(
	t *testing.T,
	workload *topology.Workload,
	us *topology.Upstream,
	useHTTP2 bool,
	path string,
	count int,
	resetFn func(r *retry.R),
	attemptFn func(r *retry.R, remoteName string),
	checkFn func(r *retry.R),
) {
	t.Helper()

	var (
		node         = workload.Node
		internalPort = workload.Port
		addr         = net.JoinHostPort(node.LocalAddress(), strconv.Itoa(internalPort))
		client       = a.mustGetHTTPClient(t, node.Cluster)
	)

	if useHTTP2 {
		// We can't use the forward proxy for http2, so use the exposed port on localhost instead.
		exposedPort := node.ExposedPort(internalPort)
		require.True(t, exposedPort > 0)

		addr = net.JoinHostPort("127.0.0.1", strconv.Itoa(exposedPort))

		// This will clear the proxy field on the transport.
		client = EnableHTTP2(client)
	}

	actualURL := fmt.Sprintf("http://localhost:%d/%s", us.LocalPort, path)

	multiassert(t, count, resetFn, func(r *retry.R) {
		name, err := GetBlankspaceNameViaHTTP(context.Background(), client, addr, actualURL)
		require.NoError(r, err)
		attemptFn(r, name)
	}, func(r *retry.R) {
		checkFn(r)
	})
}

// CheckBlankspaceNameViaTCP calls a copy of blankspace and asserts it arrived
// on the correct instance using plain tcp sockets.
func (a *Asserter) CheckBlankspaceNameViaTCP(
	t *testing.T,
	workload *topology.Workload,
	us *topology.Upstream,
	clusterName string,
	sid topology.ID,
) {
	t.Helper()

	a.checkBlankspaceNameViaTCPWithCallback(t, workload, us, 1, func(_ *retry.R) {}, func(r *retry.R, remoteName string) {
		require.Equal(r, fmt.Sprintf("%s::%s", clusterName, sid.String()), remoteName)
	}, func(r *retry.R) {})
}

// CheckBlankspaceNameTrafficSplitViaTCP is like CheckBlankspaceNameViaTCP
// but it is verifying a relative traffic split.
func (a *Asserter) CheckBlankspaceNameTrafficSplitViaTCP(
	t *testing.T,
	workload *topology.Workload,
	us *topology.Upstream,
	expect map[string]int,
) {
	t.Helper()

	got := make(map[string]int)
	a.checkBlankspaceNameViaTCPWithCallback(t, workload, us, 100, func(_ *retry.R) {
		got = make(map[string]int)
	}, func(_ *retry.R, name string) {
		got[name]++
	}, func(r *retry.R) {
		assertTrafficSplitFor100Requests(r, got, expect)
	})
}

func (a *Asserter) checkBlankspaceNameViaTCPWithCallback(
	t *testing.T,
	workload *topology.Workload,
	us *topology.Upstream,
	count int,
	resetFn func(r *retry.R),
	attemptFn func(r *retry.R, remoteName string),
	checkFn func(r *retry.R),
) {
	t.Helper()

	port := us.LocalPort
	require.True(t, port > 0)

	node := workload.Node

	// We can't use the forward proxy for TCP yet, so use the exposed port on localhost instead.
	exposedPort := node.ExposedPort(port)
	require.True(t, exposedPort > 0)

	addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(exposedPort))

	multiassert(t, count, resetFn, func(r *retry.R) {
		name, err := GetBlankspaceNameViaTCP(context.Background(), addr)
		require.NoError(r, err)
		attemptFn(r, name)
	}, func(r *retry.R) {
		checkFn(r)
	})
}

// CheckBlankspaceNameViaGRPC calls a copy of blankspace and asserts it arrived
// on the correct instance using gRPC.
func (a *Asserter) CheckBlankspaceNameViaGRPC(
	t *testing.T,
	workload *topology.Workload,
	us *topology.Upstream,
	clusterName string,
	sid topology.ID,
) {
	t.Helper()

	a.checkBlankspaceNameViaGRPCWithCallback(t, workload, us, 1, func(_ *retry.R) {}, func(r *retry.R, remoteName string) {
		require.Equal(r, fmt.Sprintf("%s::%s", clusterName, sid.String()), remoteName)
	}, func(_ *retry.R) {})
}

// CheckBlankspaceNameTrafficSplitViaGRPC is like CheckBlankspaceNameViaGRPC
// but it is verifying a relative traffic split.
func (a *Asserter) CheckBlankspaceNameTrafficSplitViaGRPC(
	t *testing.T,
	workload *topology.Workload,
	us *topology.Upstream,
	expect map[string]int,
) {
	t.Helper()

	got := make(map[string]int)
	a.checkBlankspaceNameViaGRPCWithCallback(t, workload, us, 100, func(_ *retry.R) {
		got = make(map[string]int)
	}, func(_ *retry.R, name string) {
		got[name]++
	}, func(r *retry.R) {
		assertTrafficSplitFor100Requests(r, got, expect)
	})
}

func (a *Asserter) checkBlankspaceNameViaGRPCWithCallback(
	t *testing.T,
	workload *topology.Workload,
	us *topology.Upstream,
	count int,
	resetFn func(r *retry.R),
	attemptFn func(r *retry.R, remoteName string),
	checkFn func(r *retry.R),
) {
	t.Helper()

	port := us.LocalPort
	require.True(t, port > 0)

	node := workload.Node

	// We can't use the forward proxy for gRPC yet, so use the exposed port on localhost instead.
	exposedPort := node.ExposedPort(port)
	require.True(t, exposedPort > 0)

	addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(exposedPort))

	multiassert(t, count, resetFn, func(r *retry.R) {
		name, err := GetBlankspaceNameViaGRPC(context.Background(), addr)
		require.NoError(r, err)
		attemptFn(r, name)
	}, func(r *retry.R) {
		checkFn(r)
	})
}

// assertTrafficSplitFor100Requests compares the counts of 100 requests that
// did reach an observed set of upstreams (nameCounts) against the expected
// counts of those same services is the same within a fixed difference of 2.
func assertTrafficSplitFor100Requests(t require.TestingT, nameCounts map[string]int, expect map[string]int) {
	const (
		numRequests  = 100
		allowedDelta = 2
	)
	require.Equal(t, numRequests, sumMapValues(nameCounts), "measured traffic was not %d requests", numRequests)
	require.Equal(t, numRequests, sumMapValues(expect), "expected traffic was not %d requests", numRequests)
	assertTrafficSplit(t, nameCounts, expect, allowedDelta)
}

func sumMapValues(m map[string]int) int {
	sum := 0
	for _, v := range m {
		sum += v
	}
	return sum
}

// assertTrafficSplit compares the counts of requests that did reach an
// observed set of upstreams (nameCounts) against the expected counts of
// those same services is the same within the provided allowedDelta value.
//
// When doing random traffic splits it'll never be perfect so we need the
// wiggle room to avoid having a flaky test.
func assertTrafficSplit(t require.TestingT, nameCounts map[string]int, expect map[string]int, allowedDelta int) {
	require.Len(t, nameCounts, len(expect))
	for name, expectCount := range expect {
		gotCount, ok := nameCounts[name]
		require.True(t, ok)
		if len(expect) == 1 {
			require.Equal(t, expectCount, gotCount)
		} else {
			require.InDelta(t, expectCount, gotCount, float64(allowedDelta),
				"expected %q side of split to have %d requests not %d (e=%d)",
				name, expectCount, gotCount, allowedDelta,
			)
		}
	}
}

// multiassert will retry in bulk calling attemptFn count times and following
// that with one last call to checkFn.
//
// It's primary use at the time it was written was to execute a set of requests
// repeatedly to witness where the requests went, and then at the end doing a
// verification of traffic splits (a bit like MAP/REDUCE).
func multiassert(t *testing.T, count int, resetFn, attemptFn, checkFn func(r *retry.R)) {
	retry.RunWith(&retry.Timer{Timeout: 30 * time.Second, Wait: 500 * time.Millisecond}, t, func(r *retry.R) {
		resetFn(r)
		for i := 0; i < count; i++ {
			attemptFn(r)
		}
		checkFn(r)
	})
}
