package serviceprofiles

import (
	"context"
	"errors"
	"fmt"
	"os"
	"strings"
	"testing"
	"time"

	sp "github.com/linkerd/linkerd2/controller/gen/apis/serviceprofile/v1alpha2"
	"github.com/linkerd/linkerd2/testutil"
	cmd2 "github.com/linkerd/linkerd2/viz/cmd"
	"sigs.k8s.io/yaml"
)

var TestHelper *testutil.TestHelper

type testCase struct {
	args           []string
	deployName     string
	expectedRoutes []string
	namespace      string
	sourceName     string
	spName         string
}

func TestMain(m *testing.M) {
	TestHelper = testutil.NewTestHelper()
	// Block test execution until viz extension is running
	TestHelper.WaitUntilDeployReady(testutil.LinkerdVizDeployReplicas)
	os.Exit(m.Run())
}

func TestServiceProfiles(t *testing.T) {
	ctx := context.Background()
	TestHelper.WithDataPlaneNamespace(ctx, "serviceprofile-test", map[string]string{}, t, func(t *testing.T, ns string) {
		t.Run("service profiles", testProfiles)
		t.Run("service profiles metrics", testMetrics)
	})
}

func testProfiles(t *testing.T) {
	ctx := context.Background()
	testNamespace := TestHelper.GetTestNamespace("serviceprofile-test")
	out, err := TestHelper.LinkerdRun("inject", "--manual", "testdata/tap_application.yaml")
	if err != nil {
		testutil.AnnotatedFatal(t, "'linkerd inject' command failed", err)
	}

	out, err = TestHelper.KubectlApply(out, testNamespace)
	if err != nil {
		testutil.AnnotatedFatalf(t, "'kubectl apply' command failed",
			"'kubectl apply' command failed\n%s", out)
	}

	// wait for deployments to start
	for _, deploy := range []string{"t1", "t2", "t3", "gateway"} {
		if err := TestHelper.CheckPods(ctx, testNamespace, deploy, 1); err != nil {
			//nolint:errorlint
			if rce, ok := err.(*testutil.RestartCountError); ok {
				testutil.AnnotatedWarn(t, "CheckPods timed-out", rce)
			} else {
				testutil.AnnotatedError(t, "CheckPods timed-out", err)
			}
		}
	}

	testCases := []testCase{
		{
			sourceName:     "tap",
			namespace:      testNamespace,
			deployName:     "deployment/t1",
			spName:         "t1-svc",
			expectedRoutes: []string{"POST /buoyantio.bb.TheService/theFunction", "[DEFAULT]"},
		},
		{
			sourceName:     "open-api",
			namespace:      testNamespace,
			spName:         "t3-svc",
			deployName:     "deployment/t3",
			expectedRoutes: []string{"DELETE /testpath", "GET /testpath", "PATCH /testpath", "POST /testpath", "[DEFAULT]"},
		},
	}

	for _, tc := range testCases {
		tc := tc // pin
		t.Run(tc.sourceName, func(t *testing.T) {
			routes, err := getRoutes(tc.deployName, tc.namespace, []string{})
			if err != nil {
				testutil.AnnotatedFatalf(t, "'linkerd routes' command failed",
					"'linkerd routes' command failed: %s\n", err)
			}

			initialExpectedRoutes := []string{"[DEFAULT]"}

			assertExpectedRoutes(initialExpectedRoutes, routes, t)

			sourceFlag := fmt.Sprintf("--%s", tc.sourceName)
			cmd := []string{"profile", "--namespace", tc.namespace, tc.spName, sourceFlag}
			if tc.sourceName == "tap" {
				cmd = append([]string{"viz"}, cmd...)
				tc.args = []string{
					tc.deployName,
					"--tap-route-limit",
					"1",
					"--tap-duration",
					"25s",
				}
			}

			if tc.sourceName == "open-api" {
				tc.args = []string{
					"testdata/t3.swagger",
				}
			}

			cmd = append(cmd, tc.args...)
			out, err := TestHelper.LinkerdRun(cmd...)
			if err != nil {
				testutil.AnnotatedFatal(t, fmt.Sprintf("'linkerd %s' command failed", cmd), err)
			}

			_, err = TestHelper.KubectlApply(out, tc.namespace)
			if err != nil {
				testutil.AnnotatedFatalf(t, "'kubectl apply' command failed",
					"'kubectl apply' command failed:\n%s", err)
			}

			routes, err = getRoutes(tc.deployName, tc.namespace, []string{})
			if err != nil {
				testutil.AnnotatedFatalf(t, "'linkerd routes' command failed",
					"'linkerd routes' command failed: %s\n", err)
			}

			assertExpectedRoutes(tc.expectedRoutes, routes, t)
		})
	}
}

func testMetrics(t *testing.T) {
	var (
		testNamespace        = TestHelper.GetTestNamespace("serviceprofile-test")
		testSP               = "world-svc"
		testDownstreamDeploy = "deployment/world"
		testUpstreamDeploy   = "deployment/hello"
		testYAML             = "testdata/hello_world.yaml"
	)

	out, err := TestHelper.LinkerdRun("inject", "--manual", testYAML)
	if err != nil {
		testutil.AnnotatedError(t, "'linkerd inject' command failed", err)
	}

	out, err = TestHelper.KubectlApply(out, testNamespace)
	if err != nil {
		testutil.AnnotatedErrorf(t, "'kubectl apply' command failed",
			"'kubectl apply' command failed\n%s", out)
	}

	cmd := []string{
		"profile",
		"--namespace",
		testNamespace,
		"--open-api",
		"testdata/world.swagger",
		testSP,
	}

	out, err = TestHelper.LinkerdRun(cmd...)
	if err != nil {
		testutil.AnnotatedError(t, fmt.Sprintf("'linkerd %s' command failed", cmd), err)
	}

	_, err = TestHelper.KubectlApply(out, testNamespace)
	if err != nil {
		testutil.AnnotatedErrorf(t, "'kubectl apply' command failed",
			"'kubectl apply' command failed\n%s", err)
	}

	assertRouteStat(testUpstreamDeploy, testNamespace, testDownstreamDeploy, t, func(stat *cmd2.JSONRouteStats) error {
		if !(*stat.ActualSuccess > 0.00 && *stat.ActualSuccess < 100.00) {
			return fmt.Errorf("expected Actual Success to be greater than 0%% and less than 100%% due to pre-seeded failure rate. But got %0.2f", *stat.ActualSuccess)
		}
		return nil
	})

	profile := &sp.ServiceProfile{}

	// Grab the output and convert it to a service profile object for modification
	err = yaml.Unmarshal([]byte(out), profile)
	if err != nil {
		testutil.AnnotatedErrorf(t, "unable to unmarshal YAML",
			"unable to unmarshal YAML: %s", err)
	}

	// introduce retry in the service profile
	for _, route := range profile.Spec.Routes {
		if route.Name == "GET /testpath" {
			route.IsRetryable = true
			break
		}
	}

	bytes, err := yaml.Marshal(profile)
	if err != nil {
		testutil.AnnotatedErrorf(t, "error marshalling service profile",
			"error marshalling service profile: %s", bytes)
	}

	out, err = TestHelper.KubectlApply(string(bytes), testNamespace)
	if err != nil {
		testutil.AnnotatedErrorf(t, "'kubectl apply' command failed",
			"'kubectl apply' command failed:\n%s :%s", err, out)
	}

	assertRouteStat(testUpstreamDeploy, testNamespace, testDownstreamDeploy, t, func(stat *cmd2.JSONRouteStats) error {
		if *stat.EffectiveSuccess < 0.95 {
			return fmt.Errorf("expected Effective Success to be at least 95%% with retries enabled. But got %.2f", *stat.EffectiveSuccess)
		}
		return nil
	})
}

func assertRouteStat(upstream, namespace, downstream string, t *testing.T, assertFn func(stat *cmd2.JSONRouteStats) error) {
	const routePath = "GET /testpath"
	timeout := 2 * time.Minute
	err := testutil.RetryFor(timeout, func() error {
		routes, err := getRoutes(upstream, namespace, []string{"--to", downstream})
		if err != nil {
			return fmt.Errorf("'linkerd routes' command failed: %w", err)
		}

		var testRoute *cmd2.JSONRouteStats
		assertExpectedRoutes([]string{routePath, "[DEFAULT]"}, routes, t)

		for _, route := range routes {
			if route.Route == routePath {
				testRoute = route
			}
		}

		if testRoute == nil {
			return errors.New("expected test route not to be nil")
		}

		return assertFn(testRoute)
	})

	if err != nil {
		testutil.AnnotatedFatal(t, fmt.Sprintf("timed-out asserting route stat (%s)", timeout), err)
	}
}

func assertExpectedRoutes(expected []string, actual []*cmd2.JSONRouteStats, t *testing.T) {

	if len(expected) != len(actual) {
		testutil.Errorf(t, "mismatch routes count. Expected %d, Actual %d", len(expected), len(actual))
	}

	for _, expectedRoute := range expected {
		containsRoute := false
		for _, actualRoute := range actual {
			if actualRoute.Route == expectedRoute {
				containsRoute = true
				break
			}
		}
		if !containsRoute {
			sb := strings.Builder{}
			for _, route := range actual {
				sb.WriteString(fmt.Sprintf("%s ", route.Route))
			}
			testutil.Errorf(t, "expected route %s not found in %+v", expectedRoute, sb.String())
		}
	}
}

func getRoutes(deployName, namespace string, additionalArgs []string) ([]*cmd2.JSONRouteStats, error) {
	cmd := []string{"viz", "routes", "--namespace", namespace, deployName}

	if len(additionalArgs) > 0 {
		cmd = append(cmd, additionalArgs...)
	}

	cmd = append(cmd, "--output", "json")
	var results map[string][]*cmd2.JSONRouteStats
	err := testutil.RetryFor(2*time.Minute, func() error {
		out, err := TestHelper.LinkerdRun(cmd...)
		if err != nil {
			return err
		}

		if err := yaml.Unmarshal([]byte(out), &results); err != nil {
			return err
		}

		if _, ok := results[deployName]; ok {
			return nil
		}

		keys := []string{}
		for k := range results {
			keys = append(keys, k)
		}
		return fmt.Errorf("could not retrieve route info for %s; found [%s]", deployName, strings.Join(keys, ", "))
	})
	if err != nil {
		return nil, err
	}
	return results[deployName], nil

}
