package versions

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

	"github.com/asdf-vm/asdf/internal/config"
	"github.com/asdf-vm/asdf/internal/plugins"
	"github.com/asdf-vm/asdf/internal/repotest"
	"github.com/asdf-vm/asdf/internal/toolversions"
	"github.com/stretchr/testify/assert"
)

const testPluginName = "testlua"

func TestInstallAll(t *testing.T) {
	t.Run("installs multiple tools when multiple tool versions are specified", func(t *testing.T) {
		conf, plugin := generateConfig(t)
		stdout, stderr := buildOutputs()
		currentDir := t.TempDir()
		secondPlugin := installPlugin(t, conf, "dummy_plugin", "another")
		version := "1.0.0"

		// write a version file
		content := fmt.Sprintf("%s %s\n%s %s", plugin.Name, version, secondPlugin.Name, version)
		writeVersionFile(t, currentDir, content)

		err := InstallAll(conf, currentDir, &stdout, &stderr)
		assert.Nil(t, err)

		assertVersionInstalled(t, conf.DataDir, plugin.Name, version)
		assertVersionInstalled(t, conf.DataDir, secondPlugin.Name, version)
	})

	t.Run("only installs tools with versions specified for current directory", func(t *testing.T) {
		conf, plugin := generateConfig(t)
		stdout, stderr := buildOutputs()
		currentDir := t.TempDir()
		secondPlugin := installPlugin(t, conf, "dummy_plugin", "another")
		version := "1.0.0"

		// write a version file
		content := fmt.Sprintf("%s %s\n", plugin.Name, version)
		writeVersionFile(t, currentDir, content)

		err := InstallAll(conf, currentDir, &stdout, &stderr)
		assert.ErrorContains(t, err[0], "no version set")

		assertVersionInstalled(t, conf.DataDir, plugin.Name, version)
		assertNotInstalled(t, conf.DataDir, secondPlugin.Name, version)
	})

	t.Run("installs all tools even after one fails to install", func(t *testing.T) {
		conf, plugin := generateConfig(t)
		stdout, stderr := buildOutputs()
		currentDir := t.TempDir()
		secondPlugin := installPlugin(t, conf, "dummy_plugin", "another")
		version := "1.0.0"

		// write a version file
		content := fmt.Sprintf("%s %s\n%s %s", secondPlugin.Name, "non-existent-version", plugin.Name, version)
		writeVersionFile(t, currentDir, content)

		err := InstallAll(conf, currentDir, &stdout, &stderr)
		assert.Empty(t, err)

		assertNotInstalled(t, conf.DataDir, secondPlugin.Name, version)
		assertVersionInstalled(t, conf.DataDir, plugin.Name, version)
	})
}

func TestInstall(t *testing.T) {
	conf, plugin := generateConfig(t)
	stdout, stderr := buildOutputs()
	currentDir := t.TempDir()

	t.Run("installs version of tool specified for current directory", func(t *testing.T) {
		version := "1.0.0"
		// write a version file
		data := []byte(fmt.Sprintf("%s %s", plugin.Name, version))
		err := os.WriteFile(filepath.Join(currentDir, ".tool-versions"), data, 0o666)
		assert.Nil(t, err)

		err = Install(conf, plugin, currentDir, &stdout, &stderr)
		assert.Nil(t, err)

		assertVersionInstalled(t, conf.DataDir, plugin.Name, version)
	})

	t.Run("returns error when plugin doesn't exist", func(t *testing.T) {
		conf, _ := generateConfig(t)
		stdout, stderr := buildOutputs()
		err := Install(conf, plugins.New(conf, "non-existent"), currentDir, &stdout, &stderr)
		assert.IsType(t, plugins.PluginMissing{}, err)
	})

	t.Run("returns error when no version set", func(t *testing.T) {
		conf, _ := generateConfig(t)
		stdout, stderr := buildOutputs()
		currentDir := t.TempDir()
		err := Install(conf, plugin, currentDir, &stdout, &stderr)
		assert.EqualError(t, err, "no version set")
	})

	t.Run("if multiple versions are defined installs all of them", func(t *testing.T) {
		conf, plugin := generateConfig(t)
		stdout, stderr := buildOutputs()
		currentDir := t.TempDir()

		versions := "1.0.0 2.0.0"
		// write a version file
		data := []byte(fmt.Sprintf("%s %s", plugin.Name, versions))
		err := os.WriteFile(filepath.Join(currentDir, ".tool-versions"), data, 0o666)
		assert.Nil(t, err)

		err = Install(conf, plugin, currentDir, &stdout, &stderr)
		assert.Nil(t, err)

		assertVersionInstalled(t, conf.DataDir, plugin.Name, "1.0.0")
		assertVersionInstalled(t, conf.DataDir, plugin.Name, "2.0.0")
	})

	t.Run("if multiple versions are defined and installed returns an error", func(t *testing.T) {
		conf, plugin := generateConfig(t)
		stdout, stderr := buildOutputs()
		currentDir := t.TempDir()

		versions := "1.0.0 2.0.0"
		// write a version file
		data := []byte(fmt.Sprintf("%s %s", plugin.Name, versions))
		err := os.WriteFile(filepath.Join(currentDir, ".tool-versions"), data, 0o666)
		assert.NoError(t, err)

		err = Install(conf, plugin, currentDir, &stdout, &stderr)
		assert.NoError(t, err)

		assertVersionInstalled(t, conf.DataDir, plugin.Name, "1.0.0")
		assertVersionInstalled(t, conf.DataDir, plugin.Name, "2.0.0")

		err = Install(conf, plugin, currentDir, &stdout, &stderr)
		assert.Error(t, err)
		// Expect a VersionAlreadyInstalledError
		var eerr VersionAlreadyInstalledError
		assert.ErrorAs(t, err, &eerr)
	})
}

func TestInstallVersion(t *testing.T) {
	t.Setenv("ASDF_CONFIG_FILE", "testdata/asdfrc")

	t.Run("returns error when plugin doesn't exist", func(t *testing.T) {
		conf, _ := generateConfig(t)
		stdout, stderr := buildOutputs()
		version := toolversions.Version{Type: "version", Value: "1.2.3"}
		err := InstallVersion(conf, plugins.New(conf, "non-existent"), version, &stdout, &stderr)
		assert.IsType(t, plugins.PluginMissing{}, err)
	})

	t.Run("installs latest version of tool when version is 'latest'", func(t *testing.T) {
		conf, plugin := generateConfig(t)
		stdout, stderr := buildOutputs()
		version := toolversions.Version{Type: "latest", Value: ""}
		err := InstallVersion(conf, plugin, version, &stdout, &stderr)
		assert.Nil(t, err)

		assertVersionInstalled(t, conf.DataDir, plugin.Name, "2.0.0")
	})

	t.Run("installs specific version of tool", func(t *testing.T) {
		conf, plugin := generateConfig(t)
		stdout, stderr := buildOutputs()

		version := toolversions.Version{Type: "latest", Value: "^1."}
		err := InstallVersion(conf, plugin, version, &stdout, &stderr)
		assert.Nil(t, err)

		assertVersionInstalled(t, conf.DataDir, plugin.Name, "1.1.0")
	})
}

func TestInstallOneVersion(t *testing.T) {
	t.Setenv("ASDF_CONFIG_FILE", "testdata/asdfrc")

	t.Run("returns error when plugin doesn't exist", func(t *testing.T) {
		conf, _ := generateConfig(t)
		stdout, stderr := buildOutputs()
		err := InstallOneVersion(conf, plugins.New(conf, "non-existent"), "1.2.3", false, &stdout, &stderr)
		assert.IsType(t, plugins.PluginMissing{}, err)
	})

	t.Run("returns error when passed a path version", func(t *testing.T) {
		conf, plugin := generateConfig(t)
		stdout, stderr := buildOutputs()
		err := InstallOneVersion(conf, plugin, "path:/foo/bar", false, &stdout, &stderr)

		assert.ErrorContains(t, err, "uninstallable version path of testlua")
	})

	t.Run("returns error when plugin version is 'system'", func(t *testing.T) {
		conf, plugin := generateConfig(t)
		stdout, stderr := buildOutputs()
		err := InstallOneVersion(conf, plugin, "system", false, &stdout, &stderr)
		assert.IsType(t, UninstallableVersionError{}, err)
	})

	t.Run("returns error when version doesn't exist", func(t *testing.T) {
		version := "other-dummy"
		conf, plugin := generateConfig(t)
		stdout, stderr := buildOutputs()
		err := InstallOneVersion(conf, plugin, version, false, &stdout, &stderr)
		assert.Errorf(t, err, "failed to run install callback: exit status 1")

		want := "pre_asdf_download_lua other-dummy\npre_asdf_install_lua other-dummy\nDummy couldn't install version: other-dummy (on purpose)\n"
		assert.Equal(t, want, stdout.String())

		assertNotInstalled(t, conf.DataDir, plugin.Name, version)
	})

	t.Run("returns error when version already installed", func(t *testing.T) {
		conf, plugin := generateConfig(t)
		stdout, stderr := buildOutputs()
		err := InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr)
		assert.Nil(t, err)
		assertVersionInstalled(t, conf.DataDir, plugin.Name, "1.0.0")

		// Install a second time
		err = InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr)
		assert.Error(t, err)
		// Expect a VersionAlreadyInstalledError
		var eerr VersionAlreadyInstalledError
		assert.ErrorAs(t, err, &eerr)
	})

	t.Run("creates download directory", func(t *testing.T) {
		conf, plugin := generateConfig(t)
		stdout, stderr := buildOutputs()
		err := InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr)
		assert.Nil(t, err)

		downloadPath := filepath.Join(conf.DataDir, "downloads", plugin.Name, "1.0.0")
		pathInfo, err := os.Stat(downloadPath)
		assert.Nil(t, err)
		assert.True(t, pathInfo.IsDir())
	})

	t.Run("creates install directory", func(t *testing.T) {
		conf, plugin := generateConfig(t)
		stdout, stderr := buildOutputs()
		err := InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr)
		assert.Nil(t, err)

		installPath := filepath.Join(conf.DataDir, "installs", plugin.Name, "1.0.0")
		pathInfo, err := os.Stat(installPath)
		assert.Nil(t, err)
		assert.True(t, pathInfo.IsDir())
	})

	t.Run("deletes install directory when install fails", func(t *testing.T) {
		conf, plugin := generateConfig(t)
		stdout, stderr := buildOutputs()

		installScript := filepath.Join(conf.DataDir, "plugins", plugin.Name, "bin", "install")
		f, err := os.OpenFile(installScript, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o777)
		assert.Nil(t, err)
		_, err = f.WriteString("\nexit 1")
		assert.Nil(t, err)
		err = f.Close()
		assert.Nil(t, err)

		err = InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr)
		assert.Errorf(t, err, "failed to run install callback: exit status 1")

		installPath := filepath.Join(conf.DataDir, "installs", plugin.Name, "1.0.0")
		_, err = os.Stat(installPath)
		assert.True(t, os.IsNotExist(err))
	})

	t.Run("runs pre-download, pre-install and post-install hooks when installation successful", func(t *testing.T) {
		conf, plugin := generateConfig(t)
		stdout, stderr := buildOutputs()
		err := InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr)
		assert.Nil(t, err)
		assert.Equal(t, "", stderr.String())
		want := "pre_asdf_download_lua 1.0.0\npre_asdf_install_lua 1.0.0\npost_asdf_install_lua 1.0.0\n"
		assert.Equal(t, want, stdout.String())
	})

	t.Run("installs successfully when plugin exists but version does not", func(t *testing.T) {
		conf, plugin := generateConfig(t)
		stdout, stderr := buildOutputs()
		err := InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr)
		assert.Nil(t, err)

		// Check download directory
		downloadPath := filepath.Join(conf.DataDir, "downloads", plugin.Name, "1.0.0")
		entries, err := os.ReadDir(downloadPath)
		assert.Nil(t, err)
		// mock plugin doesn't write anything
		assert.Empty(t, entries)

		// Check install directory
		assertVersionInstalled(t, conf.DataDir, plugin.Name, "1.0.0")
	})

	t.Run("install successfully when plugin lacks download callback", func(t *testing.T) {
		conf, _ := generateConfig(t)
		stdout, stderr := buildOutputs()
		testPluginName := "no-download"
		_, err := repotest.InstallPlugin("dummy_plugin_no_download", conf.DataDir, testPluginName)
		assert.Nil(t, err)
		plugin := plugins.New(conf, testPluginName)

		err = InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr)
		assert.Nil(t, err)

		// no-download install script prints 'install'
		assert.Equal(t, "install", stdout.String())
	})
}

func TestLatest(t *testing.T) {
	pluginName := "latest_test"
	conf, _ := generateConfig(t)
	_, err := repotest.InstallPlugin("dummy_legacy_plugin", conf.DataDir, pluginName)
	assert.Nil(t, err)
	plugin := plugins.New(conf, pluginName)

	t.Run("when plugin has a latest-stable callback invokes it and returns version it printed", func(t *testing.T) {
		pluginName := "latest-with-callback"
		_, err := repotest.InstallPlugin("dummy_plugin", conf.DataDir, pluginName)
		assert.Nil(t, err)
		plugin := plugins.New(conf, pluginName)

		version, err := Latest(plugin, "")
		assert.Nil(t, err)
		assert.Equal(t, "2.0.0", version)
	})

	t.Run("when plugin has latest-stable callback invokes and it does not filter results", func(t *testing.T) {
		pluginName := "latest-with-dev-version"
		pluginDir, err := repotest.InstallPlugin("dummy_plugin", conf.DataDir, pluginName)
		assert.Nil(t, err)
		plugin := plugins.New(conf, pluginName)

		// Replace latest-stable script so it returns a dev version that would be otherwise filtered out
		latestScript := filepath.Join(pluginDir, "bin", "latest-stable")
		err = os.WriteFile(latestScript, []byte("#!/usr/bin/env bash\necho 1.2.3-dev"), 0o777)
		assert.Nil(t, err)

		version, err := Latest(plugin, "")
		assert.Nil(t, err)
		assert.Equal(t, "1.2.3-dev", version)
	})

	t.Run("when given query matching no versions return empty slice of versions", func(t *testing.T) {
		version, err := Latest(plugin, "impossible-to-satisfy-query")
		assert.Error(t, err, "no latest version found")
		assert.Equal(t, version, "")
	})

	t.Run("when given no query returns latest version of plugin", func(t *testing.T) {
		version, err := Latest(plugin, "")
		assert.Nil(t, err)
		assert.Equal(t, "5.1.0", version)
	})

	t.Run("when given no query returns latest version of plugin", func(t *testing.T) {
		version, err := Latest(plugin, "4")
		assert.Nil(t, err)
		assert.Equal(t, "4.0.0", version)
	})

	t.Run("when given no query returns latest version of plugin starting with number", func(t *testing.T) {
		pluginName := "no-latest-non-numeric-versions"
		pluginDir, err := repotest.InstallPlugin("dummy_plugin", conf.DataDir, pluginName)
		assert.Nil(t, err)
		plugin := plugins.New(conf, pluginName)

		// Replace list-all script so it returns some non-numeric versions
		listAllScript := filepath.Join(pluginDir, "bin", "list-all")
		err = os.WriteFile(listAllScript, []byte("#!/usr/bin/env bash\necho foobar 3.4.5 1.2.3-dev foobar2"), 0o777)
		assert.Nil(t, err)
		latestScript := filepath.Join(pluginDir, "bin", "latest-stable")
		err = os.Remove(latestScript)
		assert.Nil(t, err)

		version, err := Latest(plugin, "")
		assert.Nil(t, err)
		assert.Equal(t, "3.4.5", version)
	})
}

func TestLatestWithSamples(t *testing.T) {
	tests := []struct {
		testFile       string
		expectedOutput string
	}{
		{
			testFile:       "list-all-ruby",
			expectedOutput: "3.4.2",
		},
		{
			testFile:       "list-all-python",
			expectedOutput: "3.13.2t",
		},
		{
			testFile:       "list-all-elixir",
			expectedOutput: "1.18.2-otp-27",
		},
	}
	for _, tt := range tests {
		pluginName := "latest_test"
		conf, _ := generateConfig(t)
		pluginDir, err := repotest.InstallPlugin("dummy_legacy_plugin", conf.DataDir, pluginName)
		assert.Nil(t, err)
		versionsFilePath, err := filepath.Abs(filepath.Join("testdata", tt.testFile))
		assert.Nil(t, err)
		contents := "#!/usr/bin/env bash\ncat \"" + versionsFilePath + "\""
		listAllPath := filepath.Join(pluginDir, "bin", "list-all")
		err = os.WriteFile(listAllPath, []byte(contents), 0o777)
		assert.Nil(t, err)

		plugin := plugins.New(conf, pluginName)
		version, err := Latest(plugin, "")
		assert.Nil(t, err)
		assert.Equal(t, tt.expectedOutput, version)
	}
}

func TestAllVersions(t *testing.T) {
	pluginName := "list-all-test"
	conf, _ := generateConfig(t)
	_, err := repotest.InstallPlugin("dummy_plugin", conf.DataDir, pluginName)
	assert.Nil(t, err)
	plugin := plugins.New(conf, pluginName)

	t.Run("returns slice of available versions from plugin", func(t *testing.T) {
		versions, err := AllVersions(plugin)
		assert.Nil(t, err)
		assert.Equal(t, versions, []string{"1.0.0", "1.1.0", "2.0.0"})
	})

	t.Run("returns error when callback missing", func(t *testing.T) {
		pluginName = "list-all-fail"
		_, err := repotest.InstallPlugin("dummy_plugin_no_download", conf.DataDir, pluginName)
		assert.Nil(t, err)
		plugin := plugins.New(conf, pluginName)

		versions, err := AllVersions(plugin)
		assert.Equal(t, err.(plugins.NoCallbackError).Error(), "Plugin named list-all-fail does not have a callback named list-all")
		assert.Empty(t, versions)
	})
}

func TestUninstall(t *testing.T) {
	t.Setenv("ASDF_CONFIG_FILE", "testdata/uninstall-asdfrc")
	pluginName := "uninstall-test"
	conf, _ := generateConfig(t)
	_, err := repotest.InstallPlugin("dummy_plugin", conf.DataDir, pluginName)
	assert.Nil(t, err)
	plugin := plugins.New(conf, pluginName)
	stdout, stderr := buildOutputs()

	t.Run("returns error when version is 'latest'", func(t *testing.T) {
		stdout, stderr := buildOutputs()
		err := Uninstall(conf, plugin, "latest", &stdout, &stderr)
		assert.Error(t, err, "'latest' is a special version value that cannot be used for uninstall command")
	})

	t.Run("returns an error when version not installed", func(t *testing.T) {
		err := Uninstall(conf, plugin, "4.0.0", &stdout, &stderr)
		assert.Error(t, err, "No such version")
	})

	t.Run("uninstalls successfully when plugin and version are installed", func(t *testing.T) {
		err = InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr)
		assert.Nil(t, err)

		err := Uninstall(conf, plugin, "1.0.0", &stdout, &stderr)
		assert.Nil(t, err)
		assertNotInstalled(t, conf.DataDir, plugin.Name, "1.0.0")
	})

	t.Run("runs pre and post-uninstall hooks", func(t *testing.T) {
		stdout, stderr := buildOutputs()
		err = InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr)
		assert.Nil(t, err)

		err := Uninstall(conf, plugin, "1.0.0", &stdout, &stderr)
		assert.Nil(t, err)
		want := "pre_asdf_uninstall_test 1.0.0\npost_asdf_uninstall_test 1.0.0\n"
		assert.Equal(t, want, stdout.String())
	})

	t.Run("invokes uninstall callback when present", func(t *testing.T) {
		stdout, stderr := buildOutputs()
		err = InstallOneVersion(conf, plugin, "1.0.0", false, &stdout, &stderr)
		assert.Nil(t, err)

		data := []byte("echo custom uninstall")
		err := os.WriteFile(filepath.Join(plugin.Dir, "bin", "uninstall"), data, 0o755)
		assert.Nil(t, err)

		err = Uninstall(conf, plugin, "1.0.0", &stdout, &stderr)
		assert.Nil(t, err)
		want := "pre_asdf_uninstall_test 1.0.0\ncustom uninstall\npost_asdf_uninstall_test 1.0.0\n"
		assert.Equal(t, want, stdout.String())
	})
}

// Helper functions
func buildOutputs() (strings.Builder, strings.Builder) {
	var stdout strings.Builder
	var stderr strings.Builder

	return stdout, stderr
}

func assertVersionInstalled(t *testing.T, dataDir, pluginName, version string) {
	t.Helper()

	installDir := filepath.Join(dataDir, "installs", pluginName, version)
	installedVersionFile := filepath.Join(installDir, "version")

	bytes, err := os.ReadFile(installedVersionFile)
	assert.Nil(t, err, "expected file from install to exist")

	want := fmt.Sprintf("%s\n", version)
	assert.Equal(t, want, string(bytes), "got wrong version")

	entries, err := os.ReadDir(installDir)
	assert.Nil(t, err)

	var fileNames []string
	for _, e := range entries {
		fileNames = append(fileNames, e.Name())
	}

	assert.Equal(t, fileNames, []string{"bin", "env", "version"})
}

func assertNotInstalled(t *testing.T, dataDir, pluginName, version string) {
	t.Helper()

	installPath := filepath.Join(dataDir, "installs", pluginName, version)
	entries, err := os.ReadDir(installPath)
	if err != nil && !os.IsNotExist(err) {
		t.Errorf("failed to check directory %s due to error %s", installPath, err)
	}
	assert.Empty(t, entries)
}

func generateConfig(t *testing.T) (config.Config, plugins.Plugin) {
	t.Helper()
	testDataDir := t.TempDir()
	conf, err := config.LoadConfig()
	assert.Nil(t, err)
	conf.DataDir = testDataDir

	_, err = repotest.InstallPlugin("dummy_plugin", testDataDir, testPluginName)
	assert.Nil(t, err)

	return conf, plugins.New(conf, testPluginName)
}

func installPlugin(t *testing.T, conf config.Config, fixture, name string) plugins.Plugin {
	_, err := repotest.InstallPlugin(fixture, conf.DataDir, name)
	assert.Nil(t, err)
	return plugins.New(conf, name)
}

func writeVersionFile(t *testing.T, dir, contents string) {
	t.Helper()
	err := os.WriteFile(filepath.Join(dir, ".tool-versions"), []byte(contents), 0o666)
	assert.Nil(t, err)
}
