// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package testlib

import (
	"context"
	"os"
	"os/exec"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
	authorizationv1 "k8s.io/api/authorization/v1"
	rbacv1 "k8s.io/api/rbac/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/client-go/kubernetes"
)

const (
	accessRetryInterval = 250 * time.Millisecond
	accessRetryTimeout  = 2 * time.Minute
)

// AccessAsUserTest runs a generic test in which a clientUnderTest operating with username
// testUsername tries to auth to the kube API (i.e., list namespaces).
//
// Use this function if you want to simply validate that a user can auth to the kube API after
// performing a Pinniped credential exchange.
func AccessAsUserTest(
	ctx context.Context,
	testUsername string,
	clientUnderTest kubernetes.Interface,
) func(t *testing.T) {
	return func(t *testing.T) {
		addTestClusterUserCanViewEverythingRoleBinding(t, testUsername)

		// Use the client which is authenticated as the test user to list namespaces
		RequireEventually(t, func(requireEventually *require.Assertions) {
			resp, err := clientUnderTest.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
			requireEventually.NoError(err)
			requireEventually.NotNil(resp)
			requireEventually.NotEmpty(resp.Items)
		}, accessRetryTimeout, accessRetryInterval, "user never had access to list namespaces")
	}
}

func AccessAsUserWithKubectlTest(
	testKubeConfigYAML string,
	testUsername string,
	expectedNamespace string,
) func(t *testing.T) {
	return func(t *testing.T) {
		addTestClusterUserCanViewEverythingRoleBinding(t, testUsername)

		// Use the given kubeconfig with kubectl to list namespaces as the test user
		RequireEventually(t, func(requireEventually *require.Assertions) {
			kubectlCommandOutput, err := runKubectlGetNamespaces(t, testKubeConfigYAML)
			requireEventually.NoError(err)
			requireEventually.Containsf(kubectlCommandOutput, expectedNamespace, "actual output: %q", kubectlCommandOutput)
		}, accessRetryTimeout, accessRetryInterval, "user never had access to list namespaces via kubectl")
	}
}

// AccessAsGroupTest runs a generic test in which a clientUnderTest with membership in group
// testGroup tries to auth to the kube API (i.e., list namespaces).
//
// Use this function if you want to simply validate that a user can auth to the kube API (via
// a group membership) after performing a Pinniped credential exchange.
func AccessAsGroupTest(
	ctx context.Context,
	testGroup string,
	clientUnderTest kubernetes.Interface,
) func(t *testing.T) {
	return func(t *testing.T) {
		addTestClusterGroupCanViewEverythingRoleBinding(t, testGroup)

		// Use the client which is authenticated as the test user to list namespaces
		RequireEventually(t, func(requireEventually *require.Assertions) {
			listNamespaceResponse, err := clientUnderTest.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
			requireEventually.NoError(err)
			requireEventually.NotNil(listNamespaceResponse)
			requireEventually.NotEmpty(listNamespaceResponse.Items)
		}, accessRetryTimeout, accessRetryInterval, "user never had access to list namespaces")
	}
}

func AccessAsGroupWithKubectlTest(
	testKubeConfigYAML string,
	testGroup string,
	expectedNamespace string,
) func(t *testing.T) {
	return func(t *testing.T) {
		addTestClusterGroupCanViewEverythingRoleBinding(t, testGroup)

		// Use the given kubeconfig with kubectl to list namespaces as the test user
		RequireEventually(t, func(requireEventually *require.Assertions) {
			kubectlCommandOutput, err := runKubectlGetNamespaces(t, testKubeConfigYAML)
			requireEventually.NoError(err)
			requireEventually.Containsf(kubectlCommandOutput, expectedNamespace, "actual output: %q", kubectlCommandOutput)
		}, accessRetryTimeout, accessRetryInterval, "user never had access to list namespaces")
	}
}

func addTestClusterUserCanViewEverythingRoleBinding(t *testing.T, testUsername string) {
	t.Helper()

	CreateTestClusterRoleBinding(t,
		rbacv1.Subject{
			Kind:     rbacv1.UserKind,
			APIGroup: rbacv1.GroupName,
			Name:     testUsername,
		},
		rbacv1.RoleRef{
			Kind:     "ClusterRole",
			APIGroup: rbacv1.GroupName,
			Name:     "view",
		},
	)
	WaitForUserToHaveAccess(t, testUsername, []string{}, &authorizationv1.ResourceAttributes{
		Verb:     "get",
		Group:    "",
		Version:  "v1",
		Resource: "namespaces",
	})
}

func addTestClusterGroupCanViewEverythingRoleBinding(t *testing.T, testGroup string) {
	t.Helper()

	CreateTestClusterRoleBinding(t,
		rbacv1.Subject{
			Kind:     rbacv1.GroupKind,
			APIGroup: rbacv1.GroupName,
			Name:     testGroup,
		},
		rbacv1.RoleRef{
			Kind:     "ClusterRole",
			APIGroup: rbacv1.GroupName,
			Name:     "view",
		},
	)
	WaitForUserToHaveAccess(t, "", []string{testGroup}, &authorizationv1.ResourceAttributes{
		Verb:     "get",
		Group:    "",
		Version:  "v1",
		Resource: "namespaces",
	})
}

func runKubectlGetNamespaces(t *testing.T, kubeConfigYAML string) (string, error) {
	t.Helper()

	f := writeStringToTempFile(t, "pinniped-generated-kubeconfig-*", kubeConfigYAML)

	//nolint:gosec // It's okay that we are passing f.Name() to an exec command here. It was created above.
	output, err := exec.CommandContext(
		t.Context(),
		"kubectl", "get", "namespace", "--kubeconfig", f.Name(),
	).CombinedOutput()

	return string(output), err
}

func writeStringToTempFile(t *testing.T, filename string, kubeConfigYAML string) *os.File {
	t.Helper()
	f, err := os.CreateTemp("", filename)
	require.NoError(t, err)
	deferMe := func() {
		err := os.Remove(f.Name())
		require.NoError(t, err)
	}
	t.Cleanup(deferMe)
	_, err = f.WriteString(kubeConfigYAML)
	require.NoError(t, err)
	err = f.Close()
	require.NoError(t, err)
	return f
}
