// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package cliconfig

import (
	"net/http"
	"path/filepath"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/zclconf/go-cty/cty"

	svchost "github.com/hashicorp/terraform-svchost"
	svcauth "github.com/hashicorp/terraform-svchost/auth"
)

func TestCredentialsForHost(t *testing.T) {
	credSrc := &CredentialsSource{
		configured: map[svchost.Hostname]cty.Value{
			"configured.example.com": cty.ObjectVal(map[string]cty.Value{
				"token": cty.StringVal("configured"),
			}),
			"unused.example.com": cty.ObjectVal(map[string]cty.Value{
				"token": cty.StringVal("incorrectly-configured"),
			}),
		},

		// We'll use a static source to stand in for what would normally be
		// a credentials helper program, since we're only testing the logic
		// for choosing when to delegate to the helper here. The logic for
		// interacting with a helper program is tested in the svcauth package.
		helper: svcauth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{
			"from-helper.example.com": {
				"token": "from-helper",
			},

			// This should be shadowed by the "configured" entry with the same
			// hostname above.
			"configured.example.com": {
				"token": "incorrectly-from-helper",
			},
		}),
		helperType: "fake",
	}

	testReqAuthHeader := func(t *testing.T, creds svcauth.HostCredentials) string {
		t.Helper()

		if creds == nil {
			return ""
		}

		req, err := http.NewRequest("GET", "http://example.com/", nil)
		if err != nil {
			t.Fatalf("cannot construct HTTP request: %s", err)
		}
		creds.PrepareRequest(req)
		return req.Header.Get("Authorization")
	}

	t.Run("configured", func(t *testing.T) {
		creds, err := credSrc.ForHost(svchost.Hostname("configured.example.com"))
		if err != nil {
			t.Fatalf("unexpected error: %s", err)
		}
		if got, want := testReqAuthHeader(t, creds), "Bearer configured"; got != want {
			t.Errorf("wrong result\ngot:  %s\nwant: %s", got, want)
		}
	})
	t.Run("from helper", func(t *testing.T) {
		creds, err := credSrc.ForHost(svchost.Hostname("from-helper.example.com"))
		if err != nil {
			t.Fatalf("unexpected error: %s", err)
		}
		if got, want := testReqAuthHeader(t, creds), "Bearer from-helper"; got != want {
			t.Errorf("wrong result\ngot:  %s\nwant: %s", got, want)
		}
	})
	t.Run("not available", func(t *testing.T) {
		creds, err := credSrc.ForHost(svchost.Hostname("unavailable.example.com"))
		if err != nil {
			t.Fatalf("unexpected error: %s", err)
		}
		if got, want := testReqAuthHeader(t, creds), ""; got != want {
			t.Errorf("wrong result\ngot:  %s\nwant: %s", got, want)
		}
	})
	t.Run("set in environment", func(t *testing.T) {
		envName := "TF_TOKEN_configured_example_com"

		expectedToken := "configured-by-env"
		t.Setenv(envName, expectedToken)

		creds, err := credSrc.ForHost(svchost.Hostname("configured.example.com"))
		if err != nil {
			t.Fatalf("unexpected error: %s", err)
		}

		if creds == nil {
			t.Fatal("no credentials found")
		}

		if got := creds.Token(); got != expectedToken {
			t.Errorf("wrong result\ngot: %s\nwant: %s", got, expectedToken)
		}
	})

	t.Run("punycode name set in environment", func(t *testing.T) {
		envName := "TF_TOKEN_env_xn--eckwd4c7cu47r2wf_com"

		expectedToken := "configured-by-env"
		t.Setenv(envName, expectedToken)

		hostname, _ := svchost.ForComparison("env.ドメイン名例.com")
		creds, err := credSrc.ForHost(hostname)

		if err != nil {
			t.Fatalf("unexpected error: %s", err)
		}

		if creds == nil {
			t.Fatal("no credentials found")
		}

		if got := creds.Token(); got != expectedToken {
			t.Errorf("wrong result\ngot: %s\nwant: %s", got, expectedToken)
		}
	})

	t.Run("hyphens can be encoded as double underscores", func(t *testing.T) {
		envName := "TF_TOKEN_env_xn____caf__dma_fr"
		expectedToken := "configured-by-fallback"

		t.Setenv(envName, expectedToken)

		hostname, _ := svchost.ForComparison("env.café.fr")
		creds, err := credSrc.ForHost(hostname)

		if err != nil {
			t.Fatalf("unexpected error: %s", err)
		}

		if creds == nil {
			t.Fatal("no credentials found")
		}

		if got := creds.Token(); got != expectedToken {
			t.Errorf("wrong result\ngot: %s\nwant: %s", got, expectedToken)
		}
	})

	t.Run("periods are ok", func(t *testing.T) {
		envName := "TF_TOKEN_configured.example.com"
		expectedToken := "configured-by-env"

		t.Setenv(envName, expectedToken)

		hostname, _ := svchost.ForComparison("configured.example.com")
		creds, err := credSrc.ForHost(hostname)

		if err != nil {
			t.Fatalf("unexpected error: %s", err)
		}

		if creds == nil {
			t.Fatal("no credentials found")
		}

		if got := creds.Token(); got != expectedToken {
			t.Errorf("wrong result\ngot: %s\nwant: %s", got, expectedToken)
		}
	})

	t.Run("casing is insensitive", func(t *testing.T) {
		envName := "TF_TOKEN_CONFIGUREDUPPERCASE_EXAMPLE_COM"
		expectedToken := "configured-by-env"

		t.Setenv(envName, expectedToken)

		hostname, _ := svchost.ForComparison("configureduppercase.example.com")
		creds, err := credSrc.ForHost(hostname)

		if err != nil {
			t.Fatalf("unexpected error: %s", err)
		}

		if creds == nil {
			t.Fatal("no credentials found")
		}

		if got := creds.Token(); got != expectedToken {
			t.Errorf("wrong result\ngot: %s\nwant: %s", got, expectedToken)
		}
	})
}

func TestCredentialsStoreForget(t *testing.T) {
	d := t.TempDir()

	mockCredsFilename := filepath.Join(d, "credentials.tfrc.json")

	cfg := &Config{
		// This simulates there being a credentials block manually configured
		// in some file _other than_ credentials.tfrc.json.
		Credentials: map[string]map[string]interface{}{
			"manually-configured.example.com": {
				"token": "manually-configured",
			},
		},
	}

	// We'll initially use a credentials source with no credentials helper at
	// all, and thus with credentials stored in the credentials file.
	credSrc := cfg.credentialsSource(
		"", nil,
		mockCredsFilename,
	)

	testReqAuthHeader := func(t *testing.T, creds svcauth.HostCredentials) string {
		t.Helper()

		if creds == nil {
			return ""
		}

		req, err := http.NewRequest("GET", "http://example.com/", nil)
		if err != nil {
			t.Fatalf("cannot construct HTTP request: %s", err)
		}
		creds.PrepareRequest(req)
		return req.Header.Get("Authorization")
	}

	// Because these store/forget calls have side-effects, we'll bail out with
	// t.Fatal (or equivalent) as soon as anything unexpected happens.
	// Otherwise downstream tests might fail in confusing ways.
	{
		err := credSrc.StoreForHost(
			svchost.Hostname("manually-configured.example.com"),
			svcauth.HostCredentialsToken("not-manually-configured"),
		)
		if err == nil {
			t.Fatalf("successfully stored for manually-configured; want error")
		}
		if _, ok := err.(ErrUnwritableHostCredentials); !ok {
			t.Fatalf("wrong error type %T; want ErrUnwritableHostCredentials", err)
		}
	}
	{
		err := credSrc.ForgetForHost(
			svchost.Hostname("manually-configured.example.com"),
		)
		if err == nil {
			t.Fatalf("successfully forgot for manually-configured; want error")
		}
		if _, ok := err.(ErrUnwritableHostCredentials); !ok {
			t.Fatalf("wrong error type %T; want ErrUnwritableHostCredentials", err)
		}
	}
	{
		// We don't have a credentials file at all yet, so this first call
		// must create it.
		err := credSrc.StoreForHost(
			svchost.Hostname("stored-locally.example.com"),
			svcauth.HostCredentialsToken("stored-locally"),
		)
		if err != nil {
			t.Fatalf("unexpected error storing locally: %s", err)
		}

		creds, err := credSrc.ForHost(svchost.Hostname("stored-locally.example.com"))
		if err != nil {
			t.Fatalf("failed to read back stored-locally credentials: %s", err)
		}

		if got, want := testReqAuthHeader(t, creds), "Bearer stored-locally"; got != want {
			t.Fatalf("wrong header value for stored-locally\ngot:  %s\nwant: %s", got, want)
		}

		got := readHostsInCredentialsFile(mockCredsFilename)
		want := map[svchost.Hostname]struct{}{
			svchost.Hostname("stored-locally.example.com"): struct{}{},
		}
		if diff := cmp.Diff(want, got); diff != "" {
			t.Fatalf("wrong credentials file content\n%s", diff)
		}
	}

	// Now we'll switch to having a credential helper active.
	// If we were loading the real CLI config from disk here then this
	// entry would already be in cfg.Credentials, but we need to fake that
	// in the test because we're constructing this *Config value directly.
	cfg.Credentials["stored-locally.example.com"] = map[string]interface{}{
		"token": "stored-locally",
	}
	mockHelper := &mockCredentialsHelper{current: make(map[svchost.Hostname]cty.Value)}
	credSrc = cfg.credentialsSource(
		"mock", mockHelper,
		mockCredsFilename,
	)
	{
		err := credSrc.StoreForHost(
			svchost.Hostname("manually-configured.example.com"),
			svcauth.HostCredentialsToken("not-manually-configured"),
		)
		if err == nil {
			t.Fatalf("successfully stored for manually-configured with helper active; want error")
		}
	}
	{
		err := credSrc.StoreForHost(
			svchost.Hostname("stored-in-helper.example.com"),
			svcauth.HostCredentialsToken("stored-in-helper"),
		)
		if err != nil {
			t.Fatalf("unexpected error storing in helper: %s", err)
		}

		creds, err := credSrc.ForHost(svchost.Hostname("stored-in-helper.example.com"))
		if err != nil {
			t.Fatalf("failed to read back stored-in-helper credentials: %s", err)
		}

		if got, want := testReqAuthHeader(t, creds), "Bearer stored-in-helper"; got != want {
			t.Fatalf("wrong header value for stored-in-helper\ngot:  %s\nwant: %s", got, want)
		}

		// Nothing should have changed in the saved credentials file
		got := readHostsInCredentialsFile(mockCredsFilename)
		want := map[svchost.Hostname]struct{}{
			svchost.Hostname("stored-locally.example.com"): struct{}{},
		}
		if diff := cmp.Diff(want, got); diff != "" {
			t.Fatalf("wrong credentials file content\n%s", diff)
		}
	}
	{
		// Because stored-locally is already in the credentials file, a new
		// store should be sent there rather than to the credentials helper.
		err := credSrc.StoreForHost(
			svchost.Hostname("stored-locally.example.com"),
			svcauth.HostCredentialsToken("stored-locally-again"),
		)
		if err != nil {
			t.Fatalf("unexpected error storing locally again: %s", err)
		}

		creds, err := credSrc.ForHost(svchost.Hostname("stored-locally.example.com"))
		if err != nil {
			t.Fatalf("failed to read back stored-locally credentials: %s", err)
		}

		if got, want := testReqAuthHeader(t, creds), "Bearer stored-locally-again"; got != want {
			t.Fatalf("wrong header value for stored-locally\ngot:  %s\nwant: %s", got, want)
		}
	}
	{
		// Forgetting a host already in the credentials file should remove it
		// from the credentials file, not from the helper.
		err := credSrc.ForgetForHost(
			svchost.Hostname("stored-locally.example.com"),
		)
		if err != nil {
			t.Fatalf("unexpected error forgetting locally: %s", err)
		}

		creds, err := credSrc.ForHost(svchost.Hostname("stored-locally.example.com"))
		if err != nil {
			t.Fatalf("failed to read back stored-locally credentials: %s", err)
		}

		if got, want := testReqAuthHeader(t, creds), ""; got != want {
			t.Fatalf("wrong header value for stored-locally\ngot:  %s\nwant: %s", got, want)
		}

		// Should not be present in the credentials file anymore
		got := readHostsInCredentialsFile(mockCredsFilename)
		want := map[svchost.Hostname]struct{}{}
		if diff := cmp.Diff(want, got); diff != "" {
			t.Fatalf("wrong credentials file content\n%s", diff)
		}
	}
	{
		err := credSrc.ForgetForHost(
			svchost.Hostname("stored-in-helper.example.com"),
		)
		if err != nil {
			t.Fatalf("unexpected error forgetting in helper: %s", err)
		}

		creds, err := credSrc.ForHost(svchost.Hostname("stored-in-helper.example.com"))
		if err != nil {
			t.Fatalf("failed to read back stored-in-helper credentials: %s", err)
		}

		if got, want := testReqAuthHeader(t, creds), ""; got != want {
			t.Fatalf("wrong header value for stored-in-helper\ngot:  %s\nwant: %s", got, want)
		}
	}

	{
		// Finally, the log in our mock helper should show that it was only
		// asked to deal with stored-in-helper, not stored-locally.
		got := mockHelper.log
		want := []mockCredentialsHelperChange{
			{
				Host:   svchost.Hostname("stored-in-helper.example.com"),
				Action: "store",
			},
			{
				Host:   svchost.Hostname("stored-in-helper.example.com"),
				Action: "forget",
			},
		}
		if diff := cmp.Diff(want, got); diff != "" {
			t.Errorf("unexpected credentials helper operation log\n%s", diff)
		}
	}
}

type mockCredentialsHelperChange struct {
	Host   svchost.Hostname
	Action string
}

type mockCredentialsHelper struct {
	current map[svchost.Hostname]cty.Value
	log     []mockCredentialsHelperChange
}

// Assertion that mockCredentialsHelper implements svcauth.CredentialsSource
var _ svcauth.CredentialsSource = (*mockCredentialsHelper)(nil)

func (s *mockCredentialsHelper) ForHost(hostname svchost.Hostname) (svcauth.HostCredentials, error) {
	v, ok := s.current[hostname]
	if !ok {
		return nil, nil
	}
	return svcauth.HostCredentialsFromObject(v), nil
}

func (s *mockCredentialsHelper) StoreForHost(hostname svchost.Hostname, new svcauth.HostCredentialsWritable) error {
	s.log = append(s.log, mockCredentialsHelperChange{
		Host:   hostname,
		Action: "store",
	})
	s.current[hostname] = new.ToStore()
	return nil
}

func (s *mockCredentialsHelper) ForgetForHost(hostname svchost.Hostname) error {
	s.log = append(s.log, mockCredentialsHelperChange{
		Host:   hostname,
		Action: "forget",
	})
	delete(s.current, hostname)
	return nil
}
