/*
Copyright The Helm 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 cmd

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"os"
	"path/filepath"
	"slices"
	"strconv"
	"strings"

	"helm.sh/helm/v4/internal/plugin/schema"

	"github.com/spf13/cobra"
	"sigs.k8s.io/yaml"

	"helm.sh/helm/v4/internal/plugin"
)

// TODO: move pluginDynamicCompletionExecutable pkg/plugin/runtime_subprocess.go
// any references to executables should be for [plugin.SubprocessPluginRuntime] only
// this should also be for backwards compatibility in [plugin.Legacy] only
//
// TODO: for v1 make this configurable with a new CompletionCommand field for
// [plugin.RuntimeConfigSubprocess]
const (
	pluginStaticCompletionFile        = "completion.yaml"
	pluginDynamicCompletionExecutable = "plugin.complete"
)

// loadCLIPlugins loads CLI plugins into the command list.
//
// This follows a different pattern than the other commands because it has
// to inspect its environment and then add commands to the base command
// as it finds them.
func loadCLIPlugins(baseCmd *cobra.Command, out io.Writer) {
	// If HELM_NO_PLUGINS is set to 1, do not load plugins.
	if os.Getenv("HELM_NO_PLUGINS") == "1" {
		return
	}

	dirs := filepath.SplitList(settings.PluginsDirectory)
	descriptor := plugin.Descriptor{
		Type: "cli/v1",
	}
	found, err := plugin.FindPlugins(dirs, descriptor)
	if err != nil {
		slog.Error("failed to load plugins", slog.String("error", err.Error()))
		return
	}

	// Now we create commands for all of these.
	for _, plug := range found {
		var use, short, long string
		var ignoreFlags bool
		if cliConfig, ok := plug.Metadata().Config.(*schema.ConfigCLIV1); ok {
			use = cliConfig.Usage
			short = cliConfig.ShortHelp
			long = cliConfig.LongHelp
			ignoreFlags = cliConfig.IgnoreFlags
		}

		// Set defaults
		if use == "" {
			use = plug.Metadata().Name
		}
		if short == "" {
			short = fmt.Sprintf("the %q plugin", plug.Metadata().Name)
		}
		// long has no default, empty is ok

		c := &cobra.Command{
			Use:   use,
			Short: short,
			Long:  long,
			RunE: func(cmd *cobra.Command, args []string) error {
				u, err := processParent(cmd, args)
				if err != nil {
					return err
				}

				// For CLI plugin types runtime, set extra args and settings
				extraArgs := []string{}
				if !ignoreFlags {
					extraArgs = u
				}

				// Prepare environment
				env := os.Environ()
				for k, v := range settings.EnvVars() {
					env = append(env, fmt.Sprintf("%s=%s", k, v))
				}

				// Invoke plugin
				input := &plugin.Input{
					Message: schema.InputMessageCLIV1{
						ExtraArgs: extraArgs,
					},
					Env:    env,
					Stdin:  os.Stdin,
					Stdout: out,
					Stderr: os.Stderr,
				}
				_, err = plug.Invoke(context.Background(), input)
				execErr := &plugin.InvokeExecError{}
				if errors.As(err, &execErr) {
					return CommandError{
						error:    execErr.Err,
						ExitCode: execErr.ExitCode,
					}
				}
				return err
			},
			// This passes all the flags to the subcommand.
			DisableFlagParsing: true,
		}

		for _, cmd := range baseCmd.Commands() {
			if cmd.Name() == c.Name() {
				slog.Error("failed to load plugins: name conflicts", slog.String("name", c.Name()))
				return
			}
		}

		baseCmd.AddCommand(c)

		// For completion, we try to load more details about the plugins so as to allow for command and
		// flag completion of the plugin itself.
		// We only do this when necessary (for the "completion" and "__complete" commands) to avoid the
		// risk of a rogue plugin affecting Helm's normal behavior.
		subCmd, _, err := baseCmd.Find(os.Args[1:])
		if (err == nil &&
			((subCmd.HasParent() && subCmd.Parent().Name() == "completion") || subCmd.Name() == cobra.ShellCompRequestCmd)) ||
			/* for the tests */ subCmd == baseCmd.Root() {
			loadCompletionForPlugin(c, plug)
		}
	}
}

func processParent(cmd *cobra.Command, args []string) ([]string, error) {
	k, u := manuallyProcessArgs(args)
	if err := cmd.Parent().ParseFlags(k); err != nil {
		return nil, err
	}
	return u, nil
}

// manuallyProcessArgs processes an arg array, removing special args.
//
// Returns two sets of args: known and unknown (in that order)
func manuallyProcessArgs(args []string) ([]string, []string) {
	known := []string{}
	unknown := []string{}
	kvargs := []string{"--kube-context", "--namespace", "-n", "--kubeconfig", "--kube-apiserver", "--kube-token", "--kube-as-user", "--kube-as-group", "--kube-ca-file", "--registry-config", "--repository-cache", "--repository-config", "--kube-insecure-skip-tls-verify", "--kube-tls-server-name"}
	knownArg := func(a string) bool {
		for _, pre := range kvargs {
			if strings.HasPrefix(a, pre+"=") {
				return true
			}
		}
		return false
	}

	isKnown := func(v string) string {
		if slices.Contains(kvargs, v) {
			return v
		}
		return ""
	}

	for i := 0; i < len(args); i++ {
		switch a := args[i]; a {
		case "--debug":
			known = append(known, a)
		case isKnown(a):
			known = append(known, a)
			i++
			if i < len(args) {
				known = append(known, args[i])
			}
		default:
			if knownArg(a) {
				known = append(known, a)
				continue
			}
			unknown = append(unknown, a)
		}
	}
	return known, unknown
}

// pluginCommand represents the optional completion.yaml file of a plugin
type pluginCommand struct {
	Name      string          `json:"name"`
	ValidArgs []string        `json:"validArgs"`
	Flags     []string        `json:"flags"`
	Commands  []pluginCommand `json:"commands"`
}

// loadCompletionForPlugin will load and parse any completion.yaml provided by the plugin
// and add the dynamic completion hook to call the optional plugin.complete
func loadCompletionForPlugin(pluginCmd *cobra.Command, plug plugin.Plugin) {
	// Parse the yaml file providing the plugin's sub-commands and flags
	cmds, err := loadFile(strings.Join(
		[]string{plug.Dir(), pluginStaticCompletionFile}, string(filepath.Separator)))

	if err != nil {
		// The file could be missing or invalid.  No static completion for this plugin.
		slog.Debug("plugin completion file loading", slog.String("error", err.Error()))
		// Continue to setup dynamic completion.
		cmds = &pluginCommand{}
	}

	// Preserve the Usage string specified for the plugin
	cmds.Name = pluginCmd.Use

	addPluginCommands(plug, pluginCmd, cmds)
}

// addPluginCommands is a recursive method that adds each different level
// of sub-commands and flags for the plugins that have provided such information
func addPluginCommands(plug plugin.Plugin, baseCmd *cobra.Command, cmds *pluginCommand) {
	if cmds == nil {
		return
	}

	if len(cmds.Name) == 0 {
		slog.Debug("sub-command name field missing", slog.String("commandPath", baseCmd.CommandPath()))
		return
	}

	baseCmd.Use = cmds.Name
	baseCmd.ValidArgs = cmds.ValidArgs
	// Setup the same dynamic completion for each plugin sub-command.
	// This is because if dynamic completion is triggered, there is a single executable
	// to call (plugin.complete), so every sub-commands calls it in the same fashion.
	if cmds.Commands == nil {
		// Only setup dynamic completion if there are no sub-commands.  This avoids
		// calling plugin.complete at every completion, which greatly simplifies
		// development of plugin.complete for plugin developers.
		baseCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
			return pluginDynamicComp(plug, cmd, args, toComplete)
		}
	}

	// Create fake flags.
	if len(cmds.Flags) > 0 {
		// The flags can be created with any type, since we only need them for completion.
		// pflag does not allow to create short flags without a corresponding long form
		// so we look for all short flags and match them to any long flag.  This will allow
		// plugins to provide short flags without a long form.
		// If there are more short-flags than long ones, we'll create an extra long flag with
		// the same single letter as the short form.
		shorts := []string{}
		longs := []string{}
		for _, flag := range cmds.Flags {
			if len(flag) == 1 {
				shorts = append(shorts, flag)
			} else {
				longs = append(longs, flag)
			}
		}

		f := baseCmd.Flags()
		if len(longs) >= len(shorts) {
			for i := range longs {
				if i < len(shorts) {
					f.BoolP(longs[i], shorts[i], false, "")
				} else {
					f.Bool(longs[i], false, "")
				}
			}
		} else {
			for i := range shorts {
				if i < len(longs) {
					f.BoolP(longs[i], shorts[i], false, "")
				} else {
					// Create a long flag with the same name as the short flag.
					// Not a perfect solution, but it's better than ignoring the extra short flags.
					f.BoolP(shorts[i], shorts[i], false, "")
				}
			}
		}
	}

	// Recursively add any sub-commands
	for _, cmd := range cmds.Commands {
		// Create a fake command so that completion can be done for the sub-commands of the plugin
		subCmd := &cobra.Command{
			// This prevents Cobra from removing the flags.  We want to keep the flags to pass them
			// to the dynamic completion script of the plugin.
			DisableFlagParsing: true,
			// A Run is required for it to be a valid command without subcommands
			Run: func(_ *cobra.Command, _ []string) {},
		}
		baseCmd.AddCommand(subCmd)
		addPluginCommands(plug, subCmd, &cmd)
	}
}

// loadFile takes a yaml file at the given path, parses it and returns a pluginCommand object
func loadFile(path string) (*pluginCommand, error) {
	cmds := new(pluginCommand)
	b, err := os.ReadFile(path)
	if err != nil {
		return cmds, fmt.Errorf("file (%s) not provided by plugin. No plugin auto-completion possible", path)
	}

	err = yaml.Unmarshal(b, cmds)
	return cmds, err
}

// pluginDynamicComp call the plugin.complete script of the plugin (if available)
// to obtain the dynamic completion choices.  It must pass all the flags and sub-commands
// specified in the command-line to the plugin.complete executable (except helm's global flags)
func pluginDynamicComp(plug plugin.Plugin, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {

	subprocessPlug, ok := plug.(*plugin.SubprocessPluginRuntime)
	if !ok {
		// Completion only supported for subprocess plugins (TODO: fix this)
		cobra.CompDebugln(fmt.Sprintf("Unsupported plugin runtime: %q", plug.Metadata().Runtime), settings.Debug)
		return nil, cobra.ShellCompDirectiveDefault
	}

	var ignoreFlags bool
	if cliConfig, ok := subprocessPlug.Metadata().Config.(*schema.ConfigCLIV1); ok {
		ignoreFlags = cliConfig.IgnoreFlags
	}

	u, err := processParent(cmd, args)
	if err != nil {
		return nil, cobra.ShellCompDirectiveError
	}

	// We will call the dynamic completion script of the plugin
	main := strings.Join([]string{plug.Dir(), pluginDynamicCompletionExecutable}, string(filepath.Separator))

	// We must include all sub-commands passed on the command-line.
	// To do that, we pass-in the entire CommandPath, except the first two elements
	// which are 'helm' and 'pluginName'.
	argv := strings.Split(cmd.CommandPath(), " ")[2:]
	if !ignoreFlags {
		argv = append(argv, u...)
		argv = append(argv, toComplete)
	}

	cobra.CompDebugln(fmt.Sprintf("calling %s with args %v", main, argv), settings.Debug)
	buf := new(bytes.Buffer)

	// Prepare environment
	env := os.Environ()
	for k, v := range settings.EnvVars() {
		env = append(env, fmt.Sprintf("%s=%s", k, v))
	}

	// For subprocess runtime, use InvokeWithEnv for dynamic completion
	if err := subprocessPlug.InvokeWithEnv(main, argv, env, nil, buf, buf); err != nil {
		// The dynamic completion file is optional for a plugin, so this error is ok.
		cobra.CompDebugln(fmt.Sprintf("Unable to call %s: %v", main, err.Error()), settings.Debug)
		return nil, cobra.ShellCompDirectiveDefault
	}

	var completions []string
	for comp := range strings.SplitSeq(buf.String(), "\n") {
		// Remove any empty lines
		if len(comp) > 0 {
			completions = append(completions, comp)
		}
	}

	// Check if the last line of output is of the form :<integer>, which
	// indicates the BashCompletionDirective.
	directive := cobra.ShellCompDirectiveDefault
	if len(completions) > 0 {
		lastLine := completions[len(completions)-1]
		if len(lastLine) > 1 && lastLine[0] == ':' {
			if strInt, err := strconv.Atoi(lastLine[1:]); err == nil {
				directive = cobra.ShellCompDirective(strInt)
				completions = completions[:len(completions)-1]
			}
		}
	}

	return completions, directive
}
