//go:generate mapstructure-to-hcl2 -type Config
//go:generate struct-markdown

package manifest

import (
	"context"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"os"
	"path/filepath"
	"time"

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

type Config struct {
	common.PackerConfig `mapstructure:",squash"`

	// The manifest will be written to this file. This defaults to
	// `packer-manifest.json`.
	OutputPath string `mapstructure:"output"`
	// Write only filename without the path to the manifest file. This defaults
	// to false.
	StripPath bool `mapstructure:"strip_path"`
	// Don't write the `build_time` field from the output.
	StripTime bool `mapstructure:"strip_time"`
	// Arbitrary data to add to the manifest. This is a [template
	// engine](https://packer.io/docs/templates/engine.html). Therefore, you
	// may use user variables and template functions in this field.
	CustomData map[string]string `mapstructure:"custom_data"`
	ctx        interpolate.Context
}

type PostProcessor struct {
	config Config
}

type ManifestFile struct {
	Builds      []Artifact `json:"builds"`
	LastRunUUID string     `json:"last_run_uuid"`
}

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

func (p *PostProcessor) Configure(raws ...interface{}) error {
	err := config.Decode(&p.config, &config.DecodeOpts{
		PluginType:         "packer.post-processor.manifest",
		Interpolate:        true,
		InterpolateContext: &p.config.ctx,
		InterpolateFilter: &interpolate.RenderFilter{
			Exclude: []string{},
		},
	}, raws...)
	if err != nil {
		return err
	}

	if p.config.OutputPath == "" {
		p.config.OutputPath = "packer-manifest.json"
	}

	if err = interpolate.Validate(p.config.OutputPath, &p.config.ctx); err != nil {
		return fmt.Errorf("Error parsing target template: %s", err)
	}

	return nil
}

func (p *PostProcessor) PostProcess(ctx context.Context, ui packersdk.Ui, source packersdk.Artifact) (packersdk.Artifact, bool, bool, error) {
	generatedData := source.State("generated_data")
	if generatedData == nil {
		// Make sure it's not a nil map so we can assign to it later.
		generatedData = make(map[string]interface{})
	}
	p.config.ctx.Data = generatedData

	for key, data := range p.config.CustomData {
		interpolatedData, err := createInterpolatedCustomData(&p.config, data)
		if err != nil {
			return nil, false, false, err
		}
		p.config.CustomData[key] = interpolatedData
	}

	artifact := &Artifact{}

	var err error
	var fi os.FileInfo

	// Create the current artifact.
	for _, name := range source.Files() {
		af := ArtifactFile{}
		if fi, err = os.Stat(name); err == nil {
			af.Size = fi.Size()
		}
		if p.config.StripPath {
			af.Name = filepath.Base(name)
		} else {
			af.Name = name
		}
		artifact.ArtifactFiles = append(artifact.ArtifactFiles, af)
	}
	artifact.ArtifactId = source.Id()
	artifact.CustomData = p.config.CustomData
	artifact.BuilderType = p.config.PackerBuilderType
	artifact.BuildName = p.config.PackerBuildName
	artifact.BuildTime = time.Now().Unix()
	if p.config.StripTime {
		artifact.BuildTime = 0
	}
	// Since each post-processor runs in a different process we need a way to
	// coordinate between various post-processors in a single packer run. We do
	// this by setting a UUID per run and tracking this in the manifest file.
	// When we detect that the UUID in the file is the same, we know that we are
	// part of the same run and we simply add our data to the list. If the UUID
	// is different we will check the -force flag and decide whether to truncate
	// the file before we proceed.
	artifact.PackerRunUUID = os.Getenv("PACKER_RUN_UUID")

	// Create a lock file with exclusive access. If this fails we will retry
	// after a delay.
	lockFilename := p.config.OutputPath + ".lock"
	for i := 0; i < 3; i++ {
		// The file should not be locked for very long so we'll keep this short.
		time.Sleep((time.Duration(i) * 200 * time.Millisecond))
		_, err = os.OpenFile(lockFilename, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
		if err == nil {
			break
		}
		log.Printf("Error locking manifest file for reading and writing. Will sleep and retry. %s", err)
	}
	defer os.Remove(lockFilename)

	// Read the current manifest file from disk
	contents := []byte{}
	if contents, err = ioutil.ReadFile(p.config.OutputPath); err != nil && !os.IsNotExist(err) {
		return source, true, true, fmt.Errorf("Unable to open %s for reading: %s", p.config.OutputPath, err)
	}

	// Parse the manifest file JSON, if we have one
	manifestFile := &ManifestFile{}
	if len(contents) > 0 {
		if err = json.Unmarshal(contents, manifestFile); err != nil {
			return source, true, true, fmt.Errorf("Unable to parse content from %s: %s", p.config.OutputPath, err)
		}
	}

	// If -force is set and we are not on same run, truncate the file. Otherwise
	// we will continue to add new builds to the existing manifest file.
	if p.config.PackerForce && os.Getenv("PACKER_RUN_UUID") != manifestFile.LastRunUUID {
		manifestFile = &ManifestFile{}
	}

	// Add the current artifact to the manifest file
	manifestFile.Builds = append(manifestFile.Builds, *artifact)
	manifestFile.LastRunUUID = os.Getenv("PACKER_RUN_UUID")

	// Write JSON to disk
	if out, err := json.MarshalIndent(manifestFile, "", "  "); err == nil {
		if err = ioutil.WriteFile(p.config.OutputPath, out, 0664); err != nil {
			return source, true, true, fmt.Errorf("Unable to write %s: %s", p.config.OutputPath, err)
		}
	} else {
		return source, true, true, fmt.Errorf("Unable to marshal JSON %s", err)
	}

	// The manifest should never delete the artifacts it is set to record, so it
	// forcibly sets "keep" to true.
	return source, true, true, nil
}

func createInterpolatedCustomData(config *Config, customData string) (string, error) {
	interpolatedCmd, err := interpolate.Render(customData, &config.ctx)
	if err != nil {
		return "", fmt.Errorf("Error interpolating custom data: %s", err)
	}
	return interpolatedCmd, nil
}
