/*
Copyright 2022 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package create

import (
	"context"
	"fmt"
	"strings"
	"time"

	"github.com/spf13/cobra"

	authenticationv1 "k8s.io/api/authentication/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	"k8s.io/apimachinery/pkg/util/sets"
	"k8s.io/cli-runtime/pkg/genericclioptions"
	corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
	cmdutil "k8s.io/kubectl/pkg/cmd/util"
	"k8s.io/kubectl/pkg/scheme"
	"k8s.io/kubectl/pkg/util/completion"
	"k8s.io/kubectl/pkg/util/templates"
	"k8s.io/kubectl/pkg/util/term"
	"k8s.io/utils/pointer"
)

// TokenOptions is the data required to perform a token request operation.
type TokenOptions struct {
	// PrintFlags holds options necessary for obtaining a printer
	PrintFlags *genericclioptions.PrintFlags
	PrintObj   func(obj runtime.Object) error

	// Name and namespace of service account to create a token for
	Name      string
	Namespace string

	// BoundObjectKind is the kind of object to bind the token to. Optional. Can be Pod or Secret.
	BoundObjectKind string
	// BoundObjectName is the name of the object to bind the token to. Required if BoundObjectKind is set.
	BoundObjectName string
	// BoundObjectUID is the uid of the object to bind the token to. If unset, defaults to the current uid of the bound object.
	BoundObjectUID string

	// Audiences indicate the valid audiences for the requested token. If unset, defaults to the Kubernetes API server audiences.
	Audiences []string

	// Duration is the requested token lifetime. Optional.
	Duration time.Duration

	// CoreClient is the API client used to request the token. Required.
	CoreClient corev1client.CoreV1Interface

	// IOStreams are the output streams for the operation. Required.
	genericclioptions.IOStreams
}

var (
	tokenLong = templates.LongDesc(`Request a service account token.`)

	tokenExample = templates.Examples(`
		# Request a token to authenticate to the kube-apiserver as the service account "myapp" in the current namespace
		kubectl create token myapp

		# Request a token for a service account in a custom namespace
		kubectl create token myapp --namespace myns

		# Request a token with a custom expiration
		kubectl create token myapp --duration 10m

		# Request a token with a custom audience
		kubectl create token myapp --audience https://example.com

		# Request a token bound to an instance of a Secret object
		kubectl create token myapp --bound-object-kind Secret --bound-object-name mysecret

		# Request a token bound to an instance of a Secret object with a specific uid
		kubectl create token myapp --bound-object-kind Secret --bound-object-name mysecret --bound-object-uid 0d4691ed-659b-4935-a832-355f77ee47cc
`)

	boundObjectKindToAPIVersion = map[string]string{
		"Pod":    "v1",
		"Secret": "v1",
	}
)

func NewTokenOpts(ioStreams genericclioptions.IOStreams) *TokenOptions {
	return &TokenOptions{
		PrintFlags: genericclioptions.NewPrintFlags("created").WithTypeSetter(scheme.Scheme),
		IOStreams:  ioStreams,
	}
}

// NewCmdCreateToken returns an initialized Command for 'create token' sub command
func NewCmdCreateToken(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command {
	o := NewTokenOpts(ioStreams)

	cmd := &cobra.Command{
		Use:                   "token SERVICE_ACCOUNT_NAME",
		DisableFlagsInUseLine: true,
		Short:                 "Request a service account token",
		Long:                  tokenLong,
		Example:               tokenExample,
		ValidArgsFunction:     completion.ResourceNameCompletionFunc(f, "serviceaccount"),
		Run: func(cmd *cobra.Command, args []string) {
			if err := o.Complete(f, cmd, args); err != nil {
				cmdutil.CheckErr(err)
				return
			}
			if err := o.Validate(); err != nil {
				cmdutil.CheckErr(err)
				return
			}
			if err := o.Run(); err != nil {
				cmdutil.CheckErr(err)
				return
			}
		},
	}

	o.PrintFlags.AddFlags(cmd)

	cmd.Flags().StringArrayVar(&o.Audiences, "audience", o.Audiences, "Audience of the requested token. If unset, defaults to requesting a token for use with the Kubernetes API server. May be repeated to request a token valid for multiple audiences.")

	cmd.Flags().DurationVar(&o.Duration, "duration", o.Duration, "Requested lifetime of the issued token. The server may return a token with a longer or shorter lifetime.")

	cmd.Flags().StringVar(&o.BoundObjectKind, "bound-object-kind", o.BoundObjectKind, "Kind of an object to bind the token to. "+
		"Supported kinds are "+strings.Join(sets.StringKeySet(boundObjectKindToAPIVersion).List(), ", ")+". "+
		"If set, --bound-object-name must be provided.")
	cmd.Flags().StringVar(&o.BoundObjectName, "bound-object-name", o.BoundObjectName, "Name of an object to bind the token to. "+
		"The token will expire when the object is deleted. "+
		"Requires --bound-object-kind.")
	cmd.Flags().StringVar(&o.BoundObjectUID, "bound-object-uid", o.BoundObjectUID, "UID of an object to bind the token to. "+
		"Requires --bound-object-kind and --bound-object-name. "+
		"If unset, the UID of the existing object is used.")

	return cmd
}

// Complete completes all the required options
func (o *TokenOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []string) error {
	var err error

	o.Name, err = NameFromCommandArgs(cmd, args)
	if err != nil {
		return err
	}

	o.Namespace, _, err = f.ToRawKubeConfigLoader().Namespace()
	if err != nil {
		return err
	}

	client, err := f.KubernetesClientSet()
	if err != nil {
		return err
	}
	o.CoreClient = client.CoreV1()

	printer, err := o.PrintFlags.ToPrinter()
	if err != nil {
		return err
	}

	o.PrintObj = func(obj runtime.Object) error {
		return printer.PrintObj(obj, o.Out)
	}

	return nil
}

// Validate makes sure provided values for TokenOptions are valid
func (o *TokenOptions) Validate() error {
	if o.CoreClient == nil {
		return fmt.Errorf("no client provided")
	}
	if len(o.Name) == 0 {
		return fmt.Errorf("service account name is required")
	}
	if len(o.Namespace) == 0 {
		return fmt.Errorf("--namespace is required")
	}
	if o.Duration < 0 {
		return fmt.Errorf("--duration must be positive")
	}
	if o.Duration%time.Second != 0 {
		return fmt.Errorf("--duration cannot be expressed in units less than seconds")
	}
	for _, aud := range o.Audiences {
		if len(aud) == 0 {
			return fmt.Errorf("--audience must not be an empty string")
		}
	}

	if len(o.BoundObjectKind) == 0 {
		if len(o.BoundObjectName) > 0 {
			return fmt.Errorf("--bound-object-name can only be set if --bound-object-kind is provided")
		}
		if len(o.BoundObjectUID) > 0 {
			return fmt.Errorf("--bound-object-uid can only be set if --bound-object-kind is provided")
		}
	} else {
		if _, ok := boundObjectKindToAPIVersion[o.BoundObjectKind]; !ok {
			return fmt.Errorf("supported --bound-object-kind values are %s", strings.Join(sets.StringKeySet(boundObjectKindToAPIVersion).List(), ", "))
		}
		if len(o.BoundObjectName) == 0 {
			return fmt.Errorf("--bound-object-name is required if --bound-object-kind is provided")
		}
	}

	return nil
}

// Run requests a token
func (o *TokenOptions) Run() error {
	request := &authenticationv1.TokenRequest{
		Spec: authenticationv1.TokenRequestSpec{
			Audiences: o.Audiences,
		},
	}
	if o.Duration > 0 {
		request.Spec.ExpirationSeconds = pointer.Int64(int64(o.Duration / time.Second))
	}
	if len(o.BoundObjectKind) > 0 {
		request.Spec.BoundObjectRef = &authenticationv1.BoundObjectReference{
			Kind:       o.BoundObjectKind,
			APIVersion: boundObjectKindToAPIVersion[o.BoundObjectKind],
			Name:       o.BoundObjectName,
			UID:        types.UID(o.BoundObjectUID),
		}
	}

	response, err := o.CoreClient.ServiceAccounts(o.Namespace).CreateToken(context.TODO(), o.Name, request, metav1.CreateOptions{})
	if err != nil {
		return fmt.Errorf("failed to create token: %v", err)
	}
	if len(response.Status.Token) == 0 {
		return fmt.Errorf("failed to create token: no token in server response")
	}

	if o.PrintFlags.OutputFlagSpecified() {
		return o.PrintObj(response)
	}

	if term.IsTerminal(o.Out) {
		// include a newline when printing interactively
		fmt.Fprintf(o.Out, "%s\n", response.Status.Token)
	} else {
		// otherwise just print the token
		fmt.Fprintf(o.Out, "%s", response.Status.Token)
	}

	return nil
}
