package registry

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"path/filepath"
	"testing"
	"testing/iotest"
	"time"

	"github.com/creack/pty"
	"github.com/docker/cli/cli/config/configfile"
	configtypes "github.com/docker/cli/cli/config/types"
	"github.com/docker/cli/cli/streams"
	"github.com/docker/cli/internal/prompt"
	"github.com/docker/cli/internal/registry"
	"github.com/docker/cli/internal/test"
	registrytypes "github.com/moby/moby/api/types/registry"
	"github.com/moby/moby/client"
	"gotest.tools/v3/assert"
	is "gotest.tools/v3/assert/cmp"
)

const (
	unknownUser     = "userunknownError"
	errUnknownUser  = "UNKNOWN_ERR"
	expiredPassword = "I_M_EXPIRED"
	useToken        = "I_M_TOKEN"
)

type fakeClient struct {
	client.Client
}

func (*fakeClient) Info(context.Context, client.InfoOptions) (client.SystemInfoResult, error) {
	return client.SystemInfoResult{}, nil
}

func (*fakeClient) RegistryLogin(_ context.Context, options client.RegistryLoginOptions) (client.RegistryLoginResult, error) {
	if options.Password == expiredPassword {
		return client.RegistryLoginResult{}, errors.New("invalid Username or Password")
	}
	if options.Password == useToken {
		return client.RegistryLoginResult{
			Auth: registrytypes.AuthResponse{IdentityToken: options.Password},
		}, nil
	}
	if options.Username == unknownUser {
		return client.RegistryLoginResult{}, errors.New(errUnknownUser)
	}
	return client.RegistryLoginResult{}, nil
}

func TestLoginWithCredStoreCreds(t *testing.T) {
	testCases := []struct {
		inputAuthConfig registrytypes.AuthConfig
		expectedErr     string
		expectedMsg     string
		expectedErrMsg  string
	}{
		{
			inputAuthConfig: registrytypes.AuthConfig{},
		},
		{
			inputAuthConfig: registrytypes.AuthConfig{
				Username: unknownUser,
			},
			expectedErr:    errUnknownUser,
			expectedErrMsg: fmt.Sprintf("Login did not succeed, error: %s\n", errUnknownUser),
		},
	}
	ctx := context.Background()
	tmpDir := t.TempDir()
	cli := test.NewFakeCli(&fakeClient{})
	cli.SetConfigFile(configfile.New(filepath.Join(tmpDir, "config.json")))
	for _, tc := range testCases {
		_, err := loginWithStoredCredentials(ctx, cli, tc.inputAuthConfig)
		if tc.expectedErrMsg != "" {
			assert.Check(t, is.Error(err, tc.expectedErr))
		} else {
			assert.NilError(t, err)
		}
		assert.Check(t, is.Equal(tc.expectedMsg, cli.OutBuffer().String()))
		assert.Check(t, is.Contains(cli.ErrBuffer().String(), tc.expectedErrMsg))
		cli.ErrBuffer().Reset()
		cli.OutBuffer().Reset()
	}
}

func TestRunLogin(t *testing.T) {
	testCases := []struct {
		doc                 string
		priorCredentials    map[string]configtypes.AuthConfig
		stdIn               string
		input               loginOptions
		expectedCredentials map[string]configtypes.AuthConfig
		expectedErr         string
	}{
		{
			doc: "valid auth from store",
			priorCredentials: map[string]configtypes.AuthConfig{
				"reg1": {
					Username:      "my-username",
					Password:      "a-password",
					ServerAddress: "reg1",
				},
			},
			input: loginOptions{
				serverAddress: "reg1",
			},
			expectedCredentials: map[string]configtypes.AuthConfig{
				"reg1": {
					Username:      "my-username",
					Password:      "a-password",
					ServerAddress: "reg1",
				},
			},
		},
		{
			doc: "expired auth from store",
			priorCredentials: map[string]configtypes.AuthConfig{
				"reg1": {
					Username:      "my-username",
					Password:      expiredPassword,
					ServerAddress: "reg1",
				},
			},
			input: loginOptions{
				serverAddress: "reg1",
			},
			expectedErr: "error: cannot perform an interactive login from a non-TTY device",
		},
		{
			doc:              "store valid username and password",
			priorCredentials: map[string]configtypes.AuthConfig{},
			input: loginOptions{
				serverAddress: "reg1",
				user:          "my-username",
				password:      "p2",
			},
			expectedCredentials: map[string]configtypes.AuthConfig{
				"reg1": {
					Username:      "my-username",
					Password:      "p2",
					ServerAddress: "reg1",
				},
			},
		},
		{
			doc: "unknown user w/ prior credentials",
			priorCredentials: map[string]configtypes.AuthConfig{
				"reg1": {
					Username:      "my-username",
					Password:      "a-password",
					ServerAddress: "reg1",
				},
			},
			input: loginOptions{
				serverAddress: "reg1",
				user:          unknownUser,
				password:      "a-password",
			},
			expectedErr: errUnknownUser,
			expectedCredentials: map[string]configtypes.AuthConfig{
				"reg1": {
					Username:      "a-password",
					Password:      "a-password",
					ServerAddress: "reg1",
				},
			},
		},
		{
			doc:              "unknown user w/o prior credentials",
			priorCredentials: map[string]configtypes.AuthConfig{},
			input: loginOptions{
				serverAddress: "reg1",
				user:          unknownUser,
				password:      "a-password",
			},
			expectedErr:         errUnknownUser,
			expectedCredentials: map[string]configtypes.AuthConfig{},
		},
		{
			doc:              "store valid token",
			priorCredentials: map[string]configtypes.AuthConfig{},
			input: loginOptions{
				serverAddress: "reg1",
				user:          "my-username",
				password:      useToken,
			},
			expectedCredentials: map[string]configtypes.AuthConfig{
				"reg1": {
					Username:      "my-username",
					IdentityToken: useToken,
					ServerAddress: "reg1",
				},
			},
		},
		{
			doc: "valid token from store",
			priorCredentials: map[string]configtypes.AuthConfig{
				"reg1": {
					Username:      "my-username",
					Password:      useToken,
					ServerAddress: "reg1",
				},
			},
			input: loginOptions{
				serverAddress: "reg1",
			},
			expectedCredentials: map[string]configtypes.AuthConfig{
				"reg1": {
					Username:      "my-username",
					IdentityToken: useToken,
					ServerAddress: "reg1",
				},
			},
		},
		{
			doc:              "no registry specified defaults to index server",
			priorCredentials: map[string]configtypes.AuthConfig{},
			input: loginOptions{
				user:     "my-username",
				password: "my-password",
			},
			expectedCredentials: map[string]configtypes.AuthConfig{
				registry.IndexServer: {
					Username:      "my-username",
					Password:      "my-password",
					ServerAddress: registry.IndexServer,
				},
			},
		},
		{
			doc:              "registry-1.docker.io",
			priorCredentials: map[string]configtypes.AuthConfig{},
			input: loginOptions{
				serverAddress: "registry-1.docker.io",
				user:          "my-username",
				password:      "my-password",
			},
			expectedCredentials: map[string]configtypes.AuthConfig{
				"registry-1.docker.io": {
					Username:      "my-username",
					Password:      "my-password",
					ServerAddress: "registry-1.docker.io",
				},
			},
		},
		// Regression test for https://github.com/docker/cli/issues/5382
		{
			doc:              "sanitizes server address to remove repo",
			priorCredentials: map[string]configtypes.AuthConfig{},
			input: loginOptions{
				serverAddress: "registry-1.docker.io/bork/test",
				user:          "my-username",
				password:      "a-password",
			},
			expectedCredentials: map[string]configtypes.AuthConfig{
				"registry-1.docker.io": {
					Username:      "my-username",
					Password:      "a-password",
					ServerAddress: "registry-1.docker.io",
				},
			},
		},
		// Regression test for https://github.com/docker/cli/issues/5382
		{
			doc: "updates credential if server address includes repo",
			priorCredentials: map[string]configtypes.AuthConfig{
				"registry-1.docker.io": {
					Username:      "my-username",
					Password:      "a-password",
					ServerAddress: "registry-1.docker.io",
				},
			},
			input: loginOptions{
				serverAddress: "registry-1.docker.io/bork/test",
				user:          "my-username",
				password:      "new-password",
			},
			expectedCredentials: map[string]configtypes.AuthConfig{
				"registry-1.docker.io": {
					Username:      "my-username",
					Password:      "new-password",
					ServerAddress: "registry-1.docker.io",
				},
			},
		},
		{
			doc:              "password stdin empty",
			priorCredentials: map[string]configtypes.AuthConfig{},
			input: loginOptions{
				serverAddress: "reg1",
				user:          "my-username",
				passwordStdin: true,
			},
			expectedErr: `password is empty`,
			expectedCredentials: map[string]configtypes.AuthConfig{
				"reg1": {
					Username:      "my-username",
					ServerAddress: "reg1",
				},
			},
		},
		{
			doc:              "password stdin read error",
			priorCredentials: map[string]configtypes.AuthConfig{},
			input: loginOptions{
				serverAddress: "reg1",
				user:          "my-username",
				passwordStdin: true,
			},
			expectedErr: `TEST_READ_ERR`,
			expectedCredentials: map[string]configtypes.AuthConfig{
				"reg1": {
					Username:      "my-username",
					ServerAddress: "reg1",
				},
			},
		},
		{
			doc:              "password stdin with line-endings",
			priorCredentials: map[string]configtypes.AuthConfig{},
			stdIn:            "my password\r\n",
			input: loginOptions{
				serverAddress: "reg1",
				user:          "my-username",
				passwordStdin: true,
			},
			expectedCredentials: map[string]configtypes.AuthConfig{
				"reg1": {
					Username:      "my-username",
					Password:      "my password",
					ServerAddress: "reg1",
				},
			},
		},
		{
			doc:              "password with leading and trailing spaces",
			priorCredentials: map[string]configtypes.AuthConfig{},
			input: loginOptions{
				serverAddress: "reg1",
				user:          "my-username",
				password:      "  my password with spaces  ",
			},
			expectedCredentials: map[string]configtypes.AuthConfig{
				"reg1": {
					Username:      "my-username",
					Password:      "  my password with spaces  ",
					ServerAddress: "reg1",
				},
			},
		},
		{
			doc:              "password stdin with line-endings",
			priorCredentials: map[string]configtypes.AuthConfig{},
			stdIn:            "  my password with spaces  \r\n",
			input: loginOptions{
				serverAddress: "reg1",
				user:          "my-username",
				passwordStdin: true,
			},
			expectedCredentials: map[string]configtypes.AuthConfig{
				"reg1": {
					Username:      "my-username",
					Password:      "  my password with spaces  ",
					ServerAddress: "reg1",
				},
			},
		},
		{
			doc:              "password stdin with multiple line-endings",
			priorCredentials: map[string]configtypes.AuthConfig{},
			stdIn:            "  my password\nwith spaces  \r\n\r\n",
			input: loginOptions{
				serverAddress: "reg1",
				user:          "my-username",
				passwordStdin: true,
			},
			expectedCredentials: map[string]configtypes.AuthConfig{
				"reg1": {
					Username:      "my-username",
					Password:      "  my password\nwith spaces  \r\n",
					ServerAddress: "reg1",
				},
			},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.doc, func(t *testing.T) {
			tmpDir := t.TempDir()
			cfg := configfile.New(filepath.Join(tmpDir, "config.json"))
			cli := test.NewFakeCli(&fakeClient{})
			cli.SetConfigFile(cfg)
			if tc.input.passwordStdin {
				if tc.expectedErr == "TEST_READ_ERR" {
					cli.SetIn(streams.NewIn(io.NopCloser(iotest.ErrReader(errors.New(tc.expectedErr)))))
				} else {
					cli.SetIn(streams.NewIn(io.NopCloser(bytes.NewBufferString(tc.stdIn))))
				}
			}

			for _, priorCred := range tc.priorCredentials {
				assert.NilError(t, cfg.GetCredentialsStore(priorCred.ServerAddress).Store(priorCred))
			}
			storedCreds, err := cfg.GetAllCredentials()
			assert.NilError(t, err)
			assert.DeepEqual(t, storedCreds, tc.priorCredentials)

			loginErr := runLogin(context.Background(), cli, tc.input)
			if tc.expectedErr != "" {
				assert.Error(t, loginErr, tc.expectedErr)
				return
			}
			assert.NilError(t, loginErr)

			outputCreds, err := cfg.GetAllCredentials()
			assert.Check(t, err)
			assert.DeepEqual(t, outputCreds, tc.expectedCredentials)
		})
	}
}

func TestLoginNonInteractive(t *testing.T) {
	t.Run("no prior credentials", func(t *testing.T) {
		testCases := []struct {
			doc         string
			username    bool
			password    bool
			expectedErr string
		}{
			{
				doc:      "success - w/ user w/ password",
				username: true,
				password: true,
			},
			{
				doc:         "error - w/o user w/o pass ",
				username:    false,
				password:    false,
				expectedErr: "error: cannot perform an interactive login from a non-TTY device",
			},
			{
				doc:         "error - w/ user w/o pass",
				username:    true,
				password:    false,
				expectedErr: "error: cannot perform an interactive login from a non-TTY device",
			},
			{
				doc:         "error - w/o user w/ pass",
				username:    false,
				password:    true,
				expectedErr: "error: cannot perform an interactive login from a non-TTY device",
			},
		}

		// "" meaning default registry
		registries := []string{"", "my-registry.com"}

		for _, registryAddr := range registries {
			for _, tc := range testCases {
				t.Run(tc.doc, func(t *testing.T) {
					tmpDir := t.TempDir()
					cfg := configfile.New(filepath.Join(tmpDir, "config.json"))
					cli := test.NewFakeCli(&fakeClient{})
					cli.SetConfigFile(cfg)
					options := loginOptions{
						serverAddress: registryAddr,
					}
					if tc.username {
						options.user = "my-username"
					}
					if tc.password {
						options.password = "my-password"
					}

					loginErr := runLogin(context.Background(), cli, options)
					if tc.expectedErr != "" {
						assert.Error(t, loginErr, tc.expectedErr)
						return
					}
					assert.NilError(t, loginErr)
				})
			}
		}
	})

	t.Run("w/ prior credentials", func(t *testing.T) {
		testCases := []struct {
			doc         string
			username    bool
			password    bool
			expectedErr string
		}{
			{
				doc:      "success - w/ user w/ password",
				username: true,
				password: true,
			},
			{
				doc:      "success - w/o user w/o pass ",
				username: false,
				password: false,
			},
			{
				doc:         "error - w/ user w/o pass",
				username:    true,
				password:    false,
				expectedErr: "error: cannot perform an interactive login from a non-TTY device",
			},
			{
				doc:         "error - w/o user w/ pass",
				username:    false,
				password:    true,
				expectedErr: "error: cannot perform an interactive login from a non-TTY device",
			},
		}

		// "" meaning default registry
		registries := []string{"", "my-registry.com"}

		for _, registryAddr := range registries {
			for _, tc := range testCases {
				t.Run(tc.doc, func(t *testing.T) {
					tmpDir := t.TempDir()
					cfg := configfile.New(filepath.Join(tmpDir, "config.json"))
					cli := test.NewFakeCli(&fakeClient{})
					cli.SetConfigFile(cfg)
					serverAddress := registryAddr
					if serverAddress == "" {
						serverAddress = "https://index.docker.io/v1/"
					}
					assert.NilError(t, cfg.GetCredentialsStore(serverAddress).Store(configtypes.AuthConfig{
						Username:      "my-username",
						Password:      "my-password",
						ServerAddress: serverAddress,
					}))

					options := loginOptions{
						serverAddress: registryAddr,
					}
					if tc.username {
						options.user = "my-username"
					}
					if tc.password {
						options.password = "my-password"
					}

					loginErr := runLogin(context.Background(), cli, options)
					if tc.expectedErr != "" {
						assert.Error(t, loginErr, tc.expectedErr)
						return
					}
					assert.NilError(t, loginErr)
				})
			}
		}
	})
}

func TestLoginTermination(t *testing.T) {
	p, tty, err := pty.Open()
	assert.NilError(t, err)

	t.Cleanup(func() {
		_ = tty.Close()
		_ = p.Close()
	})

	tmpDir := t.TempDir()
	cfg := configfile.New(filepath.Join(tmpDir, "config.json"))
	cli := test.NewFakeCli(&fakeClient{}, func(fc *test.FakeCli) {
		fc.SetOut(streams.NewOut(tty))
		fc.SetIn(streams.NewIn(tty))
	})
	cli.SetConfigFile(cfg)

	ctx, cancel := context.WithCancel(t.Context())
	t.Cleanup(cancel)

	runErr := make(chan error)
	go func() {
		runErr <- runLogin(ctx, cli, loginOptions{
			user: "test-user",
		})
	}()

	// Let the prompt get canceled by the context
	cancel()

	select {
	case <-time.After(1 * time.Second):
		t.Fatal("timed out after 1 second. `runLogin` did not return")
	case err := <-runErr:
		assert.ErrorIs(t, err, prompt.ErrTerminated)
	}
}

func TestLoginValidateFlags(t *testing.T) {
	for _, tc := range []struct {
		name        string
		args        []string
		expectedErr string
	}{
		{
			name:        "--password-stdin without --username",
			args:        []string{"--password-stdin"},
			expectedErr: `the --password-stdin option requires --username to be set`,
		},
		{
			name:        "--password-stdin with empty --username",
			args:        []string{"--password-stdin", "--username", ""},
			expectedErr: `username is empty`,
		},
		{
			name:        "empty --username",
			args:        []string{"--username", ""},
			expectedErr: `username is empty`,
		},
		{
			name:        "--username without value",
			args:        []string{"--username"},
			expectedErr: `flag needs an argument: --username`,
		},
		{
			name:        "conflicting options --password-stdin and --password",
			args:        []string{"--password-stdin", "--password", ""},
			expectedErr: `conflicting options: cannot specify both --password and --password-stdin`,
		},
		{
			name:        "empty --password",
			args:        []string{"--password", ""},
			expectedErr: `password is empty`,
		},
		{
			name:        "--password without value",
			args:        []string{"--password"},
			expectedErr: `flag needs an argument: --password`,
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			cmd := newLoginCommand(test.NewFakeCli(&fakeClient{}))
			cmd.SetOut(io.Discard)
			cmd.SetErr(io.Discard)
			cmd.SetArgs(tc.args)

			err := cmd.Execute()
			if tc.expectedErr != "" {
				assert.Check(t, is.ErrorContains(err, tc.expectedErr))
			} else {
				assert.Check(t, is.Nil(err))
			}
		})
	}
}
