//go:generate mapstructure-to-hcl2 -type Config,ModuleDir

// This package implements a provisioner for Packer that executes
// Converge to provision a remote machine

package converge

import (
	"bytes"
	"context"
	"errors"
	"fmt"

	"strings"

	"encoding/json"

	"github.com/hashicorp/hcl/v2/hcldec"
	"github.com/hashicorp/packer-plugin-sdk/common"
	packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
	"github.com/hashicorp/packer-plugin-sdk/template/config"
	"github.com/hashicorp/packer-plugin-sdk/template/interpolate"
)

// Config for Converge provisioner
type Config struct {
	common.PackerConfig `mapstructure:",squash"`

	// Bootstrapping
	Bootstrap            bool   `mapstructure:"bootstrap"`
	Version              string `mapstructure:"version"`
	BootstrapCommand     string `mapstructure:"bootstrap_command"`
	PreventBootstrapSudo bool   `mapstructure:"prevent_bootstrap_sudo"`

	// Modules
	ModuleDirs []ModuleDir `mapstructure:"module_dirs"`

	// Execution
	Module           string            `mapstructure:"module"`
	WorkingDirectory string            `mapstructure:"working_directory"`
	Params           map[string]string `mapstructure:"params"`
	ExecuteCommand   string            `mapstructure:"execute_command"`
	PreventSudo      bool              `mapstructure:"prevent_sudo"`

	ctx interpolate.Context
}

// ModuleDir is a directory to transfer to the remote system
type ModuleDir struct {
	Source      string   `mapstructure:"source"`
	Destination string   `mapstructure:"destination"`
	Exclude     []string `mapstructure:"exclude"`
}

// Provisioner for Converge
type Provisioner struct {
	config Config
}

func (p *Provisioner) ConfigSpec() hcldec.ObjectSpec { return p.config.FlatMapstructure().HCL2Spec() }

func (p *Provisioner) Prepare(raws ...interface{}) error {
	err := config.Decode(
		&p.config,
		&config.DecodeOpts{
			PluginType:         "converge",
			Interpolate:        true,
			InterpolateContext: &p.config.ctx,
			InterpolateFilter: &interpolate.RenderFilter{
				Exclude: []string{
					"execute_command",
					"bootstrap_command",
				},
			},
		},
		raws...,
	)
	if err != nil {
		return err
	}

	// require a single module
	if p.config.Module == "" {
		return errors.New("Converge requires a module to provision the system")
	}

	// set defaults
	if p.config.WorkingDirectory == "" {
		p.config.WorkingDirectory = "/tmp"
	}

	if p.config.ExecuteCommand == "" {
		p.config.ExecuteCommand = "cd {{.WorkingDirectory}} && {{if .Sudo}}sudo {{end}}converge apply --local --log-level=WARNING --paramsJSON '{{.ParamsJSON}}' {{.Module}}"
	}

	if p.config.BootstrapCommand == "" {
		p.config.BootstrapCommand = "curl -s https://get.converge.sh | {{if .Sudo}}sudo {{end}}sh {{if ne .Version \"\"}}-s -- -v {{.Version}}{{end}}"
	}

	// validate sources and destinations
	for i, dir := range p.config.ModuleDirs {
		if dir.Source == "" {
			return fmt.Errorf("Source (\"source\" key) is required in Converge module dir #%d", i)
		}
		if dir.Destination == "" {
			return fmt.Errorf("Destination (\"destination\" key) is required in Converge module dir #%d", i)
		}
	}

	return err
}

// Provision node somehow. TODO: actual docs
func (p *Provisioner) Provision(ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator, _ map[string]interface{}) error {
	ui.Say("Provisioning with Converge")

	// bootstrapping
	if err := p.maybeBootstrap(ui, comm); err != nil {
		return err // error messages are already user-friendly
	}

	// send module directories to the remote host
	if err := p.sendModuleDirectories(ui, comm); err != nil {
		return err // error messages are already user-friendly
	}

	// apply all the modules
	if err := p.applyModules(ui, comm); err != nil {
		return err // error messages are already user-friendly
	}

	return nil
}

func (p *Provisioner) maybeBootstrap(ui packersdk.Ui, comm packersdk.Communicator) error {
	ctx := context.TODO()
	if !p.config.Bootstrap {
		return nil
	}
	ui.Message("bootstrapping converge")

	p.config.ctx.Data = struct {
		Version string
		Sudo    bool
	}{
		Version: p.config.Version,
		Sudo:    !p.config.PreventBootstrapSudo,
	}
	command, err := interpolate.Render(p.config.BootstrapCommand, &p.config.ctx)
	if err != nil {
		return fmt.Errorf("Could not interpolate bootstrap command: %s", err)
	}

	var out, outErr bytes.Buffer
	cmd := &packersdk.RemoteCmd{
		Command: command,
		Stdin:   nil,
		Stdout:  &out,
		Stderr:  &outErr,
	}

	if err = comm.Start(ctx, cmd); err != nil {
		return fmt.Errorf("Error bootstrapping converge: %s", err)
	}

	cmd.Wait()
	if cmd.ExitStatus() != 0 {
		ui.Error(out.String())
		ui.Error(outErr.String())
		return errors.New("Error bootstrapping converge")
	}

	ui.Message(strings.TrimSpace(out.String()))
	return nil
}

func (p *Provisioner) sendModuleDirectories(ui packersdk.Ui, comm packersdk.Communicator) error {
	for _, dir := range p.config.ModuleDirs {
		if err := comm.UploadDir(dir.Destination, dir.Source, dir.Exclude); err != nil {
			return fmt.Errorf("Could not upload %q: %s", dir.Source, err)
		}
		ui.Message(fmt.Sprintf("transferred %q to %q", dir.Source, dir.Destination))
	}

	return nil
}

func (p *Provisioner) applyModules(ui packersdk.Ui, comm packersdk.Communicator) error {
	ctx := context.TODO()
	// create params JSON file
	params, err := json.Marshal(p.config.Params)
	if err != nil {
		return fmt.Errorf("Could not marshal parameters as JSON: %s", err)
	}

	p.config.ctx.Data = struct {
		ParamsJSON, WorkingDirectory, Module string
		Sudo                                 bool
	}{
		ParamsJSON:       string(params),
		WorkingDirectory: p.config.WorkingDirectory,
		Module:           p.config.Module,
		Sudo:             !p.config.PreventSudo,
	}
	command, err := interpolate.Render(p.config.ExecuteCommand, &p.config.ctx)
	if err != nil {
		return fmt.Errorf("Could not interpolate execute command: %s", err)
	}

	// run Converge in the specified directory
	var runOut, runErr bytes.Buffer
	cmd := &packersdk.RemoteCmd{
		Command: command,
		Stdin:   nil,
		Stdout:  &runOut,
		Stderr:  &runErr,
	}
	if err := comm.Start(ctx, cmd); err != nil {
		return fmt.Errorf("Error applying %q: %s", p.config.Module, err)
	}

	cmd.Wait()
	if cmd.ExitStatus() == 127 {
		ui.Error("Could not find Converge. Is it installed and in PATH?")
		if !p.config.Bootstrap {
			ui.Error("Bootstrapping was disabled for this run. That might be why Converge isn't present.")
		}

		return errors.New("Could not find Converge")

	} else if cmd.ExitStatus() != 0 {
		ui.Error(strings.TrimSpace(runOut.String()))
		ui.Error(strings.TrimSpace(runErr.String()))
		ui.Error(fmt.Sprintf("Exited with error code %d.", cmd.ExitStatus()))
		return fmt.Errorf("Error applying %q", p.config.Module)
	}

	ui.Message(strings.TrimSpace(runOut.String()))

	return nil
}