/*
Copyright 2017 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 diff

import (
	"fmt"
	"io"
	"io/ioutil"
	"os"
	"path/filepath"
	"regexp"
	"strings"

	"github.com/jonboulle/clockwork"
	"github.com/spf13/cobra"
	"k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/api/meta"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	"k8s.io/cli-runtime/pkg/genericclioptions"
	"k8s.io/cli-runtime/pkg/resource"
	"k8s.io/client-go/discovery"
	"k8s.io/client-go/dynamic"
	"k8s.io/klog/v2"
	"k8s.io/kubectl/pkg/cmd/apply"
	cmdutil "k8s.io/kubectl/pkg/cmd/util"
	"k8s.io/kubectl/pkg/scheme"
	"k8s.io/kubectl/pkg/util"
	"k8s.io/kubectl/pkg/util/i18n"
	"k8s.io/kubectl/pkg/util/openapi"
	"k8s.io/kubectl/pkg/util/templates"
	"k8s.io/utils/exec"
	"sigs.k8s.io/yaml"
)

var (
	diffLong = templates.LongDesc(i18n.T(`
		Diff configurations specified by filename or stdin between the current online
		configuration, and the configuration as it would be if applied.

		Output is always YAML.

		KUBECTL_EXTERNAL_DIFF environment variable can be used to select your own
		diff command. Users can use external commands with params too, example:
		KUBECTL_EXTERNAL_DIFF="colordiff -N -u"

		By default, the "diff" command available in your path will be
		run with "-u" (unified diff) and "-N" (treat absent files as empty) options.

		Exit status:
		 0
		No differences were found.
		 1
		Differences were found.
		 >1
		Kubectl or diff failed with an error.

		Note: KUBECTL_EXTERNAL_DIFF, if used, is expected to follow that convention.`))

	diffExample = templates.Examples(i18n.T(`
		# Diff resources included in pod.json.
		kubectl diff -f pod.json

		# Diff file read from stdin
		cat service.yaml | kubectl diff -f -`))
)

// Number of times we try to diff before giving-up
const maxRetries = 4

// diffError returns the ExitError if the status code is less than 1,
// nil otherwise.
func diffError(err error) exec.ExitError {
	if err, ok := err.(exec.ExitError); ok && err.ExitStatus() <= 1 {
		return err
	}
	return nil
}

type DiffOptions struct {
	FilenameOptions resource.FilenameOptions

	ServerSideApply bool
	FieldManager    string
	ForceConflicts  bool

	Selector         string
	OpenAPISchema    openapi.Resources
	DiscoveryClient  discovery.DiscoveryInterface
	DynamicClient    dynamic.Interface
	DryRunVerifier   *resource.DryRunVerifier
	CmdNamespace     string
	EnforceNamespace bool
	Builder          *resource.Builder
	Diff             *DiffProgram
}

func validateArgs(cmd *cobra.Command, args []string) error {
	if len(args) != 0 {
		return cmdutil.UsageErrorf(cmd, "Unexpected args: %v", args)
	}
	return nil
}

func NewDiffOptions(ioStreams genericclioptions.IOStreams) *DiffOptions {
	return &DiffOptions{
		Diff: &DiffProgram{
			Exec:      exec.New(),
			IOStreams: ioStreams,
		},
	}
}

func NewCmdDiff(f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command {
	options := NewDiffOptions(streams)
	cmd := &cobra.Command{
		Use:                   "diff -f FILENAME",
		DisableFlagsInUseLine: true,
		Short:                 i18n.T("Diff live version against would-be applied version"),
		Long:                  diffLong,
		Example:               diffExample,
		Run: func(cmd *cobra.Command, args []string) {
			cmdutil.CheckDiffErr(options.Complete(f, cmd))
			cmdutil.CheckDiffErr(validateArgs(cmd, args))
			// `kubectl diff` propagates the error code from
			// diff or `KUBECTL_EXTERNAL_DIFF`. Also, we
			// don't want to print an error if diff returns
			// error code 1, which simply means that changes
			// were found. We also don't want kubectl to
			// return 1 if there was a problem.
			if err := options.Run(); err != nil {
				if exitErr := diffError(err); exitErr != nil {
					os.Exit(exitErr.ExitStatus())
				}
				cmdutil.CheckDiffErr(err)
			}
		},
	}

	usage := "contains the configuration to diff"
	cmd.Flags().StringVarP(&options.Selector, "selector", "l", options.Selector, "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2)")
	cmdutil.AddFilenameOptionFlags(cmd, &options.FilenameOptions, usage)
	cmdutil.AddServerSideApplyFlags(cmd)
	cmdutil.AddFieldManagerFlagVar(cmd, &options.FieldManager, apply.FieldManagerClientSideApply)

	return cmd
}

// DiffProgram finds and run the diff program. The value of
// KUBECTL_EXTERNAL_DIFF environment variable will be used a diff
// program. By default, `diff(1)` will be used.
type DiffProgram struct {
	Exec exec.Interface
	genericclioptions.IOStreams
}

func (d *DiffProgram) getCommand(args ...string) (string, exec.Cmd) {
	diff := ""
	if envDiff := os.Getenv("KUBECTL_EXTERNAL_DIFF"); envDiff != "" {
		diffCommand := strings.Split(envDiff, " ")
		diff = diffCommand[0]

		if len(diffCommand) > 1 {
			// Regex accepts: Alphanumeric (case-insensitive) and dash
			isValidChar := regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString
			for i := 1; i < len(diffCommand); i++ {
				if isValidChar(diffCommand[i]) {
					args = append(args, diffCommand[i])
				}
			}
		}
	} else {
		diff = "diff"
		args = append([]string{"-u", "-N"}, args...)
	}

	cmd := d.Exec.Command(diff, args...)
	cmd.SetStdout(d.Out)
	cmd.SetStderr(d.ErrOut)

	return diff, cmd
}

// Run runs the detected diff program. `from` and `to` are the directory to diff.
func (d *DiffProgram) Run(from, to string) error {
	diff, cmd := d.getCommand(from, to)
	if err := cmd.Run(); err != nil {
		// Let's not wrap diff errors, or we won't be able to
		// differentiate them later.
		if diffErr := diffError(err); diffErr != nil {
			return diffErr
		}
		return fmt.Errorf("failed to run %q: %v", diff, err)
	}
	return nil
}

// Printer is used to print an object.
type Printer struct{}

// Print the object inside the writer w.
func (p *Printer) Print(obj runtime.Object, w io.Writer) error {
	if obj == nil {
		return nil
	}
	data, err := yaml.Marshal(obj)
	if err != nil {
		return err
	}
	_, err = w.Write(data)
	return err

}

// DiffVersion gets the proper version of objects, and aggregate them into a directory.
type DiffVersion struct {
	Dir  *Directory
	Name string
}

// NewDiffVersion creates a new DiffVersion with the named version.
func NewDiffVersion(name string) (*DiffVersion, error) {
	dir, err := CreateDirectory(name)
	if err != nil {
		return nil, err
	}
	return &DiffVersion{
		Dir:  dir,
		Name: name,
	}, nil
}

func (v *DiffVersion) getObject(obj Object) (runtime.Object, error) {
	switch v.Name {
	case "LIVE":
		return obj.Live(), nil
	case "MERGED":
		return obj.Merged()
	}
	return nil, fmt.Errorf("Unknown version: %v", v.Name)
}

// Print prints the object using the printer into a new file in the directory.
func (v *DiffVersion) Print(obj Object, printer Printer) error {
	vobj, err := v.getObject(obj)
	if err != nil {
		return err
	}
	f, err := v.Dir.NewFile(obj.Name())
	if err != nil {
		return err
	}
	defer f.Close()
	return printer.Print(vobj, f)
}

// Directory creates a new temp directory, and allows to easily create new files.
type Directory struct {
	Name string
}

// CreateDirectory does create the actual disk directory, and return a
// new representation of it.
func CreateDirectory(prefix string) (*Directory, error) {
	name, err := ioutil.TempDir("", prefix+"-")
	if err != nil {
		return nil, err
	}

	return &Directory{
		Name: name,
	}, nil
}

// NewFile creates a new file in the directory.
func (d *Directory) NewFile(name string) (*os.File, error) {
	return os.OpenFile(filepath.Join(d.Name, name), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0700)
}

// Delete removes the directory recursively.
func (d *Directory) Delete() error {
	return os.RemoveAll(d.Name)
}

// Object is an interface that let's you retrieve multiple version of
// it.
type Object interface {
	Live() runtime.Object
	Merged() (runtime.Object, error)

	Name() string
}

// InfoObject is an implementation of the Object interface. It gets all
// the information from the Info object.
type InfoObject struct {
	LocalObj        runtime.Object
	Info            *resource.Info
	Encoder         runtime.Encoder
	OpenAPI         openapi.Resources
	Force           bool
	ServerSideApply bool
	FieldManager    string
	ForceConflicts  bool
	genericclioptions.IOStreams
}

var _ Object = &InfoObject{}

// Returns the live version of the object
func (obj InfoObject) Live() runtime.Object {
	return obj.Info.Object
}

// Returns the "merged" object, as it would look like if applied or
// created.
func (obj InfoObject) Merged() (runtime.Object, error) {
	helper := resource.NewHelper(obj.Info.Client, obj.Info.Mapping).
		DryRun(true).
		WithFieldManager(obj.FieldManager)
	if obj.ServerSideApply {
		data, err := runtime.Encode(unstructured.UnstructuredJSONScheme, obj.LocalObj)
		if err != nil {
			return nil, err
		}
		options := metav1.PatchOptions{
			Force:        &obj.ForceConflicts,
			FieldManager: obj.FieldManager,
		}
		return helper.Patch(
			obj.Info.Namespace,
			obj.Info.Name,
			types.ApplyPatchType,
			data,
			&options,
		)
	}

	// Build the patcher, and then apply the patch with dry-run, unless the object doesn't exist, in which case we need to create it.
	if obj.Live() == nil {
		// Dry-run create if the object doesn't exist.
		return helper.CreateWithOptions(
			obj.Info.Namespace,
			true,
			obj.LocalObj,
			&metav1.CreateOptions{},
		)
	}

	var resourceVersion *string
	if !obj.Force {
		accessor, err := meta.Accessor(obj.Info.Object)
		if err != nil {
			return nil, err
		}
		str := accessor.GetResourceVersion()
		resourceVersion = &str
	}

	modified, err := util.GetModifiedConfiguration(obj.LocalObj, false, unstructured.UnstructuredJSONScheme)
	if err != nil {
		return nil, err
	}

	// This is using the patcher from apply, to keep the same behavior.
	// We plan on replacing this with server-side apply when it becomes available.
	patcher := &apply.Patcher{
		Mapping:         obj.Info.Mapping,
		Helper:          helper,
		Overwrite:       true,
		BackOff:         clockwork.NewRealClock(),
		OpenapiSchema:   obj.OpenAPI,
		ResourceVersion: resourceVersion,
	}

	_, result, err := patcher.Patch(obj.Info.Object, modified, obj.Info.Source, obj.Info.Namespace, obj.Info.Name, obj.ErrOut)
	return result, err
}

func (obj InfoObject) Name() string {
	group := ""
	if obj.Info.Mapping.GroupVersionKind.Group != "" {
		group = fmt.Sprintf("%v.", obj.Info.Mapping.GroupVersionKind.Group)
	}
	return group + fmt.Sprintf(
		"%v.%v.%v.%v",
		obj.Info.Mapping.GroupVersionKind.Version,
		obj.Info.Mapping.GroupVersionKind.Kind,
		obj.Info.Namespace,
		obj.Info.Name,
	)
}

// Differ creates two DiffVersion and diffs them.
type Differ struct {
	From *DiffVersion
	To   *DiffVersion
}

func NewDiffer(from, to string) (*Differ, error) {
	differ := Differ{}
	var err error
	differ.From, err = NewDiffVersion(from)
	if err != nil {
		return nil, err
	}
	differ.To, err = NewDiffVersion(to)
	if err != nil {
		differ.From.Dir.Delete()
		return nil, err
	}

	return &differ, nil
}

// Diff diffs to versions of a specific object, and print both versions to directories.
func (d *Differ) Diff(obj Object, printer Printer) error {
	if err := d.From.Print(obj, printer); err != nil {
		return err
	}
	if err := d.To.Print(obj, printer); err != nil {
		return err
	}
	return nil
}

// Run runs the diff program against both directories.
func (d *Differ) Run(diff *DiffProgram) error {
	return diff.Run(d.From.Dir.Name, d.To.Dir.Name)
}

// TearDown removes both temporary directories recursively.
func (d *Differ) TearDown() {
	d.From.Dir.Delete() // Ignore error
	d.To.Dir.Delete()   // Ignore error
}

func isConflict(err error) bool {
	return err != nil && errors.IsConflict(err)
}

func (o *DiffOptions) Complete(f cmdutil.Factory, cmd *cobra.Command) error {
	var err error

	err = o.FilenameOptions.RequireFilenameOrKustomize()
	if err != nil {
		return err
	}

	o.ServerSideApply = cmdutil.GetServerSideApplyFlag(cmd)
	o.FieldManager = apply.GetApplyFieldManagerFlag(cmd, o.ServerSideApply)
	o.ForceConflicts = cmdutil.GetForceConflictsFlag(cmd)
	if o.ForceConflicts && !o.ServerSideApply {
		return fmt.Errorf("--force-conflicts only works with --server-side")
	}

	if !o.ServerSideApply {
		o.OpenAPISchema, err = f.OpenAPISchema()
		if err != nil {
			return err
		}
	}

	o.DiscoveryClient, err = f.ToDiscoveryClient()
	if err != nil {
		return err
	}

	o.DynamicClient, err = f.DynamicClient()
	if err != nil {
		return err
	}

	o.DryRunVerifier = resource.NewDryRunVerifier(o.DynamicClient, o.DiscoveryClient)

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

	o.Builder = f.NewBuilder()
	return nil
}

// RunDiff uses the factory to parse file arguments, find the version to
// diff, and find each Info object for each files, and runs against the
// differ.
func (o *DiffOptions) Run() error {
	differ, err := NewDiffer("LIVE", "MERGED")
	if err != nil {
		return err
	}
	defer differ.TearDown()

	printer := Printer{}

	r := o.Builder.
		Unstructured().
		NamespaceParam(o.CmdNamespace).DefaultNamespace().
		FilenameParam(o.EnforceNamespace, &o.FilenameOptions).
		LabelSelectorParam(o.Selector).
		Flatten().
		Do()
	if err := r.Err(); err != nil {
		return err
	}

	err = r.Visit(func(info *resource.Info, err error) error {
		if err != nil {
			return err
		}

		if err := o.DryRunVerifier.HasSupport(info.Mapping.GroupVersionKind); err != nil {
			return err
		}

		local := info.Object.DeepCopyObject()
		for i := 1; i <= maxRetries; i++ {
			if err = info.Get(); err != nil {
				if !errors.IsNotFound(err) {
					return err
				}
				info.Object = nil
			}

			force := i == maxRetries
			if force {
				klog.Warningf(
					"Object (%v: %v) keeps changing, diffing without lock",
					info.Object.GetObjectKind().GroupVersionKind(),
					info.Name,
				)
			}
			obj := InfoObject{
				LocalObj:        local,
				Info:            info,
				Encoder:         scheme.DefaultJSONEncoder(),
				OpenAPI:         o.OpenAPISchema,
				Force:           force,
				ServerSideApply: o.ServerSideApply,
				FieldManager:    o.FieldManager,
				ForceConflicts:  o.ForceConflicts,
				IOStreams:       o.Diff.IOStreams,
			}

			err = differ.Diff(obj, printer)
			if !isConflict(err) {
				break
			}
		}

		apply.WarnIfDeleting(info.Object, o.Diff.ErrOut)

		return err
	})
	if err != nil {
		return err
	}

	return differ.Run(o.Diff)
}
