/*
Copyright 2018 The Doctl Authors All rights reserved.
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 commands

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"github.com/digitalocean/doctl"
	"github.com/digitalocean/doctl/commands/charm/input"
	"github.com/digitalocean/doctl/commands/charm/template"

	"github.com/spf13/cobra"
	"github.com/spf13/viper"
	"golang.org/x/term"
	yaml "gopkg.in/yaml.v2"
)

const (
	// TokenValidationServer is the default server used to validate an OAuth token
	TokenValidationServer = "https://cloud.digitalocean.com"

	legacyTokenLength = 64
	v1TokenLength     = 71
)

// ErrUnknownTerminal signifies an unknown terminal. It is returned when doit
// can't ascertain the current terminal type with requesting an auth token.
var (
	ErrUnknownTerminal = errors.New("unknown terminal")
	cfgFileWriter      = defaultConfigFileWriter // create default cfgFileWriter
)

// retrieveUserTokenFromCommandLine is a function that can retrieve a token. By default,
// it will prompt the user. In test, you can replace this with code that returns the appropriate response.
func retrieveUserTokenFromCommandLine() (string, error) {
	if !term.IsTerminal(int(os.Stdout.Fd())) {
		return "", ErrUnknownTerminal
	}

	template.Print(
		`Please authenticate doctl for use with your DigitalOcean account. You can generate a token in the control panel at {{underline "https://cloud.digitalocean.com/account/api/tokens"}}{{nl}}{{nl}}`,
		nil,
	)

	prompt := input.New("Enter your access token: ",
		input.WithHidden(),
		input.WithRequired(),
		input.WithValidator(tokenInputValidator),
	)

	return prompt.Prompt()
}

// tokenInputValidator is used to do basic validation of a token while it is being typed.
func tokenInputValidator(input string) error {
	if len(input) == legacyTokenLength || (strings.HasPrefix(input, "do") && len(input) == v1TokenLength) {
		return nil
	}
	return errors.New("")
}

// UnknownSchemeError signifies an unknown HTTP scheme.
type UnknownSchemeError struct {
	Scheme string
}

var _ error = &UnknownSchemeError{}

func (use *UnknownSchemeError) Error() string {
	return "Unknown scheme: " + use.Scheme
}

// Auth creates auth commands for doctl.
func Auth() *Command {
	cmd := &Command{
		Command: &cobra.Command{
			Use:   "auth",
			Short: "Display commands for authenticating doctl with an account",
			Long: `The ` + "`" + `doctl auth` + "`" + ` commands allow you to authenticate doctl for use with your DigitalOcean account using tokens that you generate in the control panel at https://cloud.digitalocean.com/account/api/tokens.

If you work with a just one account, call ` + "`" + `doctl auth init` + "`" + ` and supply the token when prompted. This creates an authentication context named ` + "`" + `default` + "`" + `.

To switch between multiple DigitalOcean accounts, including team accounts, create named contexts using ` + "`" + `doctl auth init --context <name>` + "`" + `, then providing the applicable token when prompted. This saves the token under the name you provide. To switch between contexts, use ` + "`" + `doctl auth switch --context <name>` + "`" + `.

To remove accounts from the configuration file, run ` + "`" + `doctl auth remove --context <name>` + "`" + `. This removes the token under the name you provide.`,
			GroupID: configureDoctlGroup,
		},
	}

	cmdAuthInit := cmdBuilderWithInit(cmd, RunAuthInit(retrieveUserTokenFromCommandLine), "init", "Initialize doctl to use a specific account", `This command allows you to initialize doctl with a token that allows it to query and manage your account details and resources.

The command requires and API token to authenticate, which you can generate in the control panel at https://cloud.digitalocean.com/account/api/tokens.

The `+"`"+`--context`+"`"+` flag allows you to add authentication for multiple accounts and then switch between them as needed. Provide a case-sensitive name for the context and then enter the API token you want use for that context when prompted. You can switch authentication contexts using `+"`"+`doctl auth switch`+"`"+`, which re-initializes doctl. You can also provide the `+"`"+`--context`+"`"+` flag when using any doctl command to specify the auth context for that command. This enables you to use multiple DigitalOcean accounts with doctl, or tokens that have different authentication scopes.

If the `+"`"+`--context`+"`"+` flag is not specified, doctl creates a default authentication context named `+"`"+`default`+"`"+`.

You can use doctl without initializing it by adding the `+"`"+`--access-token`+"`"+` flag to each command and providing an API token as the argument.`, Writer, false)
	AddStringFlag(cmdAuthInit, doctl.ArgTokenValidationServer, "", TokenValidationServer, "The server used to validate a token")
	cmdAuthInit.Example = `The following example initializes doctl with a token for a single account with the context ` + "`" + `your-team` + "`" + `: doctl auth init --context your-team`

	cmdAuthSwitch := cmdBuilderWithInit(cmd, RunAuthSwitch, "switch", "Switch between authentication contexts", `This command allows you to switch between authentication contexts you've already created.

To see a list of available authentication contexts, call `+"`"+`doctl auth list`+"`"+`.

For details on creating an authentication context, see the help for `+"`"+`doctl auth init`+"`"+`.`, Writer, false)
	cmdAuthSwitch.AddValidArgsFunc(authContextListValidArgsFunc)
	cmdAuthSwitch.Example = `The following example switches to the context ` + "`" + `your-team` + "`" + `: doctl auth switch --context your-team`

	cmdAuthRemove := cmdBuilderWithInit(cmd, RunAuthRemove, "remove --context <name>", "Remove authentication contexts ", `This command allows you to remove authentication contexts you've already created.

To see a list of available authentication contexts, call `+"`"+`doctl auth list`+"`"+`.

For details on creating an authentication context, see the help for `+"`"+`doctl auth init`+"`"+`.`, Writer, false)
	cmdAuthRemove.AddValidArgsFunc(authContextListValidArgsFunc)
	cmdAuthRemove.Example = `The following example removes the context ` + "`" + `your-team` + "`" + `: doctl auth remove --context your-team`

	cmdAuthList := cmdBuilderWithInit(cmd, RunAuthList, "list", "List available authentication contexts", `List named authentication contexts that you created with `+"`"+`doctl auth init`+"`"+`.

To switch between the contexts use `+"`"+`doctl auth switch --context <name>`+"`"+`, where `+"`"+`<name>`+"`"+` is one of the contexts listed.

To create new contexts, see the help for `+"`"+`doctl auth init`+"`"+`.`, Writer, false, aliasOpt("ls"))
	// The command runner expects that any command named "list" accepts a
	// format flag, so we include here despite only supporting text output for
	// this command.
	AddStringFlag(cmdAuthList, doctl.ArgFormat, "", "", "Columns for output in a comma-separated list. Possible values: `text`")
	cmdAuthList.Example = `The following example lists the available contexts with the ` + "`" + `--format` + "`" + ` flag: doctl auth list`

	cmdAuthToken := cmdBuilderWithInit(cmd, RunAuthToken, "token", "Display current authentication context API token", `Display the current authentication context's token that you created with `+"`"+`doctl auth init`+"`"+`.

To switch between the contexts use `+"`"+`doctl auth switch --context <name>`+"`"+`, where `+"`"+`<name>`+"`"+` is one of the contexts from: `+"`"+`doctl auth list`+"`"+`

To create new contexts, see the help for `+"`"+`doctl auth init`+"`"+`.`, Writer, false, aliasOpt("t"))
	cmdAuthToken.Example = `The following example displays the token of the current context: doctl auth token`

	return cmd
}

// RunAuthInit initializes the doctl config. Configuration is stored in $XDG_CONFIG_HOME/doctl. On Unix, if
// XDG_CONFIG_HOME is not set, use $HOME/.config. On Windows use %APPDATA%/doctl/config.
func RunAuthInit(retrieveUserTokenFunc func() (string, error)) func(c *CmdConfig) error {
	return func(c *CmdConfig) error {
		token := c.getContextAccessToken()
		context := strings.ToLower(Context)
		if context == "" {
			context = strings.ToLower(viper.GetString("context"))
		}

		if token == "" {
			in, err := retrieveUserTokenFunc()
			if err != nil {
				return fmt.Errorf("Unable to read DigitalOcean access token: %s", err)
			}
			token = strings.TrimSpace(in)
		} else {
			template.Render(c.Out, `Using token for context {{highlight .}}{{nl}}`, context)
		}

		c.setContextAccessToken(token)

		template.Render(c.Out, `{{nl}}Validating token... `, nil)

		// need to initial the godo client since we've changed the configuration.
		if err := c.initServices(c); err != nil {
			return fmt.Errorf("Unable to initialize DigitalOcean API client with new token: %s", err)
		}

		server, err := c.Doit.GetString(c.NS, doctl.ArgTokenValidationServer)
		if err != nil {
			return err
		}

		if _, err := c.OAuth().TokenInfo(server); err != nil {
			template.Render(c.Out, `{{error crossmark}}{{nl}}{{nl}}`, nil)
			return fmt.Errorf("Unable to use supplied token to access API: %s", err)
		}

		template.Render(c.Out, `{{success checkmark}}{{nl}}{{nl}}`, nil)

		return writeConfig()
	}
}

// RunAuthRemove remove available auth contexts from the user's doctl config.
func RunAuthRemove(c *CmdConfig) error {
	context := strings.ToLower(Context)

	if context == "" {
		return fmt.Errorf("You must provide a context name")
	}

	err := c.removeContext(context)

	if err != nil {
		return fmt.Errorf("Context not found")
	}

	fmt.Println("Context deleted successfully")

	return writeConfig()
}

// RunAuthList lists all available auth contexts from the user's doctl config.
func RunAuthList(c *CmdConfig) error {
	context := Context
	if context == "" {
		context = viper.GetString("context")
	}
	contexts := viper.GetStringMap("auth-contexts")

	if viper.GetString("output") == "json" {
		displayAuthContextsJSON(c.Out, context, contexts)
	} else {
		displayAuthContexts(c.Out, context, contexts)
	}

	return nil
}

// RunAuthToken displays the current context token from the user's doctl config.
func RunAuthToken(c *CmdConfig) error {
	accessToken := c.getContextAccessToken()
	fmt.Println(accessToken)
	return nil
}

func ensureDefaultContextAndKeysOrder(contexts map[string]any) []string {
	// Because the default context isn't present on the auth-contexts field,
	// we add it manually so that it's always included in the output, and so
	// we can check if it's the current context.
	contexts[doctl.ArgDefaultContext] = true

	// Extract and sort the map keys so that the order that we display the
	// auth contexts is consistent.
	keys := make([]string, 0)
	for ctx := range contexts {
		keys = append(keys, ctx)
	}
	sort.Strings(keys)

	return keys
}

func displayAuthContextsJSON(out io.Writer, currentContext string, contexts map[string]any) {
	type contextJSON struct {
		Name    string `json:"name"`
		Current bool   `json:"current"`
	}

	var contextsJSON []contextJSON

	keys := ensureDefaultContextAndKeysOrder(contexts)
	for _, ctx := range keys {
		contextsJSON = append(contextsJSON, contextJSON{
			Name:    ctx,
			Current: ctx == currentContext,
		})
	}

	jsonData, _ := json.MarshalIndent(contextsJSON, "", "  ")
	fmt.Fprintln(out, string(jsonData))
}

func displayAuthContexts(out io.Writer, currentContext string, contexts map[string]any) {
	keys := ensureDefaultContextAndKeysOrder(contexts)
	for _, ctx := range keys {
		if ctx == currentContext {
			fmt.Fprintln(out, ctx, "(current)")
			continue
		}
		fmt.Fprintln(out, ctx)
	}
}

// RunAuthSwitch changes the default context and writes it to the
// configuration.
func RunAuthSwitch(c *CmdConfig) error {
	context := strings.ToLower(Context)
	if context == "" {
		context = strings.ToLower(viper.GetString("context"))
	}

	// check that context exists
	contextsAvail := viper.GetStringMap("auth-contexts")
	contextsAvail[doctl.ArgDefaultContext] = true
	keys := make([]string, 0)
	for ctx := range contextsAvail {
		keys = append(keys, ctx)
	}

	var contextExists bool
	for _, ctx := range keys {
		if ctx == context {
			contextExists = true
		}
	}

	if !contextExists {
		return errors.New("context does not exist")
	}

	// The two lines below aren't required for doctl specific functionality,
	// but somehow magically fixes an issue
	// (https://github.com/digitalocean/doctl/issues/996) where auth-contexts
	// are mangled when running this command.
	contexts := viper.GetStringMapString("auth-contexts")
	viper.Set("auth-contexts", contexts)

	viper.Set("context", context)

	fmt.Printf("Now using context [%s] by default\n", context)
	return writeConfig()
}

func writeConfig() error {
	f, err := cfgFileWriter()
	if err != nil {
		return err
	}

	defer f.Close()

	b, err := yaml.Marshal(viper.AllSettings())
	if err != nil {
		return errors.New("Unable to encode configuration to YAML format.")
	}

	_, err = f.Write(b)
	if err != nil {
		return errors.New("Unable to write configuration.")
	}

	return nil
}

// defaultConfigFileWriter returns a writer to a newly created config.yaml file in the default config home.
// When using the default config file path the default config home directory will be created; Otherwise
// the custom config home directory must exist and be writable to the user issuing the auth command.
func defaultConfigFileWriter() (io.WriteCloser, error) {
	cfgFile := viper.GetString("config")

	defaultCfgFile := filepath.Join(defaultConfigHome(), defaultConfigName)
	if cfgFile == defaultCfgFile {
		configHome()
	}

	f, err := os.Create(cfgFile)
	if err != nil {
		return nil, err
	}
	if err := os.Chmod(cfgFile, 0600); err != nil {
		return nil, err
	}

	return f, nil
}

func getAuthContextList() []string {
	contexts := []string{"default"}
	cfgContexts := viper.GetStringMap("auth-contexts")

	for k := range cfgContexts {
		contexts = append(contexts, k)
	}

	return contexts
}

func authContextListValidArgsFunc(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
	if len(args) != 0 {
		return nil, cobra.ShellCompDirectiveNoFileComp
	}
	return getAuthContextList(), cobra.ShellCompDirectiveNoFileComp
}
