package terraform

import (
	"context"
	"path/filepath"
	"sync"
	"time"

	"github.com/cli/safeexec"
	"github.com/hashicorp/go-version"
	"github.com/hashicorp/hc-install/product"
	"github.com/hashicorp/hc-install/releases"
	"golang.org/x/xerrors"

	"cdr.dev/slog"
	"github.com/coder/coder/provisionersdk"
)

var (
	// TerraformVersion is the version of Terraform used internally
	// when Terraform is not available on the system.
	TerraformVersion = version.Must(version.NewVersion("1.2.1"))

	minTerraformVersion = version.Must(version.NewVersion("1.1.0"))
	maxTerraformVersion = version.Must(version.NewVersion("1.2.1"))

	terraformMinorVersionMismatch = xerrors.New("Terraform binary minor version mismatch.")
)

const (
	defaultExitTimeout = 5 * time.Minute
)

type ServeOptions struct {
	*provisionersdk.ServeOptions

	// BinaryPath specifies the "terraform" binary to use.
	// If omitted, the $PATH will attempt to find it.
	BinaryPath string
	CachePath  string
	Logger     slog.Logger

	// ExitTimeout defines how long we will wait for a running Terraform
	// command to exit (cleanly) if the provision was stopped. This only
	// happens when the command is still running after the provision
	// stream is closed. If the provision is canceled via RPC, this
	// timeout will not be used.
	//
	// This is a no-op on Windows where the process can't be interrupted.
	//
	// Default value: 5 minutes.
	ExitTimeout time.Duration
}

func absoluteBinaryPath(ctx context.Context) (string, error) {
	binaryPath, err := safeexec.LookPath("terraform")
	if err != nil {
		return "", xerrors.Errorf("Terraform binary not found: %w", err)
	}

	// If the "coder" binary is in the same directory as
	// the "terraform" binary, "terraform" is returned.
	//
	// We must resolve the absolute path for other processes
	// to execute this properly!
	absoluteBinary, err := filepath.Abs(binaryPath)
	if err != nil {
		return "", xerrors.Errorf("Terraform binary absolute path not found: %w", err)
	}

	// Checking the installed version of Terraform.
	version, err := versionFromBinaryPath(ctx, absoluteBinary)
	if err != nil {
		return "", xerrors.Errorf("Terraform binary get version failed: %w", err)
	}

	if version.LessThan(minTerraformVersion) || version.GreaterThan(maxTerraformVersion) {
		return "", terraformMinorVersionMismatch
	}

	return absoluteBinary, nil
}

// Serve starts a dRPC server on the provided transport speaking Terraform provisioner.
func Serve(ctx context.Context, options *ServeOptions) error {
	if options.BinaryPath == "" {
		absoluteBinary, err := absoluteBinaryPath(ctx)
		if err != nil {
			// This is an early exit to prevent extra execution in case the context is canceled.
			// It generally happens in unit tests since this method is asynchronous and
			// the unit test kills the app before this is complete.
			if xerrors.Is(err, context.Canceled) {
				return xerrors.Errorf("absolute binary context canceled: %w", err)
			}

			installer := &releases.ExactVersion{
				InstallDir: options.CachePath,
				Product:    product.Terraform,
				Version:    TerraformVersion,
			}

			execPath, err := installer.Install(ctx)
			if err != nil {
				return xerrors.Errorf("install terraform: %w", err)
			}
			options.BinaryPath = execPath
		} else {
			options.BinaryPath = absoluteBinary
		}
	}
	if options.ExitTimeout == 0 {
		options.ExitTimeout = defaultExitTimeout
	}
	return provisionersdk.Serve(ctx, &server{
		binaryPath:  options.BinaryPath,
		cachePath:   options.CachePath,
		logger:      options.Logger,
		exitTimeout: options.ExitTimeout,
	}, options.ServeOptions)
}

type server struct {
	// initMu protects against executors running `terraform init`
	// concurrently when cache path is set.
	initMu sync.Mutex

	binaryPath string
	cachePath  string
	logger     slog.Logger

	exitTimeout time.Duration
}

func (s *server) executor(workdir string) executor {
	return executor{
		initMu:     &s.initMu,
		binaryPath: s.binaryPath,
		cachePath:  s.cachePath,
		workdir:    workdir,
	}
}
