package getter_test

import (
	"context"
	"errors"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"github.com/gruntwork-io/terragrunt/internal/cas"
	"github.com/gruntwork-io/terragrunt/internal/getter"
	"github.com/gruntwork-io/terragrunt/internal/vexec"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// errHgNotFound is the LookPath error the missing-binary tests inject.
var errHgNotFound = errors.New("hg not found")

func TestHgResolver_MissingBinaryReturnsErrNoVersionMetadata(t *testing.T) {
	t.Parallel()

	e := vexec.NewMemExec(
		hgHandler(vexec.Result{}),
		vexec.WithLookPath(func(string) (string, error) { return "", errHgNotFound }),
	)

	r := &getter.HgResolver{Exec: e}

	_, err := r.Probe(t.Context(), "https://example.com/repo")
	require.ErrorIs(t, err, cas.ErrNoVersionMetadata)
}

// TestHgResolver_BinaryFailureReturnsErrNoVersionMetadata feeds the
// resolver an Exec whose handler returns a non-zero exit code, so the
// resolver swallows the failure as ErrNoVersionMetadata.
func TestHgResolver_BinaryFailureReturnsErrNoVersionMetadata(t *testing.T) {
	t.Parallel()

	e := vexec.NewMemExec(hgHandler(vexec.Result{ExitCode: 1}))

	r := &getter.HgResolver{Exec: e}

	_, err := r.Probe(t.Context(), "https://example.com/repo")
	require.ErrorIs(t, err, cas.ErrNoVersionMetadata)
}

// TestHgResolver_ParsesNodeFromStubOutput verifies the resolver picks
// up the hex hash from a successful command and wraps it in a
// content-addressed cache key. The stub returns the full 40-char node
// hash the resolver must request (the abbreviated 12-char form has
// ~280M values and is not collision-safe for use as a cache key).
func TestHgResolver_ParsesNodeFromStubOutput(t *testing.T) {
	t.Parallel()

	const fullNode = "abcdef0123456789abcdef0123456789abcdef01"

	e := vexec.NewMemExec(hgHandler(vexec.Result{Stdout: []byte(fullNode + "\n")}))

	r := &getter.HgResolver{Exec: e}

	got, err := r.Probe(t.Context(), "https://example.com/repo?rev=tip")
	require.NoError(t, err)
	assert.Equal(t, cas.ContentKey("hg-node", fullNode), got)
}

func TestHgResolver_EmptyOutputReturnsErrNoVersionMetadata(t *testing.T) {
	t.Parallel()

	e := vexec.NewMemExec(hgHandler(vexec.Result{Stdout: []byte("\n")}))

	r := &getter.HgResolver{Exec: e}

	_, err := r.Probe(t.Context(), "https://example.com/repo")
	require.ErrorIs(t, err, cas.ErrNoVersionMetadata)
}

// TestHgResolver_PassesRevAsArg pins the argv shape. `--template
// '{node}\n'` is needed for the full 40-char node hash (the cache
// key); `--id` returns the 12-char short form and is not
// collision-safe. `--rev=<v>` and the `--` URL terminator block a
// `-`-prefixed value from being reparsed as an hg flag.
func TestHgResolver_PassesRevAsArg(t *testing.T) {
	t.Parallel()

	var gotArgs []string

	handler := func(_ context.Context, inv vexec.Invocation) vexec.Result {
		gotArgs = inv.Args
		return vexec.Result{Stdout: []byte("abcdef0123456789abcdef0123456789abcdef01\n")}
	}

	r := &getter.HgResolver{Exec: vexec.NewMemExec(handler)}

	_, err := r.Probe(t.Context(), "https://example.com/repo?rev=feature-x")
	require.NoError(t, err)

	assert.Equal(
		t,
		[]string{
			"identify",
			"--template",
			"{node}\n",
			"--rev=feature-x",
			"--",
			"https://example.com/repo",
		},
		gotArgs,
	)
}

// TestHgResolver_FlagLikeRevStaysBoundToOption pins that a
// `-`-prefixed rev value stays inside the --rev argv element instead
// of appearing as its own flag-shaped element.
func TestHgResolver_FlagLikeRevStaysBoundToOption(t *testing.T) {
	t.Parallel()

	var gotArgs []string

	handler := func(_ context.Context, inv vexec.Invocation) vexec.Result {
		gotArgs = inv.Args
		return vexec.Result{Stdout: []byte("abcdef0123456789abcdef0123456789abcdef01\n")}
	}

	r := &getter.HgResolver{Exec: vexec.NewMemExec(handler)}

	_, err := r.Probe(t.Context(), "https://example.com/repo?rev=--debugger")
	require.NoError(t, err)

	assert.Contains(t, gotArgs, "--rev=--debugger",
		"flag-like rev value must stay bound to --rev in a single argv element")
	assert.NotContains(t, gotArgs, "--debugger",
		"flag-like rev value must not appear as its own argv element")
}

// TestHgResolver_RejectsRevWithControlCharacters pins that NUL,
// newline, and carriage return in the rev are rejected before any hg
// invocation. Other shell metacharacters (`;`, `|`, ...) pass through
// because [vexec.Exec.Command] does not run through a shell.
func TestHgResolver_RejectsRevWithControlCharacters(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name string
		rev  string
	}{
		{name: "null byte", rev: "tip\x00rest"},
		{name: "newline", rev: "tip\nrest"},
		{name: "carriage return", rev: "tip\rrest"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			var commandRan bool

			handler := func(_ context.Context, _ vexec.Invocation) vexec.Result {
				commandRan = true
				return vexec.Result{Stdout: []byte("abcdef0123456789abcdef0123456789abcdef01\n")}
			}

			r := &getter.HgResolver{Exec: vexec.NewMemExec(handler)}

			rawURL := "https://example.com/repo?rev=" + url.QueryEscape(tt.rev)

			_, err := r.Probe(t.Context(), rawURL)
			require.ErrorIs(t, err, getter.ErrInvalidHgRev)
			assert.False(t, commandRan, "hg must not be invoked when rev is invalid")
		})
	}
}

// TestHgResolver_AcceptsRevWithShellMetacharacters pins that shell
// metacharacters reach hg literally instead of being rejected by the
// resolver. The exec layer does not invoke a shell, so `;` is just part
// of the rev string and hg rejects it on its own merits.
func TestHgResolver_AcceptsRevWithShellMetacharacters(t *testing.T) {
	t.Parallel()

	var gotArgs []string

	handler := func(_ context.Context, inv vexec.Invocation) vexec.Result {
		gotArgs = inv.Args
		return vexec.Result{Stdout: []byte("abcdef0123456789abcdef0123456789abcdef01\n")}
	}

	r := &getter.HgResolver{Exec: vexec.NewMemExec(handler)}

	_, err := r.Probe(
		t.Context(),
		"https://example.com/repo?rev="+url.QueryEscape("tip ; echo pwned"),
	)
	require.NoError(t, err)
	assert.Contains(t, gotArgs, "--rev=tip ; echo pwned",
		"shell metacharacters must reach hg as part of a single argv element")
}

// TestHgResolver_AgainstRealHg verifies the resolver against the
// actual hg binary when it is installed. It uses a freshly-initialized
// repository on disk so the test does not reach the network. The
// assertion pins the resolver's key against a ContentKey derived
// from the full 40-char node hash reported by the stable
// `hg log -T {node}` template API; this regresses if the resolver
// reverts to `--id`'s 12-char short form.
func TestHgResolver_AgainstRealHg(t *testing.T) {
	t.Parallel()

	if _, err := exec.LookPath("hg"); err != nil {
		t.Skip("hg binary not installed on this host")
	}

	repoDir := t.TempDir()

	hg := func(args ...string) string {
		cmd := exec.CommandContext(t.Context(), "hg", args...)
		cmd.Dir = repoDir

		out, err := cmd.CombinedOutput()
		require.NoErrorf(t, err, "hg %v failed: %s", args, string(out))

		return string(out)
	}

	hg("init", ".")
	require.NoError(t, os.WriteFile(filepath.Join(repoDir, "main.tf"), []byte("hello\n"), 0o644))
	hg("--config", "ui.username=test <test@test>", "commit", "-A", "-m", "initial")

	// The template API is a stable hg contract, unlike debug output text.
	fullNode := strings.TrimSpace(hg("log", "-r", "tip", "-T", "{node}"))
	require.Len(t, fullNode, 40, "hg log -T {node} must report a full 40-char node hash")

	r := getter.NewHgResolver()

	got, err := r.Probe(t.Context(), repoDir+"?rev=tip")
	require.NoError(t, err)
	assert.Equal(t, cas.ContentKey("hg-node", fullNode), got)
}

// hgHandler returns a vexec.Handler that always produces the given
// Result, regardless of invocation arguments. Used to pin
// stdout/exit-code on a per-test basis.
func hgHandler(r vexec.Result) vexec.Handler {
	return func(_ context.Context, _ vexec.Invocation) vexec.Result {
		return r
	}
}
