package argv

import (
	"strings"
	"testing"
)

// Every script calls the same command with the same arguments, and registers
// itself with the shell it was written for.
//
// Checked as text rather than by running a shell: what a script *does* is a
// shell's business, and the five of them are ported from usage-argv's, where the
// behaviour was worked out. What this can catch is the part that goes wrong when
// a name is threaded through a template — a callback pointed at the wrong binary,
// a function registered under a name nothing defines.
func TestEveryScriptCallsTheBinaryAndRegistersIt(t *testing.T) {
	for _, c := range []struct {
		shell    Shell
		flag     string
		register string
	}{
		{Bash, "--shell bash", "complete -F _usage_complete_mise 'mise'"},
		{Zsh, "--shell zsh", "compdef _mise 'mise'"},
		{Fish, "--shell fish", "complete -c 'mise' -f -a '(__usage_complete_mise)'"},
		{Nu, "--shell nu", "$env.config.completions.external.completer = {|spans|"},
		{PowerShell, "--shell powershell", "Register-ArgumentCompleter -Native -CommandName 'mise'"},
	} {
		out := Script("mise", c.shell)
		if !strings.Contains(out, RequestName+" "+c.flag) {
			t.Errorf("%v: should call `%s %s`:\n%s", c.shell, RequestName, c.flag, out)
		}
		if !strings.Contains(out, c.register) {
			t.Errorf("%v: should register with %q:\n%s", c.shell, c.register, out)
		}
		// The header names the same shell the call does, since it is read off the
		// script rather than passed beside it. zsh is the exception: `compinit`
		// reads only the first line of a file in `$fpath` looking for `#compdef`,
		// so the magic comment goes above the header rather than below it.
		want := "# @generated by usage for `mise " + RequestName + " " + c.flag + "`"
		if c.shell == Zsh {
			want = "#compdef mise\n" + want
		}
		if !strings.HasPrefix(out, want) {
			t.Errorf("%v: script does not lead with %q:\n%s", c.shell, want, out)
		}
		if strings.Contains(out, "{bin}") || strings.Contains(out, "{shell}") ||
			strings.Contains(out, "{ident}") {
			t.Errorf("%v: a placeholder survived into the script:\n%s", c.shell, out)
		}
	}
}

// The marker lines the scripts watch for are the ones the renderer writes.
//
// Spelled differently on each side — `$'\001files'` in bash, `[char]1` in
// PowerShell, `\u{1}` in nushell — so a change to the constant would leave five
// scripts watching for something that never arrives.
func TestTheScriptsWatchForTheMarkerTheRendererWrites(t *testing.T) {
	if FilesMarker != "\x01files" || DirsMarker != "\x01dirs" || ExecutablePathsMarker != "\x01executables" || CommandsMarker != "\x01commands" {
		t.Fatalf("the scripts are written for \\x01: %q %q %q %q", FilesMarker, DirsMarker, ExecutablePathsMarker, CommandsMarker)
	}
	for _, c := range []struct{ shell, spelling Shell }{{Bash, Bash}, {Zsh, Zsh}} {
		out := Script("mise", c.shell)
		for _, want := range []string{`$'\001files'`, `$'\001dirs'`, `$'\001executables'`, `$'\001commands'`} {
			if !strings.Contains(out, want) {
				t.Errorf("%v should watch for %s:\n%s", c.shell, want, out)
			}
		}
	}
	if !strings.Contains(Script("mise", Fish), `printf '\x01files'`) {
		t.Error("fish builds the marker with printf")
	}
	if !strings.Contains(Script("mise", Fish), `test -d "$value"; or test -x "$value"`) {
		t.Error("fish filters executable-path candidates")
	}
	if !strings.Contains(Script("mise", Zsh), `_files -g '*(-/,*)'`) {
		t.Error("zsh keeps directories beside executable-path candidates")
	}
	if !strings.Contains(Script("mise", PowerShell), "-CommandType Application, ExternalScript") {
		t.Error("powershell filters executable-path candidates")
	}
	if out := Script("mise", PowerShell); !strings.Contains(out, "} elseif ($files) {") || strings.Contains(out, "} else if ($files) {") {
		t.Errorf("powershell uses its elseif keyword:\n%s", out)
	}
	if !strings.Contains(Script("mise", Nu), `"\u{1}"`) {
		t.Error("nushell spells it as an escape")
	}
	if !strings.Contains(Script("mise", PowerShell), "[char]1") {
		t.Error("PowerShell builds it from the code point")
	}
	for _, test := range []struct {
		shell Shell
		want  string
	}{
		{Bash, "compgen -c"},
		{Zsh, "_command_names"},
		{Fish, "__fish_complete_command"},
		{Nu, "which"},
		{PowerShell, "Get-Command"},
	} {
		if out := Script("mise", test.shell); !strings.Contains(out, test.want) {
			t.Errorf("%v should ask the shell for commands with %q:\n%s", test.shell, test.want, out)
		}
	}
	if out := Script("mise", Nu); strings.Contains(out, "| commandline complete") {
		t.Errorf("nushell command completion must not recurse through the external completer:\n%s", out)
	}
	if out := Script("mise", Nu); !strings.Contains(out, "let wants_path_fallback") || !strings.Contains(out, `$nu.os-info.name == "windows"`) {
		t.Errorf("nushell command completion must preserve path and Windows lookup semantics:\n%s", out)
	}
}

// A candidate that looks like an option to the shell's own printer is still
// offered.
//
// fish's `echo` reads a leading `-n`, `-e`, `-s` or `-E` as its own option, so a
// CLI with a `-n` would have had it swallowed on the way to the prompt.
func TestFishPrintsCandidatesThatLookLikeOptions(t *testing.T) {
	out := Script("mise", Fish)
	if strings.Contains(out, "echo $entry") {
		t.Errorf("fish's echo eats a leading -n:\n%s", out)
	}
	if !strings.Contains(out, `printf '%s\n' $entry`) {
		t.Errorf("want printf, which takes its format first and the rest as data:\n%s", out)
	}
}

// nushell has one external completer for the whole shell, so the script chains
// rather than replaces.
//
// A config that completes several tools should keep completing all of them, and
// a script that defined a function without installing it would be a no-op —
// sourced, sourced correctly, and doing nothing at all.
func TestTheNushellScriptChainsTheCompleterItFound(t *testing.T) {
	out := Script("mise", Nu)
	for _, want := range []string{
		"let __usage_previous_mise = ($env.config.completions.external.completer? | default null)",
		"$env.config.completions.external.completer = {|spans|",
		`if ($spans | get 0) == "mise"`,
		"do $__usage_previous_mise $spans",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("want %q:\n%s", want, out)
		}
	}
}

// nushell binds a variable, so a name with a `-` in it has to be escaped rather
// than flattened.
//
// Flattening mapped `foo-bar` and `foo+bar` both to `foo_bar`, and two scripts
// loaded together would each have completed the other's binary.
func TestNushellNamesCannotCollide(t *testing.T) {
	if got := nuIdent("foo-bar"); got != "foo_x2dbar" {
		t.Errorf("want an escape, got %q", got)
	}
	if nuIdent("foo-bar") == nuIdent("foo+bar") {
		t.Error("two names became one identifier")
	}
	// The underscore is escaped too, so a name cannot spell an escape by hand and
	// collide with the character it stands for.
	if nuIdent("foo_x2dbar") == nuIdent("foo-bar") {
		t.Error("a name spelling an escape collided with the character")
	}
	if !strings.Contains(Script("foo-bar", Nu), "__usage_complete_foo_x2dbar") {
		t.Error("the script should use the escaped identifier")
	}
	// And the four shells that take a name verbatim keep it, because sanitizing is
	// what makes two of them collide.
	if !strings.Contains(Script("foo-bar", Bash), "_usage_complete_foo-bar()") {
		t.Error("bash takes the name as it is")
	}
}

// A name that is not one plain shell word cannot be registered by anyone.
//
// zsh's `#compdef` line is read by `compinit` before shell quoting exists, so
// there is nowhere to put a quote. Said out loud where it can be fixed rather
// than discovered at a prompt.
func TestAnUnregisterableNameIsRefused(t *testing.T) {
	for _, bin := range []string{"", "my cli", "it's", "a;b", "a$b"} {
		func() {
			defer func() {
				if recover() == nil {
					t.Errorf("%q is not one shell word, and should not be registered", bin)
				}
			}()
			Script(bin, Bash)
		}()
	}
	// The set that is accepted is what binaries are actually called.
	for _, bin := range []string{"mise", "foo-bar", "foo_bar", "foo.bar", "g++", "7zip"} {
		if !plainWord(bin) {
			t.Errorf("%q is a name a binary actually has", bin)
		}
	}
}
