package config

import (
	"bytes"
	"fmt"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"

	"github.com/docker/cli/cli/config/configfile"
	"github.com/docker/cli/cli/config/credentials"
	"gotest.tools/v3/assert"
	is "gotest.tools/v3/assert/cmp"
	"gotest.tools/v3/skip"
)

func setupConfigDir(t *testing.T) string {
	t.Helper()
	tmpdir := t.TempDir()
	oldDir := Dir()
	SetDir(tmpdir)
	t.Cleanup(func() {
		SetDir(oldDir)
	})
	return tmpdir
}

func TestEmptyConfigDir(t *testing.T) {
	tmpHome := setupConfigDir(t)

	config, err := Load("")
	assert.NilError(t, err)

	expectedConfigFilename := filepath.Join(tmpHome, ConfigFileName)
	assert.Check(t, is.Equal(expectedConfigFilename, config.Filename))

	// Now save it and make sure it shows up in new form
	saveConfigAndValidateNewFormat(t, config, tmpHome)
}

func TestMissingFile(t *testing.T) {
	tmpHome := t.TempDir()

	config, err := Load(tmpHome)
	assert.NilError(t, err)

	// Now save it and make sure it shows up in new form
	saveConfigAndValidateNewFormat(t, config, tmpHome)
}

// TestLoadDanglingSymlink verifies that we gracefully handle dangling symlinks.
//
// TODO(thaJeztah): consider whether we want dangling symlinks to be an error condition instead.
func TestLoadDanglingSymlink(t *testing.T) {
	cfgDir := t.TempDir()
	cfgFile := filepath.Join(cfgDir, ConfigFileName)
	err := os.Symlink(filepath.Join(cfgDir, "no-such-file"), cfgFile)
	assert.NilError(t, err)

	config, err := Load(cfgDir)
	assert.NilError(t, err)

	// Now save it and make sure it shows up in new form
	saveConfigAndValidateNewFormat(t, config, cfgDir)

	// Make sure we kept the symlink.
	fi, err := os.Lstat(cfgFile)
	assert.NilError(t, err)
	assert.Equal(t, fi.Mode()&os.ModeSymlink, os.ModeSymlink, "expected %v to be a symlink", cfgFile)
}

func TestLoadNoPermissions(t *testing.T) {
	if runtime.GOOS != "windows" {
		skip.If(t, os.Getuid() == 0, "cannot test permission denied when running as root")
	}
	cfgDir := t.TempDir()
	cfgFile := filepath.Join(cfgDir, ConfigFileName)
	err := os.WriteFile(cfgFile, []byte(`{}`), os.FileMode(0o000))
	assert.NilError(t, err)

	_, err = Load(cfgDir)
	assert.ErrorIs(t, err, os.ErrPermission)
}

func TestSaveFileToDirs(t *testing.T) {
	tmpHome := filepath.Join(t.TempDir(), ".docker")
	config, err := Load(tmpHome)
	assert.NilError(t, err)

	// Now save it and make sure it shows up in new form
	saveConfigAndValidateNewFormat(t, config, tmpHome)
}

func TestEmptyFile(t *testing.T) {
	tmpHome := t.TempDir()

	fn := filepath.Join(tmpHome, ConfigFileName)
	err := os.WriteFile(fn, []byte(""), 0o600)
	assert.NilError(t, err)

	_, err = Load(tmpHome)
	assert.NilError(t, err)
}

func TestEmptyJSON(t *testing.T) {
	tmpHome := t.TempDir()

	fn := filepath.Join(tmpHome, ConfigFileName)
	err := os.WriteFile(fn, []byte("{}"), 0o600)
	assert.NilError(t, err)

	config, err := Load(tmpHome)
	assert.NilError(t, err)

	// Now save it and make sure it shows up in new form
	saveConfigAndValidateNewFormat(t, config, tmpHome)
}

func TestMalformedJSON(t *testing.T) {
	tmpHome := t.TempDir()

	fn := filepath.Join(tmpHome, ConfigFileName)
	err := os.WriteFile(fn, []byte("{"), 0o600)
	assert.NilError(t, err)

	_, err = Load(tmpHome)
	assert.Check(t, is.ErrorContains(err, fmt.Sprintf(`parsing config file (%s):`, fn)))
}

func TestNewJSON(t *testing.T) {
	tmpHome := t.TempDir()

	fn := filepath.Join(tmpHome, ConfigFileName)
	js := ` { "auths": { "https://index.docker.io/v1/": { "auth": "am9lam9lOmhlbGxv" } } }`
	if err := os.WriteFile(fn, []byte(js), 0o600); err != nil {
		t.Fatal(err)
	}

	config, err := Load(tmpHome)
	assert.NilError(t, err)

	ac := config.AuthConfigs["https://index.docker.io/v1/"]
	assert.Equal(t, ac.Username, "joejoe")
	assert.Equal(t, ac.Password, "hello")

	// Now save it and make sure it shows up in new form
	configStr := saveConfigAndValidateNewFormat(t, config, tmpHome)

	expConfStr := `{
	"auths": {
		"https://index.docker.io/v1/": {
			"auth": "am9lam9lOmhlbGxv"
		}
	}
}`

	if configStr != expConfStr {
		t.Fatalf("Should have save in new form: \n%s\n not \n%s", configStr, expConfStr)
	}
}

func TestNewJSONNoEmail(t *testing.T) {
	tmpHome := t.TempDir()

	fn := filepath.Join(tmpHome, ConfigFileName)
	js := ` { "auths": { "https://index.docker.io/v1/": { "auth": "am9lam9lOmhlbGxv" } } }`
	if err := os.WriteFile(fn, []byte(js), 0o600); err != nil {
		t.Fatal(err)
	}

	config, err := Load(tmpHome)
	assert.NilError(t, err)

	ac := config.AuthConfigs["https://index.docker.io/v1/"]
	assert.Equal(t, ac.Username, "joejoe")
	assert.Equal(t, ac.Password, "hello")

	// Now save it and make sure it shows up in new form
	configStr := saveConfigAndValidateNewFormat(t, config, tmpHome)

	expConfStr := `{
	"auths": {
		"https://index.docker.io/v1/": {
			"auth": "am9lam9lOmhlbGxv"
		}
	}
}`

	if configStr != expConfStr {
		t.Fatalf("Should have save in new form: \n%s\n not \n%s", configStr, expConfStr)
	}
}

func TestJSONWithPsFormat(t *testing.T) {
	tmpHome := t.TempDir()

	fn := filepath.Join(tmpHome, ConfigFileName)
	js := `{
		"auths": { "https://index.docker.io/v1/": { "auth": "am9lam9lOmhlbGxv", "email": "user@example.com" } },
		"psFormat": "table {{.ID}}\\t{{.Label \"com.docker.label.cpu\"}}"
}`
	if err := os.WriteFile(fn, []byte(js), 0o600); err != nil {
		t.Fatal(err)
	}

	config, err := Load(tmpHome)
	assert.NilError(t, err)

	if config.PsFormat != `table {{.ID}}\t{{.Label "com.docker.label.cpu"}}` {
		t.Fatalf("Unknown ps format: %s\n", config.PsFormat)
	}

	// Now save it and make sure it shows up in new form
	configStr := saveConfigAndValidateNewFormat(t, config, tmpHome)
	if !strings.Contains(configStr, `"psFormat":`) ||
		!strings.Contains(configStr, "{{.ID}}") {
		t.Fatalf("Should have save in new form: %s", configStr)
	}
}

func TestJSONWithCredentialStore(t *testing.T) {
	tmpHome := t.TempDir()

	fn := filepath.Join(tmpHome, ConfigFileName)
	js := `{
		"auths": { "https://index.docker.io/v1/": { "auth": "am9lam9lOmhlbGxv", "email": "user@example.com" } },
		"credsStore": "crazy-secure-storage"
}`
	if err := os.WriteFile(fn, []byte(js), 0o600); err != nil {
		t.Fatal(err)
	}

	config, err := Load(tmpHome)
	assert.NilError(t, err)

	if config.CredentialsStore != "crazy-secure-storage" {
		t.Fatalf("Unknown credential store: %s\n", config.CredentialsStore)
	}

	// Now save it and make sure it shows up in new form
	configStr := saveConfigAndValidateNewFormat(t, config, tmpHome)
	if !strings.Contains(configStr, `"credsStore":`) ||
		!strings.Contains(configStr, "crazy-secure-storage") {
		t.Fatalf("Should have save in new form: %s", configStr)
	}
}

func TestJSONWithCredentialHelpers(t *testing.T) {
	tmpHome := t.TempDir()

	fn := filepath.Join(tmpHome, ConfigFileName)
	js := `{
		"auths": { "https://index.docker.io/v1/": { "auth": "am9lam9lOmhlbGxv", "email": "user@example.com" } },
		"credHelpers": { "images.io": "images-io", "containers.com": "crazy-secure-storage" }
}`
	if err := os.WriteFile(fn, []byte(js), 0o600); err != nil {
		t.Fatal(err)
	}

	config, err := Load(tmpHome)
	assert.NilError(t, err)

	if config.CredentialHelpers == nil {
		t.Fatal("config.CredentialHelpers was nil")
	} else if config.CredentialHelpers["images.io"] != "images-io" ||
		config.CredentialHelpers["containers.com"] != "crazy-secure-storage" {
		t.Fatalf("Credential helpers not deserialized properly: %v\n", config.CredentialHelpers)
	}

	// Now save it and make sure it shows up in new form
	configStr := saveConfigAndValidateNewFormat(t, config, tmpHome)
	if !strings.Contains(configStr, `"credHelpers":`) ||
		!strings.Contains(configStr, "images.io") ||
		!strings.Contains(configStr, "images-io") ||
		!strings.Contains(configStr, "containers.com") ||
		!strings.Contains(configStr, "crazy-secure-storage") {
		t.Fatalf("Should have save in new form: %s", configStr)
	}
}

// Save it and make sure it shows up in new form
func saveConfigAndValidateNewFormat(t *testing.T, config *configfile.ConfigFile, configDir string) string {
	t.Helper()
	assert.NilError(t, config.Save())

	buf, err := os.ReadFile(filepath.Join(configDir, ConfigFileName))
	assert.NilError(t, err)
	assert.Check(t, is.Contains(string(buf), `"auths":`))
	return string(buf)
}

func TestConfigDir(t *testing.T) {
	tmpHome := t.TempDir()

	if Dir() == tmpHome {
		t.Fatalf("Expected ConfigDir to be different than %s by default, but was the same", tmpHome)
	}

	// Update configDir
	SetDir(tmpHome)

	if Dir() != tmpHome {
		t.Fatalf("Expected ConfigDir to %s, but was %s", tmpHome, Dir())
	}
}

func TestJSONReaderNoFile(t *testing.T) {
	js := ` { "auths": { "https://index.docker.io/v1/": { "auth": "am9lam9lOmhlbGxv", "email": "user@example.com" } } }`

	config, err := LoadFromReader(strings.NewReader(js))
	assert.NilError(t, err)

	ac := config.AuthConfigs["https://index.docker.io/v1/"]
	assert.Equal(t, ac.Username, "joejoe")
	assert.Equal(t, ac.Password, "hello")
}

func TestJSONWithPsFormatNoFile(t *testing.T) {
	js := `{
		"auths": { "https://index.docker.io/v1/": { "auth": "am9lam9lOmhlbGxv", "email": "user@example.com" } },
		"psFormat": "table {{.ID}}\\t{{.Label \"com.docker.label.cpu\"}}"
}`
	config, err := LoadFromReader(strings.NewReader(js))
	assert.NilError(t, err)

	if config.PsFormat != `table {{.ID}}\t{{.Label "com.docker.label.cpu"}}` {
		t.Fatalf("Unknown ps format: %s\n", config.PsFormat)
	}
}

func TestJSONSaveWithNoFile(t *testing.T) {
	js := `{
		"auths": { "https://index.docker.io/v1/": { "auth": "am9lam9lOmhlbGxv" } },
		"psFormat": "table {{.ID}}\\t{{.Label \"com.docker.label.cpu\"}}"
}`
	config, err := LoadFromReader(strings.NewReader(js))
	assert.NilError(t, err)
	err = config.Save()
	assert.ErrorContains(t, err, "with empty filename")

	tmpHome := t.TempDir()

	fn := filepath.Join(tmpHome, ConfigFileName)
	f, _ := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
	defer f.Close()

	assert.NilError(t, config.SaveToWriter(f))
	buf, err := os.ReadFile(filepath.Join(tmpHome, ConfigFileName))
	assert.NilError(t, err)
	expConfStr := `{
	"auths": {
		"https://index.docker.io/v1/": {
			"auth": "am9lam9lOmhlbGxv"
		}
	},
	"psFormat": "table {{.ID}}\\t{{.Label \"com.docker.label.cpu\"}}"
}`
	if string(buf) != expConfStr {
		t.Fatalf("Should have save in new form: \n%s\nnot \n%s", string(buf), expConfStr)
	}
}

func TestLoadDefaultConfigFile(t *testing.T) {
	dir := setupConfigDir(t)
	buffer := new(bytes.Buffer)

	filename := filepath.Join(dir, ConfigFileName)
	content := []byte(`{"PsFormat": "format"}`)
	err := os.WriteFile(filename, content, 0o644)
	assert.NilError(t, err)

	t.Run("success", func(t *testing.T) {
		configFile := LoadDefaultConfigFile(buffer)
		credStore := credentials.DetectDefaultStore("")
		expected := configfile.New(filename)
		expected.CredentialsStore = credStore
		expected.PsFormat = "format"

		assert.Check(t, is.DeepEqual(expected, configFile))
		assert.Check(t, is.Equal(buffer.String(), ""))
	})

	t.Run("permission error", func(t *testing.T) {
		if runtime.GOOS != "windows" {
			skip.If(t, os.Getuid() == 0, "cannot test permission denied when running as root")
		}
		err = os.Chmod(filename, 0o000)
		assert.NilError(t, err)

		buffer.Reset()
		_ = LoadDefaultConfigFile(buffer)
		warnings := buffer.String()

		assert.Check(t, is.Contains(warnings, "WARNING:"))
		assert.Check(t, is.Contains(warnings, os.ErrPermission.Error()))
	})
}

// The CLI no longer disables/hides experimental CLI features, however, we need
// to verify that existing configuration files do not break
func TestLoadLegacyExperimental(t *testing.T) {
	tests := []struct {
		doc        string
		configfile string
	}{
		{
			doc:        "default",
			configfile: `{}`,
		},
		{
			doc: "experimental",
			configfile: `{
	"experimental": "enabled"
}`,
		},
	}
	for _, tc := range tests {
		t.Run(tc.doc, func(t *testing.T) {
			_, err := LoadFromReader(strings.NewReader(tc.configfile))
			assert.NilError(t, err)
		})
	}
}

func TestConfigPath(t *testing.T) {
	oldDir := Dir()

	for _, tc := range []struct {
		name        string
		dir         string
		path        []string
		expected    string
		expectedErr string
	}{
		{
			name:     "valid_path",
			dir:      "dummy",
			path:     []string{"a", "b"},
			expected: filepath.Join("dummy", "a", "b"),
		},
		{
			name:     "valid_path_absolute_dir",
			dir:      "/dummy",
			path:     []string{"a", "b"},
			expected: filepath.Join("/dummy", "a", "b"),
		},
		{
			name:        "invalid_relative_path",
			dir:         "dummy",
			path:        []string{"e", "..", "..", "f"},
			expectedErr: fmt.Sprintf("is outside of root config directory %q", "dummy"),
		},
		{
			name:        "invalid_absolute_path",
			dir:         "dummy",
			path:        []string{"/a", "..", ".."},
			expectedErr: fmt.Sprintf("is outside of root config directory %q", "dummy"),
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			SetDir(tc.dir)
			f, err := Path(tc.path...)
			assert.Equal(t, f, tc.expected)
			if tc.expectedErr == "" {
				assert.NilError(t, err)
			} else {
				assert.ErrorContains(t, err, tc.expectedErr)
			}
		})
	}

	SetDir(oldDir)
}

// TestSetDir verifies that Dir() does not overwrite the value set through
// SetDir() if it has not been run before.
func TestSetDir(t *testing.T) {
	const expected = "my_config_dir"
	resetConfigDir()
	SetDir(expected)
	assert.Check(t, is.Equal(Dir(), expected))
}
