package discovery

import (
	"errors"
	"fmt"
	"io"
	"io/ioutil"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"

	"github.com/hashicorp/errwrap"
	getter "github.com/hashicorp/go-getter"
	multierror "github.com/hashicorp/go-multierror"
	"github.com/hashicorp/terraform-plugin-sdk/internal/addrs"
	"github.com/hashicorp/terraform-plugin-sdk/internal/httpclient"
	"github.com/hashicorp/terraform-plugin-sdk/internal/registry"
	"github.com/hashicorp/terraform-plugin-sdk/internal/registry/regsrc"
	"github.com/hashicorp/terraform-plugin-sdk/internal/registry/response"
	"github.com/hashicorp/terraform-plugin-sdk/internal/tfdiags"
	tfversion "github.com/hashicorp/terraform-plugin-sdk/internal/version"
	"github.com/hashicorp/terraform-svchost/disco"
	"github.com/mitchellh/cli"
)

// Releases are located by querying the terraform registry.

var httpClient *http.Client

func init() {
	httpClient = httpclient.New()

	httpGetter := &getter.HttpGetter{
		Client: httpClient,
		Netrc:  true,
	}

	getter.Getters["http"] = httpGetter
	getter.Getters["https"] = httpGetter
}

// ProviderInstaller is an Installer implementation that knows how to
// download Terraform providers from the official HashiCorp releases service
// into a local directory. The files downloaded are compliant with the
// naming scheme expected by FindPlugins, so the target directory of a
// provider installer can be used as one of several plugin discovery sources.
type ProviderInstaller struct {
	Dir string

	// Cache is used to access and update a local cache of plugins if non-nil.
	// Can be nil to disable caching.
	Cache PluginCache

	PluginProtocolVersion uint

	// OS and Arch specify the OS and architecture that should be used when
	// installing plugins. These use the same labels as the runtime.GOOS and
	// runtime.GOARCH variables respectively, and indeed the values of these
	// are used as defaults if either of these is the empty string.
	OS   string
	Arch string

	// Skip checksum and signature verification
	SkipVerify bool

	Ui cli.Ui // Ui for output

	// Services is a required *disco.Disco, which may have services and
	// credentials pre-loaded.
	Services *disco.Disco

	// registry client
	registry *registry.Client
}

// Get is part of an implementation of type Installer, and attempts to download
// and install a Terraform provider matching the given constraints.
//
// This method may return one of a number of sentinel errors from this
// package to indicate issues that are likely to be resolvable via user action:
//
//     ErrorNoSuchProvider: no provider with the given name exists in the repository.
//     ErrorNoSuitableVersion: the provider exists but no available version matches constraints.
//     ErrorNoVersionCompatible: a plugin was found within the constraints but it is
//                               incompatible with the current Terraform version.
//
// These errors should be recognized and handled as special cases by the caller
// to present a suitable user-oriented error message.
//
// All other errors indicate an internal problem that is likely _not_ solvable
// through user action, or at least not within Terraform's scope. Error messages
// are produced under the assumption that if presented to the user they will
// be presented alongside context about what is being installed, and thus the
// error messages do not redundantly include such information.
func (i *ProviderInstaller) Get(provider addrs.ProviderType, req Constraints) (PluginMeta, tfdiags.Diagnostics, error) {
	var diags tfdiags.Diagnostics

	// a little bit of initialization.
	if i.OS == "" {
		i.OS = runtime.GOOS
	}
	if i.Arch == "" {
		i.Arch = runtime.GOARCH
	}
	if i.registry == nil {
		i.registry = registry.NewClient(i.Services, nil)
	}

	// get a full listing of versions for the requested provider
	allVersions, err := i.listProviderVersions(provider)

	// TODO: return multiple errors
	if err != nil {
		log.Printf("[DEBUG] %s", err)
		if registry.IsServiceUnreachable(err) {
			registryHost, err := i.hostname()
			if err == nil && registryHost == regsrc.PublicRegistryHost.Raw {
				return PluginMeta{}, diags, ErrorPublicRegistryUnreachable
			}
			return PluginMeta{}, diags, ErrorServiceUnreachable
		}
		if registry.IsServiceNotProvided(err) {
			return PluginMeta{}, diags, err
		}
		return PluginMeta{}, diags, ErrorNoSuchProvider
	}

	// Add any warnings from the response to diags
	for _, warning := range allVersions.Warnings {
		hostname, err := i.hostname()
		if err != nil {
			return PluginMeta{}, diags, err
		}
		diag := tfdiags.SimpleWarning(fmt.Sprintf("%s: %s", hostname, warning))
		diags = diags.Append(diag)
	}

	if len(allVersions.Versions) == 0 {
		return PluginMeta{}, diags, ErrorNoSuitableVersion
	}
	providerSource := allVersions.ID

	// Filter the list of plugin versions to those which meet the version constraints
	versions := allowedVersions(allVersions, req)
	if len(versions) == 0 {
		return PluginMeta{}, diags, ErrorNoSuitableVersion
	}

	// sort them newest to oldest. The newest version wins!
	response.ProviderVersionCollection(versions).Sort()

	// if the chosen provider version does not support the requested platform,
	// filter the list of acceptable versions to those that support that platform
	if err := i.checkPlatformCompatibility(versions[0]); err != nil {
		versions = i.platformCompatibleVersions(versions)
		if len(versions) == 0 {
			return PluginMeta{}, diags, ErrorNoVersionCompatibleWithPlatform
		}
	}

	// we now have a winning platform-compatible version
	versionMeta := versions[0]
	v := VersionStr(versionMeta.Version).MustParse()

	// check protocol compatibility
	if err := i.checkPluginProtocol(versionMeta); err != nil {
		closestMatch, err := i.findClosestProtocolCompatibleVersion(allVersions.Versions)
		if err != nil {
			// No operation here if we can't find a version with compatible protocol
			return PluginMeta{}, diags, err
		}

		// Prompt version suggestion to UI based on closest protocol match
		var errMsg string
		closestVersion := VersionStr(closestMatch.Version).MustParse()
		if v.NewerThan(closestVersion) {
			errMsg = providerProtocolTooNew
		} else {
			errMsg = providerProtocolTooOld
		}

		constraintStr := req.String()
		if constraintStr == "" {
			constraintStr = "(any version)"
		}

		return PluginMeta{}, diags, errwrap.Wrap(ErrorVersionIncompatible, fmt.Errorf(fmt.Sprintf(
			errMsg, provider, v.String(), tfversion.String(),
			closestVersion.String(), closestVersion.MinorUpgradeConstraintStr(), constraintStr)))
	}

	downloadURLs, err := i.listProviderDownloadURLs(providerSource, versionMeta.Version)
	if err != nil {
		return PluginMeta{}, diags, err
	}
	providerURL := downloadURLs.DownloadURL

	if !i.SkipVerify {
		// Terraform verifies the integrity of a provider release before downloading
		// the plugin binary. The digital signature (SHA256SUMS.sig) on the
		// release distribution (SHA256SUMS) is verified with the public key of the
		// publisher provided in the Terraform Registry response, ensuring that
		// everything is as intended by the publisher. The checksum of the provider
		// plugin is expected in the SHA256SUMS file and is double checked to match
		// the checksum of the original published release to the Registry. This
		// enforces immutability of releases between the Registry and the plugin's
		// host location. Lastly, the integrity of the binary is verified upon
		// download matches the Registry and signed checksum.
		sha256, err := i.getProviderChecksum(downloadURLs)
		if err != nil {
			return PluginMeta{}, diags, err
		}

		// add the checksum parameter for go-getter to verify the download for us.
		if sha256 != "" {
			providerURL = providerURL + "?checksum=sha256:" + sha256
		}
	}

	printedProviderName := fmt.Sprintf("%q (%s)", provider.Name, providerSource)
	i.Ui.Info(fmt.Sprintf("- Downloading plugin for provider %s %s...", printedProviderName, versionMeta.Version))
	log.Printf("[DEBUG] getting provider %s version %q", printedProviderName, versionMeta.Version)
	err = i.install(provider, v, providerURL)
	if err != nil {
		return PluginMeta{}, diags, err
	}

	// Find what we just installed
	// (This is weird, because go-getter doesn't directly return
	//  information about what was extracted, and we just extracted
	//  the archive directly into a shared dir here.)
	log.Printf("[DEBUG] looking for the %s %s plugin we just installed", provider.Name, versionMeta.Version)
	metas := FindPlugins("provider", []string{i.Dir})
	log.Printf("[DEBUG] all plugins found %#v", metas)
	metas, _ = metas.ValidateVersions()
	metas = metas.WithName(provider.Name).WithVersion(v)
	log.Printf("[DEBUG] filtered plugins %#v", metas)
	if metas.Count() == 0 {
		// This should never happen. Suggests that the release archive
		// contains an executable file whose name doesn't match the
		// expected convention.
		return PluginMeta{}, diags, fmt.Errorf(
			"failed to find installed plugin version %s; this is a bug in Terraform and should be reported",
			versionMeta.Version,
		)
	}

	if metas.Count() > 1 {
		// This should also never happen, and suggests that a
		// particular version was re-released with a different
		// executable filename. We consider releases as immutable, so
		// this is an error.
		return PluginMeta{}, diags, fmt.Errorf(
			"multiple plugins installed for version %s; this is a bug in Terraform and should be reported",
			versionMeta.Version,
		)
	}

	// By now we know we have exactly one meta, and so "Newest" will
	// return that one.
	return metas.Newest(), diags, nil
}

func (i *ProviderInstaller) install(provider addrs.ProviderType, version Version, url string) error {
	if i.Cache != nil {
		log.Printf("[DEBUG] looking for provider %s %s in plugin cache", provider.Name, version)
		cached := i.Cache.CachedPluginPath("provider", provider.Name, version)
		if cached == "" {
			log.Printf("[DEBUG] %s %s not yet in cache, so downloading %s", provider.Name, version, url)
			err := getter.Get(i.Cache.InstallDir(), url)
			if err != nil {
				return err
			}
			// should now be in cache
			cached = i.Cache.CachedPluginPath("provider", provider.Name, version)
			if cached == "" {
				// should never happen if the getter is behaving properly
				// and the plugins are packaged properly.
				return fmt.Errorf("failed to find downloaded plugin in cache %s", i.Cache.InstallDir())
			}
		}

		// Link or copy the cached binary into our install dir so the
		// normal resolution machinery can find it.
		filename := filepath.Base(cached)
		targetPath := filepath.Join(i.Dir, filename)
		// check if the target dir exists, and create it if not
		var err error
		if _, StatErr := os.Stat(i.Dir); os.IsNotExist(StatErr) {
			err = os.MkdirAll(i.Dir, 0700)
		}
		if err != nil {
			return err
		}

		log.Printf("[DEBUG] installing %s %s to %s from local cache %s", provider.Name, version, targetPath, cached)

		// Delete if we can. If there's nothing there already then no harm done.
		// This is important because we can't create a link if there's
		// already a file of the same name present.
		// (any other error here we'll catch below when we try to write here)
		os.Remove(targetPath)

		// We don't attempt linking on Windows because links are not
		// comprehensively supported by all tools/apps in Windows and
		// so we choose to be conservative to avoid creating any
		// weird issues for Windows users.
		linkErr := errors.New("link not supported for Windows") // placeholder error, never actually returned
		if runtime.GOOS != "windows" {
			// Try hard linking first. Hard links are preferable because this
			// creates a self-contained directory that doesn't depend on the
			// cache after install.
			linkErr = os.Link(cached, targetPath)

			// If that failed, try a symlink. This _does_ depend on the cache
			// after install, so the user must manage the cache more carefully
			// in this case, but avoids creating redundant copies of the
			// plugins on disk.
			if linkErr != nil {
				linkErr = os.Symlink(cached, targetPath)
			}
		}

		// If we still have an error then we'll try a copy as a fallback.
		// In this case either the OS is Windows or the target filesystem
		// can't support symlinks.
		if linkErr != nil {
			srcFile, err := os.Open(cached)
			if err != nil {
				return fmt.Errorf("failed to open cached plugin %s: %s", cached, err)
			}
			defer srcFile.Close()

			destFile, err := os.OpenFile(targetPath, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, os.ModePerm)
			if err != nil {
				return fmt.Errorf("failed to create %s: %s", targetPath, err)
			}

			_, err = io.Copy(destFile, srcFile)
			if err != nil {
				destFile.Close()
				return fmt.Errorf("failed to copy cached plugin from %s to %s: %s", cached, targetPath, err)
			}

			err = destFile.Close()
			if err != nil {
				return fmt.Errorf("error creating %s: %s", targetPath, err)
			}
		}

		// One way or another, by the time we get here we should have either
		// a link or a copy of the cached plugin within i.Dir, as expected.
	} else {
		log.Printf("[DEBUG] plugin cache is disabled, so downloading %s %s from %s", provider.Name, version, url)
		err := getter.Get(i.Dir, url)
		if err != nil {
			return err
		}
	}
	return nil
}

func (i *ProviderInstaller) PurgeUnused(used map[string]PluginMeta) (PluginMetaSet, error) {
	purge := make(PluginMetaSet)

	present := FindPlugins("provider", []string{i.Dir})
	for meta := range present {
		chosen, ok := used[meta.Name]
		if !ok {
			purge.Add(meta)
		}
		if chosen.Path != meta.Path {
			purge.Add(meta)
		}
	}

	removed := make(PluginMetaSet)
	var errs error
	for meta := range purge {
		path := meta.Path
		err := os.Remove(path)
		if err != nil {
			errs = multierror.Append(errs, fmt.Errorf(
				"failed to remove unused provider plugin %s: %s",
				path, err,
			))
		} else {
			removed.Add(meta)
		}
	}

	return removed, errs
}

func (i *ProviderInstaller) getProviderChecksum(resp *response.TerraformProviderPlatformLocation) (string, error) {
	// Get SHA256SUMS file.
	shasums, err := getFile(resp.ShasumsURL)
	if err != nil {
		log.Printf("[ERROR] error fetching checksums from %q: %s", resp.ShasumsURL, err)
		return "", ErrorMissingChecksumVerification
	}

	// Get SHA256SUMS.sig file.
	signature, err := getFile(resp.ShasumsSignatureURL)
	if err != nil {
		log.Printf("[ERROR] error fetching checksums signature from %q: %s", resp.ShasumsSignatureURL, err)
		return "", ErrorSignatureVerification
	}

	// Verify the GPG signature returned from the Registry.
	asciiArmor := resp.SigningKeys.GPGASCIIArmor()
	signer, err := verifySig(shasums, signature, asciiArmor)
	if err != nil {
		log.Printf("[ERROR] error verifying signature: %s", err)
		return "", ErrorSignatureVerification
	}

	// Also verify the GPG signature against the HashiCorp public key. This is
	// a temporary additional check until a more robust key verification
	// process is added in a future release.
	_, err = verifySig(shasums, signature, HashicorpPublicKey)
	if err != nil {
		log.Printf("[ERROR] error verifying signature against HashiCorp public key: %s", err)
		return "", ErrorSignatureVerification
	}

	// Display identity for GPG key which succeeded verifying the signature.
	// This could also be used to display to the user with i.Ui.Info().
	identities := []string{}
	for k := range signer.Identities {
		identities = append(identities, k)
	}
	identity := strings.Join(identities, ", ")
	log.Printf("[DEBUG] verified GPG signature with key from %s", identity)

	// Extract checksum for this os/arch platform binary and verify against Registry
	checksum := checksumForFile(shasums, resp.Filename)
	if checksum == "" {
		log.Printf("[ERROR] missing checksum for %s from source %s", resp.Filename, resp.ShasumsURL)
		return "", ErrorMissingChecksumVerification
	} else if checksum != resp.Shasum {
		log.Printf("[ERROR] unexpected checksum for %s from source %q", resp.Filename, resp.ShasumsURL)
		return "", ErrorChecksumVerification
	}

	return checksum, nil
}

func (i *ProviderInstaller) hostname() (string, error) {
	provider := regsrc.NewTerraformProvider("", i.OS, i.Arch)
	svchost, err := provider.SvcHost()
	if err != nil {
		return "", err
	}

	return svchost.ForDisplay(), nil
}

// list all versions available for the named provider
func (i *ProviderInstaller) listProviderVersions(provider addrs.ProviderType) (*response.TerraformProviderVersions, error) {
	req := regsrc.NewTerraformProvider(provider.Name, i.OS, i.Arch)
	versions, err := i.registry.TerraformProviderVersions(req)
	return versions, err
}

func (i *ProviderInstaller) listProviderDownloadURLs(name, version string) (*response.TerraformProviderPlatformLocation, error) {
	urls, err := i.registry.TerraformProviderLocation(regsrc.NewTerraformProvider(name, i.OS, i.Arch), version)
	if urls == nil {
		return nil, fmt.Errorf("No download urls found for provider %s", name)
	}
	return urls, err
}

// findClosestProtocolCompatibleVersion searches for the provider version with the closest protocol match.
// Prerelease versions are filtered.
func (i *ProviderInstaller) findClosestProtocolCompatibleVersion(versions []*response.TerraformProviderVersion) (*response.TerraformProviderVersion, error) {
	// Loop through all the provider versions to find the earliest and latest
	// versions that match the installer protocol to then select the closest of the two
	var latest, earliest *response.TerraformProviderVersion
	for _, version := range versions {
		// Prereleases are filtered and will not be suggested
		v, err := VersionStr(version.Version).Parse()
		if err != nil || v.IsPrerelease() {
			continue
		}

		if err := i.checkPluginProtocol(version); err == nil {
			if earliest == nil {
				// Found the first provider version with compatible protocol
				earliest = version
			}
			// Update the latest protocol compatible version
			latest = version
		}
	}
	if earliest == nil {
		// No compatible protocol was found for any version
		return nil, ErrorNoVersionCompatible
	}

	// Convert protocols to comparable types
	protoString := strconv.Itoa(int(i.PluginProtocolVersion))
	protocolVersion, err := VersionStr(protoString).Parse()
	if err != nil {
		return nil, fmt.Errorf("invalid plugin protocol version: %q", i.PluginProtocolVersion)
	}

	earliestVersionProtocol, err := VersionStr(earliest.Protocols[0]).Parse()
	if err != nil {
		return nil, err
	}

	// Compare installer protocol version with the first protocol listed of the earliest match
	// [A, B] where A is assumed the earliest compatible major version of the protocol pair
	if protocolVersion.NewerThan(earliestVersionProtocol) {
		// Provider protocols are too old, the closest version is the earliest compatible version
		return earliest, nil
	}

	// Provider protocols are too new, the closest version is the latest compatible version
	return latest, nil
}

func (i *ProviderInstaller) checkPluginProtocol(versionMeta *response.TerraformProviderVersion) error {
	// TODO: should this be a different error? We should probably differentiate between
	// no compatible versions and no protocol versions listed at all
	if len(versionMeta.Protocols) == 0 {
		return fmt.Errorf("no plugin protocol versions listed")
	}

	protoString := strconv.Itoa(int(i.PluginProtocolVersion))
	protocolVersion, err := VersionStr(protoString).Parse()
	if err != nil {
		return fmt.Errorf("invalid plugin protocol version: %q", i.PluginProtocolVersion)
	}
	protocolConstraint, err := protocolVersion.MinorUpgradeConstraintStr().Parse()
	if err != nil {
		// This should not fail if the preceding function succeeded.
		return fmt.Errorf("invalid plugin protocol version: %q", protocolVersion.String())
	}

	for _, p := range versionMeta.Protocols {
		proPro, err := VersionStr(p).Parse()
		if err != nil {
			// invalid protocol reported by the registry. Move along.
			log.Printf("[WARN] invalid provider protocol version %q found in the registry", versionMeta.Version)
			continue
		}
		// success!
		if protocolConstraint.Allows(proPro) {
			return nil
		}
	}

	return ErrorNoVersionCompatible
}

// platformCompatibleVersions returns a list of provider versions that are
// compatible with the requested platform.
func (i *ProviderInstaller) platformCompatibleVersions(versions []*response.TerraformProviderVersion) []*response.TerraformProviderVersion {
	var v []*response.TerraformProviderVersion
	for _, version := range versions {
		if err := i.checkPlatformCompatibility(version); err == nil {
			v = append(v, version)
		}
	}
	return v
}

func (i *ProviderInstaller) checkPlatformCompatibility(versionMeta *response.TerraformProviderVersion) error {
	if len(versionMeta.Platforms) == 0 {
		return fmt.Errorf("no supported provider platforms listed")
	}
	for _, p := range versionMeta.Platforms {
		if p.Arch == i.Arch && p.OS == i.OS {
			return nil
		}
	}
	return fmt.Errorf("version %s does not support the requested platform %s_%s", versionMeta.Version, i.OS, i.Arch)
}

// take the list of available versions for a plugin, and filter out those that
// don't fit the constraints.
func allowedVersions(available *response.TerraformProviderVersions, required Constraints) []*response.TerraformProviderVersion {
	var allowed []*response.TerraformProviderVersion

	for _, v := range available.Versions {
		version, err := VersionStr(v.Version).Parse()
		if err != nil {
			log.Printf("[WARN] invalid version found for %q: %s", available.ID, err)
			continue
		}
		if required.Allows(version) {
			allowed = append(allowed, v)
		}
	}
	return allowed
}

func checksumForFile(sums []byte, name string) string {
	for _, line := range strings.Split(string(sums), "\n") {
		parts := strings.Fields(line)
		if len(parts) > 1 && parts[1] == name {
			return parts[0]
		}
	}
	return ""
}

func getFile(url string) ([]byte, error) {
	resp, err := httpClient.Get(url)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("%s", resp.Status)
	}

	data, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return data, err
	}
	return data, nil
}

// providerProtocolTooOld is a message sent to the CLI UI if the provider's
// supported protocol versions are too old for the user's version of terraform,
// but an older version of the provider is compatible.
const providerProtocolTooOld = `
[reset][bold][red]Provider %q v%s is not compatible with Terraform %s.[reset][red]

Provider version %s is the earliest compatible version. Select it with 
the following version constraint:

	version = %q

Terraform checked all of the plugin versions matching the given constraint:
    %s

Consult the documentation for this provider for more information on
compatibility between provider and Terraform versions.
`

// providerProtocolTooNew is a message sent to the CLI UI if the provider's
// supported protocol versions are too new for the user's version of terraform,
// and the user could either upgrade terraform or choose an older version of the
// provider
const providerProtocolTooNew = `
[reset][bold][red]Provider %q v%s is not compatible with Terraform %s.[reset][red]

Provider version %s is the latest compatible version. Select it with 
the following constraint:

    version = %q

Terraform checked all of the plugin versions matching the given constraint:
    %s

Consult the documentation for this provider for more information on
compatibility between provider and Terraform versions.

Alternatively, upgrade to the latest version of Terraform for compatibility with newer provider releases.
`
