package main

import (
	"archive/zip"
	"context"
	"flag"
	"fmt"
	"io"
	"io/ioutil"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"time"

	getter "github.com/hashicorp/go-getter"
	"github.com/hashicorp/terraform-svchost/disco"
	"github.com/hashicorp/terraform/addrs"
	"github.com/hashicorp/terraform/httpclient"
	"github.com/hashicorp/terraform/internal/depsfile"
	"github.com/hashicorp/terraform/internal/getproviders"
	"github.com/hashicorp/terraform/internal/providercache"
	discovery "github.com/hashicorp/terraform/plugin/discovery"
	"github.com/hashicorp/terraform/tfdiags"
	"github.com/hashicorp/terraform/version"
	"github.com/mitchellh/cli"
)

var releaseHost = "https://releases.hashicorp.com"

var pluginDir = ".plugins"

type PackageCommand struct {
	ui cli.Ui
}

func (c *PackageCommand) Run(args []string) int {
	flags := flag.NewFlagSet("package", flag.ExitOnError)
	osPtr := flags.String("os", "", "Target operating system")
	archPtr := flags.String("arch", "", "Target CPU architecture")
	pluginDirPtr := flags.String("plugin-dir", "", "Path to custom plugins directory")
	err := flags.Parse(args)
	if err != nil {
		c.ui.Error(err.Error())
		return 1
	}

	osName := runtime.GOOS
	archName := runtime.GOARCH
	if *osPtr != "" {
		osName = *osPtr
	}
	if *archPtr != "" {
		archName = *archPtr
	}
	if *pluginDirPtr != "" {
		pluginDir = *pluginDirPtr
	}

	if flags.NArg() != 1 {
		c.ui.Error("Configuration filename is required")
		return 1
	}
	configFn := flags.Arg(0)

	config, err := LoadConfigFile(configFn)
	if err != nil {
		c.ui.Error(fmt.Sprintf("Failed to read config: %s", err))
		return 1
	}

	tmpDir, err := ioutil.TempDir("", "terraform-bundle")
	if err != nil {
		c.ui.Error(fmt.Sprintf("Could not create temporary dir: %s", err))
		return 1
	}
	// symlinked tmp directories can cause odd behaviors.
	workDir, err := filepath.EvalSymlinks(tmpDir)
	if err != nil {
		c.ui.Error(fmt.Sprintf("Error evaulating symlinks: %s", err))
		return 1
	}
	defer os.RemoveAll(workDir)

	c.ui.Info(fmt.Sprintf("Fetching Terraform %s core package...", config.Terraform.Version))

	coreZipURL := c.coreURL(config.Terraform.Version, osName, archName)
	err = getter.Get(workDir, coreZipURL)
	if err != nil {
		c.ui.Error(fmt.Sprintf("Failed to fetch core package from %s: %s", coreZipURL, err))
		return 1
	}

	// get the list of required providers from the config
	reqs := make(map[addrs.Provider][]string)
	for name, provider := range config.Providers {
		var fqn addrs.Provider
		var diags tfdiags.Diagnostics
		if provider.Source != "" {
			fqn, diags = addrs.ParseProviderSourceString(provider.Source)
			if diags.HasErrors() {
				c.ui.Error(fmt.Sprintf("Invalid provider source string: %s", provider.Source))
				return 1
			}
		} else {
			fqn = addrs.NewDefaultProvider(name)
		}
		reqs[fqn] = provider.Versions
	}

	// set up the provider installer
	platform := getproviders.Platform{
		OS:   osName,
		Arch: archName,
	}
	installdir := providercache.NewDirWithPlatform(filepath.Join(workDir, "plugins"), platform)

	services := disco.New()
	services.SetUserAgent(httpclient.TerraformUserAgent(version.String()))
	var sources []getproviders.MultiSourceSelector

	// Find any local providers first so we can exclude these from the registry
	// install. We'll just silently ignore any errors and assume it would fail
	// real installation later too.
	foundLocally := map[addrs.Provider]struct{}{}

	if absPluginDir, err := filepath.Abs(pluginDir); err == nil {
		c.ui.Info(fmt.Sprintf("Local plugin directory %q found; scanning for provider binaries.", pluginDir))
		if _, err := os.Stat(absPluginDir); err == nil {
			localSource := getproviders.NewFilesystemMirrorSource(absPluginDir)
			if available, err := localSource.AllAvailablePackages(); err == nil {
				for found := range available {
					c.ui.Info(fmt.Sprintf("Found provider %q in %q. p", found.String(), pluginDir))
					foundLocally[found] = struct{}{}
				}
			}
			sources = append(sources, getproviders.MultiSourceSelector{
				Source: localSource,
			})
			if len(foundLocally) == 0 {
				c.ui.Info(fmt.Sprintf("No local providers found in %q.", pluginDir))
			}
		} else {
			c.ui.Info(fmt.Sprintf("No %q directory found, skipping local provider discovery.", pluginDir))
		}
	}

	// Anything we found in local directories above is excluded from being
	// looked up via the registry source we're about to construct.
	var directExcluded getproviders.MultiSourceMatchingPatterns
	for addr := range foundLocally {
		directExcluded = append(directExcluded, addr)
	}

	// Add the registry source, minus any providers found in the local pluginDir.
	sources = append(sources, getproviders.MultiSourceSelector{
		Source:  getproviders.NewMemoizeSource(getproviders.NewRegistrySource(services)),
		Exclude: directExcluded,
	})

	installer := providercache.NewInstaller(installdir, getproviders.MultiSource(sources))

	err = c.ensureProviderVersions(installer, reqs)
	if err != nil {
		c.ui.Error(err.Error())
		return 1
	}

	// remove the selections.json file created by the provider installer
	os.Remove(filepath.Join(workDir, "plugins", "selections.json"))

	// If we get this far then our workDir now contains the union of the
	// contents of all the zip files we downloaded above. We can now create
	// our output file.
	outFn := c.bundleFilename(config.Terraform.Version, time.Now(), osName, archName)
	c.ui.Info(fmt.Sprintf("Creating %s ...", outFn))
	outF, err := os.OpenFile(outFn, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, os.ModePerm)
	if err != nil {
		c.ui.Error(fmt.Sprintf("Failed to create %s: %s", outFn, err))
		return 1
	}
	outZ := zip.NewWriter(outF)
	defer func() {
		err := outZ.Close()
		if err != nil {
			c.ui.Error(fmt.Sprintf("Failed to close %s: %s", outFn, err))
			os.Exit(1)
		}
		err = outF.Close()
		if err != nil {
			c.ui.Error(fmt.Sprintf("Failed to close %s: %s", outFn, err))
			os.Exit(1)
		}
	}()

	// recursively walk the workDir to get a list of all binary filepaths
	err = filepath.Walk(workDir,
		func(path string, info os.FileInfo, err error) error {
			if err != nil {
				return err
			}
			if info.IsDir() {
				return nil
			}

			// maybe symlinks
			linkPath, err := filepath.EvalSymlinks(path)
			if err != nil {
				return err
			}
			linkInfo, err := os.Stat(linkPath)
			if err != nil {
				return err
			}

			if linkInfo.IsDir() {
				// The only time we should encounter a symlink directory is when we
				// have a locally-installed provider, so we will grab the provider
				// binary from that file.
				files, err := ioutil.ReadDir(linkPath)
				if err != nil {
					return err
				}
				for _, file := range files {
					if strings.Contains(file.Name(), "terraform-provider") {
						relPath, _ := filepath.Rel(workDir, path)
						return addZipFile(
							filepath.Join(linkPath, file.Name()), // the link to this provider binary
							filepath.Join(relPath, file.Name()),  // the expected directory for the binary
							file, outZ,
						)
					}
				}
				// This shouldn't happen - we should always find a provider
				// binary and exit the loop - but on the chance it does not,
				// just continue.
				return nil
			}

			// provider plugins need to be created in the same relative directory structure
			absPath, err := filepath.Abs(linkPath)
			if err != nil {
				return err
			}
			relPath, err := filepath.Rel(workDir, absPath)
			if err != nil {
				return err
			}

			return addZipFile(path, relPath, info, outZ)

		})

	if err != nil {
		c.ui.Error(err.Error())
		return 1
	}
	c.ui.Info("All done!")

	return 0
}

// addZipFile is a helper function intneded to simplify customizing the file
// path when adding a file to the zip archive. The relPath is specified for
// provider binaries, which need to be zipped into the full directory hierarchy.
func addZipFile(fn, relPath string, info os.FileInfo, outZ *zip.Writer) error {
	hdr, err := zip.FileInfoHeader(info)
	if err != nil {
		return fmt.Errorf("Failed to add zip entry for %s: %s", fn, err)
	}
	hdr.Method = zip.Deflate // be sure to compress files
	hdr.Name = relPath       // we need the full, relative path to the provider binary
	w, err := outZ.CreateHeader(hdr)
	if err != nil {
		return fmt.Errorf("Failed to add zip entry for %s: %s", fn, err)
	}

	r, err := os.Open(fn)
	if err != nil {
		return fmt.Errorf("Failed to open %s: %s", fn, err)
	}
	_, err = io.Copy(w, r)
	if err != nil {
		return fmt.Errorf("Failed to write %s to bundle: %s", fn, err)
	}
	return nil
}

func (c *PackageCommand) bundleFilename(version discovery.VersionStr, time time.Time, osName, archName string) string {
	time = time.UTC()
	return fmt.Sprintf(
		"terraform_%s-bundle%04d%02d%02d%02d_%s_%s.zip",
		version,
		time.Year(), time.Month(), time.Day(), time.Hour(),
		osName, archName,
	)
}

func (c *PackageCommand) coreURL(version discovery.VersionStr, osName, archName string) string {
	return fmt.Sprintf(
		"%s/terraform/%s/terraform_%s_%s_%s.zip",
		releaseHost, version, version, osName, archName,
	)
}

func (c *PackageCommand) Synopsis() string {
	return "Produces a bundle archive"
}

func (c *PackageCommand) Help() string {
	return `Usage: terraform-bundle package [options] <config-file>

Uses the given bundle configuration file to produce a zip file in the
current working directory containing a Terraform binary along with zero or
more provider plugin binaries.

Options:
  -os=name    		Target operating system the archive will be built for. Defaults
              		to that of the system where the command is being run.

  -arch=name  		Target CPU architecture the archive will be built for. Defaults
					to that of the system where the command is being run.
					  
  -plugin-dir=path 	The path to the custom plugins directory. Defaults to "./plugins".

The resulting zip file can be used to more easily install Terraform and
a fixed set of providers together on a server, so that Terraform's provider
auto-installation mechanism can be avoided.

To build an archive for Terraform Enterprise, use:
  -os=linux -arch=amd64

Note that the given configuration file is a format specific to this command,
not a normal Terraform configuration file. The file format looks like this:

  terraform {
    # Version of Terraform to include in the bundle. An exact version number
	# is required.
    version = "0.13.0"
  }

  # Define which provider plugins are to be included
  providers {
    # Include the newest "aws" provider version in the 1.0 series.
    aws = {
		versions = ["~> 1.0"]
	}

    # Include both the newest 1.0 and 2.0 versions of the "google" provider.
    # Each item in these lists allows a distinct version to be added. If the
	# two expressions match different versions then _both_ are included in
	# the bundle archive.
	google = {
		versions = ["~> 1.0", "~> 2.0"]
	}

	# Include a custom plugin to the bundle. Will search for the plugin in the 
	# plugins directory, and package it with the bundle archive. Plugin must 
	# have a name of the form: terraform-provider-*, and must be built with
	# the operating system and architecture that terraform enterprise is running,
	# e.g. linux and amd64.
	# See the README for more information on the source attribute and plugin
	# directory layout.
	customplugin = {
		versions = ["0.1"]
		source = "example.com/myorg/customplugin"
	}
  }

`
}

// ensureProviderVersions is a wrapper around
// providercache.EnsureProviderVersions which allows installing multiple
// versions of a given provider.
func (c *PackageCommand) ensureProviderVersions(installer *providercache.Installer, reqs map[addrs.Provider][]string) error {
	mode := providercache.InstallNewProvidersOnly
	evts := &providercache.InstallerEvents{
		ProviderAlreadyInstalled: func(provider addrs.Provider, selectedVersion getproviders.Version) {
			c.ui.Info(fmt.Sprintf("- Using previously-installed %s v%s", provider.ForDisplay(), selectedVersion))
		},
		QueryPackagesBegin: func(provider addrs.Provider, versionConstraints getproviders.VersionConstraints, locked bool) {
			if len(versionConstraints) > 0 {
				c.ui.Info(fmt.Sprintf("- Finding %s versions matching %q...", provider.ForDisplay(), getproviders.VersionConstraintsString(versionConstraints)))
			} else {
				c.ui.Info(fmt.Sprintf("- Finding latest version of %s...", provider.ForDisplay()))
			}
		},
		FetchPackageBegin: func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) {
			c.ui.Info(fmt.Sprintf("- Installing %s v%s...", provider.ForDisplay(), version))
		},
		QueryPackagesFailure: func(provider addrs.Provider, err error) {
			c.ui.Error(fmt.Sprintf("Could not retrieve the list of available versions for provider %s: %s.", provider.ForDisplay(), err))
		},
		FetchPackageFailure: func(provider addrs.Provider, version getproviders.Version, err error) {
			c.ui.Error(fmt.Sprintf("Error while installing %s v%s: %s.", provider.ForDisplay(), version, err))
		},
	}

	ctx := evts.OnContext(context.TODO())
	for provider, versions := range reqs {
		for _, constraint := range versions {
			req := make(getproviders.Requirements, 1)
			cstr, err := getproviders.ParseVersionConstraints(constraint)
			if err != nil {
				return err
			}
			req[provider] = cstr

			// We always start with no locks here, because we want to take
			// the newest version matching the given version constraint, and
			// never consider anything that might've been selected before.
			locks := depsfile.NewLocks()

			_, err = installer.EnsureProviderVersions(ctx, locks, req, mode)
			if err != nil {
				return err
			}
		}
	}

	return nil
}
