// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package command

import (
	"bytes"
	"fmt"
	"io"
	"log"
	"net/url"
	"os"
	"os/exec"
	"path"
	"runtime"

	"google.golang.org/grpc/metadata"

	"github.com/hashicorp/go-plugin"
	"github.com/hashicorp/terraform/internal/cloud"
	"github.com/hashicorp/terraform/internal/cloudplugin"
	"github.com/hashicorp/terraform/internal/cloudplugin/cloudplugin1"
	"github.com/hashicorp/terraform/internal/logging"
	"github.com/hashicorp/terraform/internal/tfdiags"
)

// CloudCommand is a Command implementation that interacts with Terraform
// Cloud for operations that are inherently planless. It delegates
// all execution to an internal plugin.
type CloudCommand struct {
	Meta
	// Path to the plugin server executable
	pluginBinary string
	// Service URL we can download plugin release binaries from
	pluginService *url.URL
	// Everything the plugin needs to build a client and Do Things
	pluginConfig CloudPluginConfig
}

const (
	// DefaultCloudPluginVersion is the implied protocol version, though all
	// historical versions are defined explicitly.
	DefaultCloudPluginVersion = 1

	// ExitRPCError is the exit code that is returned if an plugin
	// communication error occurred.
	ExitRPCError = 99

	// ExitPluginError is the exit code that is returned if the plugin
	// cannot be downloaded.
	ExitPluginError = 98

	// The regular HCP Terraform API service that the go-tfe client relies on.
	tfeServiceID = "tfe.v2"
	// The cloud plugin release download service that the BinaryManager relies
	// on to fetch the plugin.
	cloudpluginServiceID = "cloudplugin.v1"
)

var (
	// Handshake is used to verify that the plugin is the appropriate plugin for
	// the client. This is not a security verification.
	Handshake = plugin.HandshakeConfig{
		MagicCookieKey:   "TF_CLOUDPLUGIN_MAGIC_COOKIE",
		MagicCookieValue: "721fca41431b780ff3ad2623838faaa178d74c65e1cfdfe19537c31656496bf9f82d6c6707f71d81c8eed0db9043f79e56ab4582d013bc08ead14f57961461dc",
		ProtocolVersion:  DefaultCloudPluginVersion,
	}
	// CloudPluginDataDir is the name of the directory within the data directory
	CloudPluginDataDir = "cloudplugin"
)

func (c *CloudCommand) realRun(args []string, stdout, stderr io.Writer) int {
	args = c.Meta.process(args)

	diags := c.initPlugin()
	if diags.HasWarnings() || diags.HasErrors() {
		c.View.Diagnostics(diags)
	}
	if diags.HasErrors() {
		return ExitPluginError
	}

	client := plugin.NewClient(&plugin.ClientConfig{
		HandshakeConfig:  Handshake,
		AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
		Cmd:              exec.Command(c.pluginBinary),
		Logger:           logging.NewCloudLogger(),
		VersionedPlugins: map[int]plugin.PluginSet{
			1: {
				"cloud": &cloudplugin1.GRPCCloudPlugin{
					Metadata: c.pluginConfig.ToMetadata(),
				},
			},
		},
	})
	defer client.Kill()

	// Connect via RPC
	rpcClient, err := client.Client()
	if err != nil {
		fmt.Fprintf(stderr, "Failed to create cloud plugin client: %s", err)
		return ExitRPCError
	}

	// Request the plugin
	raw, err := rpcClient.Dispense("cloud")
	if err != nil {
		fmt.Fprintf(stderr, "Failed to request cloud plugin interface: %s", err)
		return ExitRPCError
	}

	// Proxy the request
	// Note: future changes will need to determine the type of raw when
	// multiple versions are possible.
	cloud1, ok := raw.(cloudplugin.Cloud1)
	if !ok {
		c.Ui.Error("If more than one cloudplugin versions are available, they need to be added to the cloud command. This is a bug in Terraform.")
		return ExitRPCError
	}
	return cloud1.Execute(args, stdout, stderr)
}

// discoverAndConfigure is an implementation detail of initPlugin. It fills in the
// pluginService and pluginConfig fields on a CloudCommand struct.
func (c *CloudCommand) discoverAndConfigure() tfdiags.Diagnostics {
	var diags tfdiags.Diagnostics

	// First, spin up a Cloud backend. (Why? bc finding the info the plugin
	// needs is hard, and the Cloud backend already knows how to do it all.)
	backendConfig, bConfigDiags := c.loadBackendConfig(".")
	diags = diags.Append(bConfigDiags)
	if diags.HasErrors() {
		return diags
	}
	b, backendDiags := c.Backend(&BackendOpts{
		Config: backendConfig,
	})
	diags = diags.Append(backendDiags)
	if diags.HasErrors() {
		return diags
	}
	cb, ok := b.(*cloud.Cloud)
	if !ok {
		diags = diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			"No `cloud` block found",
			"Cloud command requires that a `cloud` block be configured in the working directory",
		))
		return diags
	}

	// Ok sweet. First, re-use the cached service discovery info for this TFC
	// instance to find our plugin service and TFE API URLs:
	pluginService, err := cb.ServicesHost.ServiceURL(cloudpluginServiceID)
	if err != nil {
		return diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			"Cloud plugin service not found",
			err.Error(),
		))
	}
	c.pluginService = pluginService

	tfeService, err := cb.ServicesHost.ServiceURL(tfeServiceID)
	if err != nil {
		return diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			"HCP Terraform API service not found",
			err.Error(),
		))
	}

	currentWorkspace, err := c.Workspace()
	if err != nil {
		// The only possible error here is "you set TF_WORKSPACE badly"
		return diags.Append(tfdiags.Sourceless(
			tfdiags.Error,
			"Bad current workspace",
			err.Error(),
		))
	}

	// Now just steal everything we need so we can pass it to the plugin later.
	c.pluginConfig = CloudPluginConfig{
		Address:            tfeService.String(),
		BasePath:           tfeService.Path,
		DisplayHostname:    cb.Hostname,
		Token:              cb.Token,
		Organization:       cb.Organization,
		CurrentWorkspace:   currentWorkspace,
		WorkspaceName:      cb.WorkspaceMapping.Name,
		WorkspaceTags:      cb.WorkspaceMapping.Tags,
		DefaultProjectName: cb.WorkspaceMapping.Project,
	}

	return diags
}

func (c *CloudCommand) initPlugin() tfdiags.Diagnostics {
	var diags tfdiags.Diagnostics
	var errorSummary = "Cloud plugin initialization error"

	// Initialization can be aborted by interruption signals
	ctx, done := c.InterruptibleContext(c.CommandContext())
	defer done()

	// Discover service URLs, and build out the plugin config
	diags = diags.Append(c.discoverAndConfigure())
	if diags.HasErrors() {
		return diags
	}

	packagesPath, err := c.initPackagesCache()
	if err != nil {
		return diags.Append(tfdiags.Sourceless(tfdiags.Error, errorSummary, err.Error()))
	}

	overridePath := os.Getenv("TF_CLOUD_PLUGIN_DEV_OVERRIDE")

	bm, err := cloudplugin.NewBinaryManager(ctx, packagesPath, overridePath, c.pluginService, runtime.GOOS, runtime.GOARCH)
	if err != nil {
		return diags.Append(tfdiags.Sourceless(tfdiags.Error, errorSummary, err.Error()))
	}

	version, err := bm.Resolve()
	if err != nil {
		return diags.Append(tfdiags.Sourceless(tfdiags.Error, "Cloud plugin download error", err.Error()))
	}

	var cacheTraceMsg = ""
	if version.ResolvedFromCache {
		cacheTraceMsg = " (resolved from cache)"
	}
	if version.ResolvedFromDevOverride {
		cacheTraceMsg = " (resolved from dev override)"
		detailMsg := fmt.Sprintf("Instead of using the current released version, Terraform is loading the cloud plugin from the following location:\n\n - %s\n\nOverriding the cloud plugin location can cause unexpected behavior, and is only intended for use when developing new versions of the plugin.", version.Path)
		diags = diags.Append(tfdiags.Sourceless(
			tfdiags.Warning,
			"Cloud plugin development overrides are in effect",
			detailMsg,
		))
	}
	log.Printf("[TRACE] plugin %q binary located at %q%s", version.ProductVersion, version.Path, cacheTraceMsg)
	c.pluginBinary = version.Path
	return diags
}

func (c *CloudCommand) initPackagesCache() (string, error) {
	packagesPath := path.Join(c.WorkingDir.DataDir(), CloudPluginDataDir)

	if info, err := os.Stat(packagesPath); err != nil || !info.IsDir() {
		log.Printf("[TRACE] initialized cloudplugin cache directory at %q", packagesPath)
		err = os.MkdirAll(packagesPath, 0755)
		if err != nil {
			return "", fmt.Errorf("failed to initialize cloudplugin cache directory: %w", err)
		}
	} else {
		log.Printf("[TRACE] cloudplugin cache directory found at %q", packagesPath)
	}

	return packagesPath, nil
}

// Run runs the cloud command with the given arguments.
func (c *CloudCommand) Run(args []string) int {
	args = c.Meta.process(args)
	return c.realRun(args, c.Meta.Streams.Stdout.File, c.Meta.Streams.Stderr.File)
}

// Help returns help text for the cloud command.
func (c *CloudCommand) Help() string {
	helpText := new(bytes.Buffer)
	if exitCode := c.realRun([]string{}, helpText, io.Discard); exitCode != 0 {
		return ""
	}

	return helpText.String()
}

// Synopsis returns a short summary of the cloud command.
func (c *CloudCommand) Synopsis() string {
	return "Manage HCP Terraform settings and metadata"
}

// CloudPluginConfig is everything the cloud plugin needs to know to configure a
// client and talk to HCP Terraform.
type CloudPluginConfig struct {
	// Maybe someday we can use struct tags to automate grabbing these out of
	// the metadata headers! And verify client-side that we're sending the right
	// stuff, instead of having it all be a stringly-typed mystery ball! I want
	// to believe in that distant shining day! 🌻 Meantime, these struct tags
	// serve purely as docs.
	Address         string `md:"tfc-address"`
	BasePath        string `md:"tfc-base-path"`
	DisplayHostname string `md:"tfc-display-hostname"`
	Token           string `md:"tfc-token"`
	Organization    string `md:"tfc-organization"`
	// The actual selected workspace
	CurrentWorkspace string `md:"tfc-current-workspace"`

	// The raw "WorkspaceMapping" attributes, which determine the workspaces
	// that could be selected. Generally you want CurrentWorkspace instead, but
	// these can potentially be useful for niche use cases.
	WorkspaceName      string   `md:"tfc-workspace-name"`
	WorkspaceTags      []string `md:"tfc-workspace-tags"`
	DefaultProjectName string   `md:"tfc-default-project-name"`
}

func (c CloudPluginConfig) ToMetadata() metadata.MD {
	// First, do everything except tags the easy way
	md := metadata.Pairs(
		"tfc-address", c.Address,
		"tfc-base-path", c.BasePath,
		"tfc-display-hostname", c.DisplayHostname,
		"tfc-token", c.Token,
		"tfc-organization", c.Organization,
		"tfc-current-workspace", c.CurrentWorkspace,
		"tfc-workspace-name", c.WorkspaceName,
		"tfc-default-project-name", c.DefaultProjectName,
	)
	// Then the straggler
	md["tfc-workspace-tags"] = c.WorkspaceTags
	return md
}
