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

package nomad

import (
	"fmt"
	"math/rand"
	"reflect"
	"testing"
	"testing/quick"
	"time"

	"github.com/hashicorp/nomad/ci"
	"github.com/hashicorp/nomad/nomad/mock"
	"github.com/hashicorp/nomad/nomad/structs"
	"github.com/stretchr/testify/require"
)

func now(year int) time.Time {
	return time.Date(2000+year, 1, 2, 3, 4, 5, 6, time.UTC)
}

func TestBlockedResourceSummary_Add(t *testing.T) {
	now1 := now(1)
	now2 := now(2)
	a := BlockedResourcesSummary{
		Timestamp: now1,
		CPU:       600,
		MemoryMB:  256,
	}

	b := BlockedResourcesSummary{
		Timestamp: now2,
		CPU:       250,
		MemoryMB:  128,
	}

	result := a.Add(b)

	// a not modified
	require.Equal(t, 600, a.CPU)
	require.Equal(t, 256, a.MemoryMB)
	require.Equal(t, now1, a.Timestamp)

	// b not modified
	require.Equal(t, 250, b.CPU)
	require.Equal(t, 128, b.MemoryMB)
	require.Equal(t, now2, b.Timestamp)

	// result is a + b, using timestamp from b
	require.Equal(t, 850, result.CPU)
	require.Equal(t, 384, result.MemoryMB)
	require.Equal(t, now2, result.Timestamp)
}

func TestBlockedResourceSummary_Add_nil(t *testing.T) {
	now1 := now(1)
	b := BlockedResourcesSummary{
		Timestamp: now1,
		CPU:       250,
		MemoryMB:  128,
	}

	// zero + b == b
	result := (BlockedResourcesSummary{}).Add(b)
	require.Equal(t, now1, result.Timestamp)
	require.Equal(t, 250, result.CPU)
	require.Equal(t, 128, result.MemoryMB)
}

func TestBlockedResourceSummary_Subtract(t *testing.T) {
	now1 := now(1)
	now2 := now(2)
	a := BlockedResourcesSummary{
		Timestamp: now1,
		CPU:       600,
		MemoryMB:  256,
	}

	b := BlockedResourcesSummary{
		Timestamp: now2,
		CPU:       250,
		MemoryMB:  120,
	}

	result := a.Subtract(b)

	// a not modified
	require.Equal(t, 600, a.CPU)
	require.Equal(t, 256, a.MemoryMB)
	require.Equal(t, now1, a.Timestamp)

	// b not modified
	require.Equal(t, 250, b.CPU)
	require.Equal(t, 120, b.MemoryMB)
	require.Equal(t, now2, b.Timestamp)

	// result is a + b, using timestamp from b
	require.Equal(t, 350, result.CPU)
	require.Equal(t, 136, result.MemoryMB)
	require.Equal(t, now2, result.Timestamp)
}

func TestBlockedResourceSummary_IsZero(t *testing.T) {
	now1 := now(1)

	// cpu and mem zero, timestamp is ignored
	require.True(t, (&BlockedResourcesSummary{
		Timestamp: now1,
		CPU:       0,
		MemoryMB:  0,
	}).IsZero())

	// cpu non-zero
	require.False(t, (&BlockedResourcesSummary{
		Timestamp: now1,
		CPU:       1,
		MemoryMB:  0,
	}).IsZero())

	// mem non-zero
	require.False(t, (&BlockedResourcesSummary{
		Timestamp: now1,
		CPU:       0,
		MemoryMB:  1,
	}).IsZero())
}

func TestBlockedResourceStats_New(t *testing.T) {
	a := NewBlockedResourcesStats()
	require.NotNil(t, a.ByJob)
	require.Empty(t, a.ByJob)
	require.NotNil(t, a.ByClassInDC)
	require.Empty(t, a.ByClassInDC)
}

var (
	id1 = structs.NamespacedID{
		ID:        "1",
		Namespace: "one",
	}

	id2 = structs.NamespacedID{
		ID:        "2",
		Namespace: "two",
	}

	node1 = classInDC{
		dc:    "dc1",
		class: "alpha",
	}

	node2 = classInDC{
		dc:    "dc1",
		class: "beta",
	}

	node3 = classInDC{
		dc:    "dc1",
		class: "", // not set
	}
)

func TestBlockedResourceStats_Copy(t *testing.T) {
	now1 := now(1)
	now2 := now(2)

	a := NewBlockedResourcesStats()
	a.ByJob = map[structs.NamespacedID]BlockedResourcesSummary{
		id1: {
			Timestamp: now1,
			CPU:       100,
			MemoryMB:  256,
		},
	}
	a.ByClassInDC = map[classInDC]BlockedResourcesSummary{
		node1: {
			Timestamp: now1,
			CPU:       300,
			MemoryMB:  333,
		},
	}

	c := a.Copy()
	c.ByJob[id1] = BlockedResourcesSummary{
		Timestamp: now2,
		CPU:       888,
		MemoryMB:  888,
	}
	c.ByClassInDC[node1] = BlockedResourcesSummary{
		Timestamp: now2,
		CPU:       999,
		MemoryMB:  999,
	}

	// underlying data should have been deep copied
	require.Equal(t, 100, a.ByJob[id1].CPU)
	require.Equal(t, 300, a.ByClassInDC[node1].CPU)
}

func TestBlockedResourcesStats_Add(t *testing.T) {
	a := NewBlockedResourcesStats()
	a.ByJob = map[structs.NamespacedID]BlockedResourcesSummary{
		id1: {Timestamp: now(1), CPU: 111, MemoryMB: 222},
	}
	a.ByClassInDC = map[classInDC]BlockedResourcesSummary{
		node1: {Timestamp: now(2), CPU: 333, MemoryMB: 444},
	}

	b := NewBlockedResourcesStats()
	b.ByJob = map[structs.NamespacedID]BlockedResourcesSummary{
		id1: {Timestamp: now(3), CPU: 200, MemoryMB: 300},
		id2: {Timestamp: now(4), CPU: 400, MemoryMB: 500},
	}
	b.ByClassInDC = map[classInDC]BlockedResourcesSummary{
		node1: {Timestamp: now(5), CPU: 600, MemoryMB: 700},
		node2: {Timestamp: now(6), CPU: 800, MemoryMB: 900},
	}

	t.Run("a add b", func(t *testing.T) {
		result := a.Add(b)

		require.Equal(t, map[structs.NamespacedID]BlockedResourcesSummary{
			id1: {Timestamp: now(3), CPU: 311, MemoryMB: 522},
			id2: {Timestamp: now(4), CPU: 400, MemoryMB: 500},
		}, result.ByJob)

		require.Equal(t, map[classInDC]BlockedResourcesSummary{
			node1: {Timestamp: now(5), CPU: 933, MemoryMB: 1144},
			node2: {Timestamp: now(6), CPU: 800, MemoryMB: 900},
		}, result.ByClassInDC)
	})

	// make sure we handle zeros in both directions
	// and timestamps originate from rhs
	t.Run("b add a", func(t *testing.T) {
		result := b.Add(a)
		require.Equal(t, map[structs.NamespacedID]BlockedResourcesSummary{
			id1: {Timestamp: now(1), CPU: 311, MemoryMB: 522},
			id2: {Timestamp: now(4), CPU: 400, MemoryMB: 500},
		}, result.ByJob)

		require.Equal(t, map[classInDC]BlockedResourcesSummary{
			node1: {Timestamp: now(2), CPU: 933, MemoryMB: 1144},
			node2: {Timestamp: now(6), CPU: 800, MemoryMB: 900},
		}, result.ByClassInDC)
	})
}

func TestBlockedResourcesStats_Add_NoClass(t *testing.T) {
	a := NewBlockedResourcesStats()
	a.ByClassInDC = map[classInDC]BlockedResourcesSummary{
		node3: {Timestamp: now(1), CPU: 111, MemoryMB: 1111},
	}
	result := a.Add(a)
	require.Equal(t, map[classInDC]BlockedResourcesSummary{
		node3: {Timestamp: now(1), CPU: 222, MemoryMB: 2222},
	}, result.ByClassInDC)
}

func TestBlockedResourcesStats_Subtract(t *testing.T) {
	a := NewBlockedResourcesStats()
	a.ByJob = map[structs.NamespacedID]BlockedResourcesSummary{
		id1: {Timestamp: now(1), CPU: 100, MemoryMB: 100},
		id2: {Timestamp: now(2), CPU: 200, MemoryMB: 200},
	}
	a.ByClassInDC = map[classInDC]BlockedResourcesSummary{
		node1: {Timestamp: now(3), CPU: 300, MemoryMB: 300},
		node2: {Timestamp: now(4), CPU: 400, MemoryMB: 400},
	}

	b := NewBlockedResourcesStats()
	b.ByJob = map[structs.NamespacedID]BlockedResourcesSummary{
		id1: {Timestamp: now(5), CPU: 10, MemoryMB: 11},
		id2: {Timestamp: now(6), CPU: 12, MemoryMB: 13},
	}
	b.ByClassInDC = map[classInDC]BlockedResourcesSummary{
		node1: {Timestamp: now(7), CPU: 14, MemoryMB: 15},
		node2: {Timestamp: now(8), CPU: 16, MemoryMB: 17},
	}

	result := a.Subtract(b)

	// id1
	require.Equal(t, now(5), result.ByJob[id1].Timestamp)
	require.Equal(t, 90, result.ByJob[id1].CPU)
	require.Equal(t, 89, result.ByJob[id1].MemoryMB)

	// id2
	require.Equal(t, now(6), result.ByJob[id2].Timestamp)
	require.Equal(t, 188, result.ByJob[id2].CPU)
	require.Equal(t, 187, result.ByJob[id2].MemoryMB)

	// node1
	require.Equal(t, now(7), result.ByClassInDC[node1].Timestamp)
	require.Equal(t, 286, result.ByClassInDC[node1].CPU)
	require.Equal(t, 285, result.ByClassInDC[node1].MemoryMB)

	// node2
	require.Equal(t, now(8), result.ByClassInDC[node2].Timestamp)
	require.Equal(t, 384, result.ByClassInDC[node2].CPU)
	require.Equal(t, 383, result.ByClassInDC[node2].MemoryMB)
}

// testBlockedEvalsRandomBlockedEval wraps an eval that is randomly generated.
type testBlockedEvalsRandomBlockedEval struct {
	eval *structs.Evaluation
}

// Generate returns a random eval.
func (t testBlockedEvalsRandomBlockedEval) Generate(rand *rand.Rand, _ int) reflect.Value {
	resourceTypes := []string{"cpu", "memory"}

	// Start with a mock eval.
	e := mock.BlockedEval()

	// Get how many task groups, datacenters and node classes to generate.
	// Add 1 to avoid 0.
	jobCount := rand.Intn(3) + 1
	tgCount := rand.Intn(10) + 1
	dcCount := rand.Intn(3) + 1
	nodeClassCount := rand.Intn(3) + 1

	failedTGAllocs := map[string]*structs.AllocMetric{}

	e.JobID = fmt.Sprintf("job-%d", jobCount)
	for tg := 1; tg <= tgCount; tg++ {
		tgName := fmt.Sprintf("group-%d", tg)

		// Get which resource type to use for this task group.
		// Nomad stops at the first dimension that is exhausted, so only 1 is
		// added per task group.
		i := rand.Int() % len(resourceTypes)
		resourceType := resourceTypes[i]

		failedTGAllocs[tgName] = &structs.AllocMetric{
			DimensionExhausted: map[string]int{
				resourceType: 1,
			},
			NodesAvailable: map[string]int{},
			ClassExhausted: map[string]int{},
		}

		for dc := 1; dc <= dcCount; dc++ {
			dcName := fmt.Sprintf("dc%d", dc)
			failedTGAllocs[tgName].NodesAvailable[dcName] = 1
		}

		for nc := 1; nc <= nodeClassCount; nc++ {
			nodeClassName := fmt.Sprintf("node-class-%d", nc)
			failedTGAllocs[tgName].ClassExhausted[nodeClassName] = 1
		}

		// Generate resources for each task.
		taskCount := rand.Intn(5) + 1
		resourcesExhausted := map[string]*structs.Resources{}

		for t := 1; t <= taskCount; t++ {
			task := fmt.Sprintf("task-%d", t)
			resourcesExhausted[task] = &structs.Resources{}

			resourceAmount := rand.Intn(1000)
			switch resourceType {
			case "cpu":
				resourcesExhausted[task].CPU = resourceAmount
			case "memory":
				resourcesExhausted[task].MemoryMB = resourceAmount
			}
		}
		failedTGAllocs[tgName].ResourcesExhausted = resourcesExhausted
	}
	e.FailedTGAllocs = failedTGAllocs
	t.eval = e
	return reflect.ValueOf(t)
}

// clearTimestampFromBlockedResourceStats set timestamp metrics to zero to
// avoid invalid comparisons.
func clearTimestampFromBlockedResourceStats(b *BlockedResourcesStats) {
	for k, v := range b.ByJob {
		v.Timestamp = time.Time{}
		b.ByJob[k] = v
	}
	for k, v := range b.ByClassInDC {
		v.Timestamp = time.Time{}
		b.ByClassInDC[k] = v
	}
}

// TestBlockedEvalsStats_BlockedResources generates random evals and processes
// them using the expected code paths and a manual check of the expeceted result.
func TestBlockedEvalsStats_BlockedResources(t *testing.T) {
	ci.Parallel(t)
	blocked, _ := testBlockedEvals(t)

	// evalHistory stores all evals generated during the test.
	var evalHistory []*structs.Evaluation

	// blockedEvals keeps track if evals are blocked or unblocked.
	blockedEvals := map[string]bool{}

	// blockAndUntrack processes the generated evals in order using a
	// BlockedEvals instance.
	blockAndUntrack := func(testEval testBlockedEvalsRandomBlockedEval, block bool, unblockIdx uint16) *BlockedResourcesStats {
		if block || len(evalHistory) == 0 {
			blocked.Block(testEval.eval)
		} else {
			i := int(unblockIdx) % len(evalHistory)
			eval := evalHistory[i]
			blocked.Untrack(eval.JobID, eval.Namespace)
		}

		// Remove zero stats from unblocked evals.
		blocked.pruneStats(time.Now().UTC())

		result := blocked.Stats().BlockedResources
		clearTimestampFromBlockedResourceStats(result)
		return result
	}

	// manualCount processes only the blocked evals and generate a
	// BlockedResourcesStats result directly from the eval history.
	manualCount := func(testEval testBlockedEvalsRandomBlockedEval, block bool, unblockIdx uint16) *BlockedResourcesStats {
		if block || len(evalHistory) == 0 {
			evalHistory = append(evalHistory, testEval.eval)

			// Find and unblock evals for the same job.
			for _, e := range evalHistory {
				if e.Namespace == testEval.eval.Namespace && e.JobID == testEval.eval.JobID {
					blockedEvals[e.ID] = false
				}
			}
			blockedEvals[testEval.eval.ID] = true
		} else {
			i := int(unblockIdx) % len(evalHistory)
			eval := evalHistory[i]

			// Find and unlock all evals for this job.
			for _, e := range evalHistory {
				if e.Namespace == eval.Namespace && e.JobID == eval.JobID {
					blockedEvals[e.ID] = false
				}
			}
		}

		result := NewBlockedResourcesStats()
		for _, e := range evalHistory {
			if !blockedEvals[e.ID] {
				continue
			}
			result = result.Add(generateResourceStats(e))
		}
		clearTimestampFromBlockedResourceStats(result)
		return result
	}

	err := quick.CheckEqual(blockAndUntrack, manualCount, nil)
	if err != nil {
		t.Error(err)
	}
}
