// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1

package rpcapi

import (
	"context"
	"io"
	"path/filepath"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/hashicorp/go-slug/sourceaddrs"
	"github.com/hashicorp/go-slug/sourcebundle"
	"github.com/hashicorp/terraform-svchost/disco"

	"github.com/hashicorp/terraform/internal/addrs"
	"github.com/hashicorp/terraform/internal/depsfile"
	"github.com/hashicorp/terraform/internal/getproviders"
	"github.com/hashicorp/terraform/internal/rpcapi/terraform1"
	"github.com/hashicorp/terraform/internal/rpcapi/terraform1/dependencies"

	"google.golang.org/grpc"
	"google.golang.org/protobuf/testing/protocmp"

	_ "github.com/hashicorp/terraform/internal/logging"
)

func TestDependenciesOpenCloseSourceBundle(t *testing.T) {
	ctx := context.Background()

	handles := newHandleTable()
	depsServer := newDependenciesServer(handles, disco.New())

	openResp, err := depsServer.OpenSourceBundle(ctx, &dependencies.OpenSourceBundle_Request{
		LocalPath: "testdata/sourcebundle",
	})
	if err != nil {
		t.Fatal(err)
	}

	// A client wouldn't normally be able to interact directly with the
	// source bundle, but we're doing that here to simulate what would
	// happen in another service that takes source bundle handles as input.
	// (This nested scope encapsulates some internal stuff that a normal client
	// would not have access to.)
	{
		hnd := handle[*sourcebundle.Bundle](openResp.SourceBundleHandle)
		sources := handles.SourceBundle(hnd)
		if sources == nil {
			t.Fatal("returned source bundle handle is invalid")
		}

		_, err = sources.LocalPathForSource(
			// The following is one of the source addresses known to the
			// source bundle we requested above.
			sourceaddrs.MustParseSource("git::https://example.com/foo.git").(sourceaddrs.FinalSource),
		)
		if err != nil {
			t.Fatalf("source bundle doesn't have the package we were expecting: %s", err)
		}
	}

	_, err = depsServer.CloseSourceBundle(ctx, &dependencies.CloseSourceBundle_Request{
		SourceBundleHandle: openResp.SourceBundleHandle,
	})
	if err != nil {
		t.Fatal(err)
	}
}

func TestDependencyLocks(t *testing.T) {
	ctx := context.Background()

	handles := newHandleTable()
	depsServer := newDependenciesServer(handles, disco.New())

	openSourcesResp, err := depsServer.OpenSourceBundle(ctx, &dependencies.OpenSourceBundle_Request{
		LocalPath: "testdata/sourcebundle",
	})
	if err != nil {
		t.Fatal(err)
	}
	defer func() {
		depsServer.CloseSourceBundle(ctx, &dependencies.CloseSourceBundle_Request{
			SourceBundleHandle: openSourcesResp.SourceBundleHandle,
		})
	}()

	openLocksResp, err := depsServer.OpenDependencyLockFile(ctx, &dependencies.OpenDependencyLockFile_Request{
		SourceBundleHandle: openSourcesResp.SourceBundleHandle,
		SourceAddress: &terraform1.SourceAddress{
			Source: "git::https://example.com/foo.git//.terraform.lock.hcl",
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	if len(openLocksResp.Diagnostics) != 0 {
		t.Error("OpenDependencyLockFile returned unexpected diagnostics")
	}

	// A client wouldn't normally be able to interact directly with the
	// locks object, but we're doing that here to simulate what would
	// happen in another service that takes dependency lock handles as input.
	// (This nested scope encapsulates some internal stuff that a normal client
	// would not have access to.)
	{
		hnd := handle[*depsfile.Locks](openLocksResp.DependencyLocksHandle)
		locks := handles.DependencyLocks(hnd)
		if locks == nil {
			t.Fatal("returned dependency locks handle is invalid")
		}

		wantProvider := addrs.MustParseProviderSourceString("example.com/foo/bar")
		got := locks.AllProviders()
		want := map[addrs.Provider]*depsfile.ProviderLock{
			wantProvider: depsfile.NewProviderLock(
				wantProvider, getproviders.MustParseVersion("1.2.3"),
				nil,
				[]getproviders.Hash{
					"zh:abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd",
				},
			),
		}
		if diff := cmp.Diff(want, got, cmp.AllowUnexported(depsfile.ProviderLock{})); diff != "" {
			t.Errorf("wrong locked providers\n%s", diff)
		}
	}

	getProvidersResp, err := depsServer.GetLockedProviderDependencies(ctx, &dependencies.GetLockedProviderDependencies_Request{
		DependencyLocksHandle: openLocksResp.DependencyLocksHandle,
	})
	if err != nil {
		t.Fatal(err)
	}
	wantProviderLocks := []*terraform1.ProviderPackage{
		{
			SourceAddr: "example.com/foo/bar",
			Version:    "1.2.3",
			Hashes: []string{
				"zh:abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd",
			},
		},
	}
	if diff := cmp.Diff(wantProviderLocks, getProvidersResp.SelectedProviders, protocmp.Transform()); diff != "" {
		t.Errorf("wrong GetLockedProviderDependencies result\n%s", diff)
	}

	_, err = depsServer.CloseDependencyLocks(ctx, &dependencies.CloseDependencyLocks_Request{
		DependencyLocksHandle: openLocksResp.DependencyLocksHandle,
	})
	if err != nil {
		t.Fatal(err)
	}

	// We should now be able to create a new locks handle referring to the
	// same providers as the one we just closed. This simulates a caller
	// propagating its provider locks between separate instances of rpcapi.
	newLocksResp, err := depsServer.CreateDependencyLocks(ctx, &dependencies.CreateDependencyLocks_Request{
		ProviderSelections: getProvidersResp.SelectedProviders,
	})
	if err != nil {
		t.Fatal(err)
	}

	getProvidersResp, err = depsServer.GetLockedProviderDependencies(ctx, &dependencies.GetLockedProviderDependencies_Request{
		DependencyLocksHandle: newLocksResp.DependencyLocksHandle,
	})
	if err != nil {
		t.Fatal(err)
	}
	if diff := cmp.Diff(wantProviderLocks, getProvidersResp.SelectedProviders, protocmp.Transform()); diff != "" {
		t.Errorf("wrong GetLockedProviderDependencies result\n%s", diff)
	}
}

func TestDependenciesProviderCache(t *testing.T) {
	ctx := context.Background()

	handles := newHandleTable()
	depsServer := newDependenciesServer(handles, disco.New())

	// This test involves a streaming RPC operation, so we'll need help from
	// a real in-memory gRPC connection to exercise it concisely so that
	// we can work with the client API rather than the server API.
	grpcClient, close := grpcClientForTesting(ctx, t, func(srv *grpc.Server) {
		dependencies.RegisterDependenciesServer(srv, depsServer)
	})
	defer close()
	depsClient := dependencies.NewDependenciesClient(grpcClient)

	openSourcesResp, err := depsClient.OpenSourceBundle(ctx, &dependencies.OpenSourceBundle_Request{
		LocalPath: "testdata/sourcebundle",
	})
	if err != nil {
		t.Fatal(err)
	}
	defer func() {
		_, err := depsClient.CloseSourceBundle(ctx, &dependencies.CloseSourceBundle_Request{
			SourceBundleHandle: openSourcesResp.SourceBundleHandle,
		})
		if err != nil {
			t.Error(err)
		}
	}()

	openLocksResp, err := depsClient.OpenDependencyLockFile(ctx, &dependencies.OpenDependencyLockFile_Request{
		SourceBundleHandle: openSourcesResp.SourceBundleHandle,
		SourceAddress: &terraform1.SourceAddress{
			Source: "git::https://example.com/foo.git//.terraform.lock.hcl",
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	if len(openLocksResp.Diagnostics) != 0 {
		t.Error("OpenDependencyLockFile returned unexpected diagnostics")
	}

	tmpDir := t.TempDir()
	cacheDir := filepath.Join(tmpDir, "pc")

	evts, err := depsClient.BuildProviderPluginCache(ctx, &dependencies.BuildProviderPluginCache_Request{
		DependencyLocksHandle: openLocksResp.DependencyLocksHandle,
		CacheDir:              cacheDir,

		// We force a local provider mirror and fake platform here just to keep
		// this test self-contained. This wraps the provider installer which
		// already has its own tests for the different installation methods,
		// so we don't need to be exhaustive about them all here.
		// (A real client of this API would typically just specify the "direct"
		// installation method, which retrieves packages from their origin
		// registries.)
		InstallationMethods: []*dependencies.BuildProviderPluginCache_Request_InstallMethod{
			{
				Source: &dependencies.BuildProviderPluginCache_Request_InstallMethod_LocalMirrorDir{
					LocalMirrorDir: "testdata/provider-fs-mirror",
				},
			},
		},
		OverridePlatform: "os_arch",
	})
	if err != nil {
		t.Fatal(err)
	}

	seenFakeProvider := false
	for {
		msg, err := evts.Recv()
		if err == io.EOF {
			break
		}
		if err != nil {
			t.Fatal(err) // not expecting any errors
		}

		// TODO: We're not comprehensively testing all of the events right now
		// since we're primarily interested in whether the provider gets
		// installed at all, but once clients start depending on the events
		// for UI purposes we ought to add more coverage here for the other
		// event types.
		switch evt := msg.Event.(type) {
		case *dependencies.BuildProviderPluginCache_Event_Diagnostic:
			t.Errorf("unexpected diagnostic:\n\n%s\n\n%s", evt.Diagnostic.Summary, evt.Diagnostic.Detail)
		case *dependencies.BuildProviderPluginCache_Event_FetchComplete_:
			if evt.FetchComplete.ProviderVersion.SourceAddr == "example.com/foo/bar" {
				seenFakeProvider = true
				if got, want := evt.FetchComplete.ProviderVersion.Version, "1.2.3"; got != want {
					t.Errorf("wrong provider version\ngot:  %s\nwant: %s", got, want)
				}
			}
		}
		t.Logf("installation event: %s", msg.String())
	}

	if !seenFakeProvider {
		t.Error("no 'fetch complete' event for example.com/foo/bar")
	}

	openCacheResp, err := depsClient.OpenProviderPluginCache(ctx, &dependencies.OpenProviderPluginCache_Request{
		CacheDir:         cacheDir,
		OverridePlatform: "os_arch",
	})
	if err != nil {
		t.Fatal(err)
	}
	defer func() {
		_, err := depsClient.CloseProviderPluginCache(ctx, &dependencies.CloseProviderPluginCache_Request{
			ProviderCacheHandle: openCacheResp.ProviderCacheHandle,
		})
		if err != nil {
			t.Error(err)
		}
	}()
	pkgsResp, err := depsClient.GetCachedProviders(ctx, &dependencies.GetCachedProviders_Request{
		ProviderCacheHandle: openCacheResp.ProviderCacheHandle,
	})
	if err != nil {
		t.Fatal(err)
	}

	got := pkgsResp.AvailableProviders
	want := []*terraform1.ProviderPackage{
		{
			SourceAddr: "example.com/foo/bar",
			Version:    "1.2.3",
			Hashes: []string{
				// This hash is of the fake package directory we installed
				// from, under testdata/provider-fs-mirror .
				"h1:cAp58lPuOAaPN9ZDdFHx9FxVK2NU0UeObQs2/zld9Lc=",
			},
		},
	}
	if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
		t.Errorf("wrong providers in cache reported after building\n%s", diff)
	}
}

func TestDependenciesProviderSchema(t *testing.T) {
	ctx := context.Background()

	handles := newHandleTable()
	depsServer := newDependenciesServer(handles, disco.New())

	providersResp, err := depsServer.GetBuiltInProviders(ctx, &dependencies.GetBuiltInProviders_Request{})
	if err != nil {
		t.Fatal(err)
	}
	{
		got := providersResp.AvailableProviders
		want := []*terraform1.ProviderPackage{
			{
				SourceAddr: "terraform.io/builtin/terraform",
			},
		}
		if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
			t.Errorf("wrong built-in providers\n%s", diff)
		}
	}

	schemaResp, err := depsServer.GetProviderSchema(ctx, &dependencies.GetProviderSchema_Request{
		ProviderAddr: "terraform.io/builtin/terraform",
	})
	if err != nil {
		t.Fatal(err)
	}
	{
		got := schemaResp.Schema

		want := &dependencies.ProviderSchema{
			ProviderConfig: &dependencies.Schema{
				Block: &dependencies.Schema_Block{
					// This provider has no configuration arguments
				},
			},
			DataResourceTypes: map[string]*dependencies.Schema{
				"terraform_remote_state": &dependencies.Schema{
					Block: &dependencies.Schema_Block{
						Attributes: []*dependencies.Schema_Attribute{
							{
								Name:     "backend",
								Type:     []byte(`"string"`),
								Required: true,
								Description: &dependencies.Schema_DocString{
									Description: "The remote backend to use, e.g. `remote` or `http`.",
									Format:      dependencies.Schema_DocString_MARKDOWN,
								},
							},
							{
								Name:     "config",
								Type:     []byte(`"dynamic"`),
								Optional: true,
								Description: &dependencies.Schema_DocString{
									Description: "The configuration of the remote backend. Although this is optional, most backends require some configuration.\n\nThe object can use any arguments that would be valid in the equivalent `terraform { backend \"<TYPE>\" { ... } }` block.",
									Format:      dependencies.Schema_DocString_MARKDOWN,
								},
							},
							{
								Name:     "defaults",
								Type:     []byte(`"dynamic"`),
								Optional: true,
								Description: &dependencies.Schema_DocString{
									Description: "Default values for outputs, in case the state file is empty or lacks a required output.",
									Format:      dependencies.Schema_DocString_MARKDOWN,
								},
							},
							{
								Name:     "outputs",
								Type:     []byte(`"dynamic"`),
								Computed: true,
								Description: &dependencies.Schema_DocString{
									Description: "An object containing every root-level output in the remote state.",
									Format:      dependencies.Schema_DocString_MARKDOWN,
								},
							},
							{
								Name:     "workspace",
								Type:     []byte(`"string"`),
								Optional: true,
								Description: &dependencies.Schema_DocString{
									Description: "The Terraform workspace to use, if the backend supports workspaces.",
									Format:      dependencies.Schema_DocString_MARKDOWN,
								},
							},
						},
					},
				},
			},
			ManagedResourceTypes: map[string]*dependencies.Schema{
				"terraform_data": &dependencies.Schema{
					Block: &dependencies.Schema_Block{
						Attributes: []*dependencies.Schema_Attribute{
							{
								Name:     "id",
								Type:     []byte(`"string"`),
								Computed: true,
							},
							{
								Name:     "input",
								Type:     []byte(`"dynamic"`),
								Optional: true,
							},
							{
								Name:     "output",
								Type:     []byte(`"dynamic"`),
								Computed: true,
							},
							{
								Name:     "triggers_replace",
								Type:     []byte(`"dynamic"`),
								Optional: true,
							},
						},
						BlockTypes: []*dependencies.Schema_NestedBlock{
							{
								TypeName: "store",
								Nesting:  dependencies.Schema_NestedBlock_SINGLE,
								Block: &dependencies.Schema_Block{
									Attributes: []*dependencies.Schema_Attribute{
										{
											Name:     "input",
											Type:     []byte(`"dynamic"`),
											Optional: true,
										},
										{
											Name:     "output",
											Type:     []byte(`"dynamic"`),
											Computed: true,
										},
										{
											Name:     "replace",
											Type:     []byte(`"bool"`),
											Optional: true,
										},
										{
											Name:     "sensitive",
											Type:     []byte(`"bool"`),
											Optional: true,
										},
										{
											Name:      "sensitive_output",
											Type:      []byte(`"dynamic"`),
											Computed:  true,
											Sensitive: true,
										},
										{
											Name:     "version",
											Type:     []byte(`"dynamic"`),
											Optional: true,
										},
									},
								},
							},
						},
					},
				},
			},
		}
		if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
			// NOTE: This is testing against the schema of a real provider
			// that can evolve independently of rpcapi. If that provider's
			// schema changes in future then it's expected that this test
			// will fail, and it's okay to change "want" to match as long as
			// it's a correct description of that provider's updated schema.
			//
			// If this turns out to be a big maintenence burden then we could
			// consider some way to include a mock provider, but that would
			// add another possible kind of provider into the mix and we'd
			// rather avoid that complexity if possible.
			t.Errorf("unexpected schema for the built-in 'terraform' provider\n%s", diff)
		}
	}

}
