// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package middleware

import (
	"errors"
	"net"
	"net/netip"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/armon/go-metrics"
	"github.com/hashicorp/consul/agent/consul/rate"
	"github.com/hashicorp/go-hclog"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
)

// obs holds all the things we want to assert on that we recorded correctly in our tests.
type obs struct {
	key     []string
	elapsed float32
	labels  []metrics.Label
}

// recorderStore acts as an in-mem mock storage for all the RequestRecorder.Record() RecorderFunc calls.
type recorderStore struct {
	lock  sync.Mutex
	store map[string]obs
}

func (rs *recorderStore) put(key []string, o obs) {
	rs.lock.Lock()
	defer rs.lock.Unlock()

	actualKey := strings.Join(append(key, o.labels[0].Value), "")
	rs.store[actualKey] = o
}

func (rs *recorderStore) get(key []string) obs {
	rs.lock.Lock()
	defer rs.lock.Unlock()

	actualKey := strings.Join(key, "")
	return rs.store[actualKey]
}

var store = recorderStore{store: make(map[string]obs)}
var simpleRecorderFunc = func(key []string, val float32, labels []metrics.Label) {
	o := obs{key: key, elapsed: val, labels: labels}
	store.put(key, o)
}

type readRequest struct{}
type writeRequest struct{}
type readReqWithTD struct{}
type writeReqWithTD struct{}

func (rr readRequest) IsRead() bool {
	return true
}

func (wr writeRequest) IsRead() bool {
	return false
}

func (r readReqWithTD) IsRead() bool {
	return true
}

func (r readReqWithTD) RequestDatacenter() string {
	return "dc3"
}

func (r readReqWithTD) GetMinQueryIndex() uint64 {
	return 1
}
func (r readReqWithTD) AllowStaleRead() bool {
	return false
}

func (w writeReqWithTD) IsRead() bool {
	return false
}

func (w writeReqWithTD) RequestDatacenter() string {
	return "dc2"
}

type testCase struct {
	name string
	// description is meant for human friendliness
	description string
	// requestName is encouraged to be unique across tests to
	// avoid lock contention
	requestName string
	requestI    interface{}
	rpcType     string
	errored     bool
	isLeader    func() bool
	dc          string
	// the first element in expectedLabels should be the method name
	expectedLabels []metrics.Label
}

var testCases = []testCase{
	{
		name:        "simple ok",
		description: "This is a simple happy path test case. We check for pass through and normal request processing",
		requestName: "A.B",
		requestI:    struct{}{},
		rpcType:     RPCTypeInternal,
		errored:     false,
		dc:          "dc1",
		expectedLabels: []metrics.Label{
			{Name: "method", Value: "A.B"},
			{Name: "errored", Value: "false"},
			{Name: "request_type", Value: "unreported"},
			{Name: "rpc_type", Value: RPCTypeInternal},
			{Name: "leader", Value: "unreported"},
		},
	},
	{
		name:        "simple ok errored",
		description: "Checks that the errored value is populated right.",
		requestName: "A.C",
		requestI:    struct{}{},
		rpcType:     "test",
		errored:     true,
		dc:          "dc1",
		expectedLabels: []metrics.Label{
			{Name: "method", Value: "A.C"},
			{Name: "errored", Value: "true"},
			{Name: "request_type", Value: "unreported"},
			{Name: "rpc_type", Value: "test"},
			{Name: "leader", Value: "unreported"},
		},
	},
	{
		name:        "read request, rpc type internal",
		description: "Checks for read request interface parsing",
		requestName: "B.C",
		requestI:    readRequest{},
		rpcType:     RPCTypeInternal,
		errored:     false,
		dc:          "dc1",
		expectedLabels: []metrics.Label{
			{Name: "method", Value: "B.C"},
			{Name: "errored", Value: "false"},
			{Name: "request_type", Value: "read"},
			{Name: "rpc_type", Value: RPCTypeInternal},
			{Name: "leader", Value: "unreported"},
		},
	},
	{
		name:        "write request, rpc type net/rpc",
		description: "Checks for write request interface, different RPC type",
		requestName: "D.E",
		requestI:    writeRequest{},
		rpcType:     RPCTypeNetRPC,
		errored:     false,
		dc:          "dc1",
		expectedLabels: []metrics.Label{
			{Name: "method", Value: "D.E"},
			{Name: "errored", Value: "false"},
			{Name: "request_type", Value: "write"},
			{Name: "rpc_type", Value: RPCTypeNetRPC},
			{Name: "leader", Value: "unreported"},
		},
	},
	{
		name:        "read request with blocking stale and target dc",
		description: "Checks for locality, blocking status and target dc",
		requestName: "E.F",
		requestI:    readReqWithTD{},
		rpcType:     RPCTypeNetRPC,
		errored:     false,
		dc:          "dc1",
		expectedLabels: []metrics.Label{
			{Name: "method", Value: "E.F"},
			{Name: "errored", Value: "false"},
			{Name: "request_type", Value: "read"},
			{Name: "rpc_type", Value: RPCTypeNetRPC},
			{Name: "leader", Value: "unreported"},
			{Name: "allow_stale", Value: "false"},
			{Name: "blocking", Value: "true"},
			{Name: "target_datacenter", Value: "dc3"},
			{Name: "locality", Value: "forwarded"},
		},
	},
	{
		name:        "write request with TD, locality local",
		description: "Checks for write request with local forwarding and target dc",
		requestName: "F.G",
		requestI:    writeReqWithTD{},
		rpcType:     RPCTypeNetRPC,
		errored:     false,
		dc:          "dc2",
		expectedLabels: []metrics.Label{
			{Name: "method", Value: "F.G"},
			{Name: "errored", Value: "false"},
			{Name: "request_type", Value: "write"},
			{Name: "rpc_type", Value: RPCTypeNetRPC},
			{Name: "leader", Value: "unreported"},
			{Name: "target_datacenter", Value: "dc2"},
			{Name: "locality", Value: "local"},
		},
	},
	{
		name:        "is leader",
		description: "checks for is leader",
		requestName: "G.H",
		requestI:    struct{}{},
		rpcType:     "test",
		errored:     false,
		isLeader: func() bool {
			return true
		},
		expectedLabels: []metrics.Label{
			{Name: "method", Value: "G.H"},
			{Name: "errored", Value: "false"},
			{Name: "request_type", Value: "unreported"},
			{Name: "rpc_type", Value: "test"},
			{Name: "leader", Value: "true"},
		},
	},
	{
		name:        "is not leader",
		description: "checks for is not leader",
		requestName: "H.I",
		requestI:    struct{}{},
		rpcType:     "test",
		errored:     false,
		isLeader: func() bool {
			return false
		},
		expectedLabels: []metrics.Label{
			{Name: "method", Value: "H.I"},
			{Name: "errored", Value: "false"},
			{Name: "request_type", Value: "unreported"},
			{Name: "rpc_type", Value: "test"},
			{Name: "leader", Value: "false"},
		},
	},
}

// TestRequestRecorder goes over all the parsing and reporting that RequestRecorder
// is expected to perform.
func TestRequestRecorder(t *testing.T) {

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {

			r := RequestRecorder{
				Logger:         hclog.NewInterceptLogger(&hclog.LoggerOptions{}),
				RecorderFunc:   simpleRecorderFunc,
				serverIsLeader: tc.isLeader,
				localDC:        tc.dc,
			}

			start := time.Now()
			r.Record(tc.requestName, tc.rpcType, start, tc.requestI, tc.errored)

			key := append(metricRPCRequest, tc.expectedLabels[0].Value)
			o := store.get(key)

			require.Equal(t, o.key, metricRPCRequest)
			require.LessOrEqual(t, o.elapsed, float32(time.Now().Sub(start).Microseconds())/1000)
			require.Equal(t, o.labels, tc.expectedLabels)

		})
	}
}

func TestGetNetRPCRateLimitingInterceptor(t *testing.T) {
	limiter := rate.NewMockRequestLimitsHandler(t)

	logger := hclog.NewNullLogger()
	rateLimitInterceptor := GetNetRPCRateLimitingInterceptor(limiter, NewPanicHandler(logger))

	addr := net.TCPAddrFromAddrPort(netip.MustParseAddrPort("1.2.3.4:5678"))

	t.Run("allow operation", func(t *testing.T) {
		limiter.On("Allow", mock.Anything).
			Return(nil).
			Once()

		err := rateLimitInterceptor("Status.Leader", addr)
		require.NoError(t, err)
	})

	t.Run("allow returns error", func(t *testing.T) {
		limiter.On("Allow", mock.Anything).
			Return(errors.New("uh oh")).
			Once()

		err := rateLimitInterceptor("Status.Leader", addr)
		require.Error(t, err)
		require.Equal(t, "uh oh", err.Error())
	})

	t.Run("allow panics", func(t *testing.T) {
		limiter.On("Allow", mock.Anything).
			Panic("uh oh").
			Once()

		err := rateLimitInterceptor("Status.Leader", addr)

		require.Error(t, err)
		require.Equal(t, "rpc: panic serving request", err.Error())
	})
}
