// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package e2e

import (
	"bytes"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"testing"

	"github.com/opentofu/opentofu/internal/encryption"
	"github.com/opentofu/opentofu/internal/plans"
	"github.com/opentofu/opentofu/internal/plans/planfile"
	"github.com/opentofu/opentofu/internal/states"
	"github.com/opentofu/opentofu/internal/states/statefile"
)

// Type binary represents the combination of a compiled binary
// and a temporary working directory to run it in.
type binary struct {
	binPath string
	workDir string
	env     []string
}

// NewBinary prepares a temporary directory containing the files from the
// given fixture and returns an instance of type binary that can run
// the generated binary in that directory.
//
// If the temporary directory cannot be created, a fixture of the given name
// cannot be found, or if an error occurs while _copying_ the fixture files,
// this function will panic. Tests should be written to assume that this
// function always succeeds.
func NewBinary(t *testing.T, binaryPath, workingDir string) *binary {
	tmpDir, err := filepath.EvalSymlinks(t.TempDir())
	if err != nil {
		panic(err)
	}

	// For our purposes here we do a very simplistic file copy that doesn't
	// attempt to preserve file permissions, attributes, alternate data
	// streams, etc. Since we only have to deal with our own fixtures in
	// the testdata subdir, we know we don't need to deal with anything
	// of this nature.
	err = filepath.Walk(workingDir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if path == workingDir {
			// nothing to do at the root
			return nil
		}

		if filepath.Base(path) == ".exists" {
			// We use this file just to let git know the "empty" fixture
			// exists. It is not used by any test.
			return nil
		}

		srcFn := path

		path, err = filepath.Rel(workingDir, path)
		if err != nil {
			return err
		}

		dstFn := filepath.Join(tmpDir, path)

		if info.IsDir() {
			return os.Mkdir(dstFn, os.ModePerm)
		}

		src, err := os.Open(srcFn)
		if err != nil {
			return err
		}
		dst, err := os.OpenFile(dstFn, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.ModePerm)
		if err != nil {
			return err
		}

		_, err = io.Copy(dst, src)
		if err != nil {
			return err
		}

		if err := src.Close(); err != nil {
			return err
		}
		if err := dst.Close(); err != nil {
			return err
		}

		return nil
	})
	if err != nil {
		panic(err)
	}

	return &binary{
		binPath: binaryPath,
		workDir: tmpDir,
	}
}

// AddEnv appends an entry to the environment variable table passed to any
// commands subsequently run.
func (b *binary) AddEnv(entry string) {
	b.env = append(b.env, entry)
}

// Cmd returns an exec.Cmd pre-configured to run the generated OpenTofu
// binary with the given arguments in the temporary working directory.
//
// The returned object can be mutated by the caller to customize how the
// process will be run, before calling Run.
func (b *binary) Cmd(args ...string) *exec.Cmd {
	cmd := exec.Command(b.binPath, args...)
	cmd.Dir = b.workDir
	cmd.Env = os.Environ()

	cmd.Env = append(cmd.Env, b.env...)

	return cmd
}

// Run executes the generated OpenTofu binary with the given arguments
// and returns the bytes that it wrote to both stdout and stderr.
//
// This is a simple way to run OpenTofu for non-interactive commands
// that don't need any special environment variables. For more complex
// situations, use Cmd and customize the command before running it.
func (b *binary) Run(args ...string) (stdout, stderr string, err error) {
	cmd := b.Cmd(args...)
	cmd.Stdin = nil
	cmd.Stdout = &bytes.Buffer{}
	cmd.Stderr = &bytes.Buffer{}
	err = cmd.Run()
	stdout = cmd.Stdout.(*bytes.Buffer).String()
	stderr = cmd.Stderr.(*bytes.Buffer).String()
	return
}

// Path returns a file path within the temporary working directory by
// appending the given arguments as path segments.
func (b *binary) Path(parts ...string) string {
	args := make([]string, 0, len(parts)+1)
	args = append(args, b.workDir)
	args = append(args, parts...)
	return filepath.Join(args...)
}

// OpenFile is a helper for easily opening a file from the working directory
// for reading.
func (b *binary) OpenFile(path ...string) (*os.File, error) {
	flatPath := b.Path(path...)
	return os.Open(flatPath)
}

// ReadFile is a helper for easily reading a whole file from the working
// directory.
func (b *binary) ReadFile(path ...string) ([]byte, error) {
	flatPath := b.Path(path...)
	return os.ReadFile(flatPath)
}

// FileExists is a helper for easily testing whether a particular file
// exists in the working directory.
func (b *binary) FileExists(path ...string) bool {
	flatPath := b.Path(path...)
	_, err := os.Stat(flatPath)
	return !os.IsNotExist(err)
}

// LocalState is a helper for easily reading the local backend's state file
// terraform.tfstate from the working directory.
func (b *binary) LocalState() (*states.State, error) {
	return b.StateFromFile("terraform.tfstate")
}

// StateFromFile is a helper for easily reading a state snapshot from a file
// on disk relative to the working directory.
func (b *binary) StateFromFile(filename string) (*states.State, error) {
	f, err := b.OpenFile(filename)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	stateFile, err := statefile.Read(f, encryption.StateEncryptionDisabled())
	if err != nil {
		return nil, fmt.Errorf("Error reading statefile: %w", err)
	}
	return stateFile.State, nil
}

// Plan is a helper for easily reading a plan file from the working directory.
func (b *binary) Plan(path string) (*plans.Plan, error) {
	path = b.Path(path)
	pr, err := planfile.Open(path, encryption.PlanEncryptionDisabled())
	if err != nil {
		return nil, err
	}
	plan, err := pr.ReadPlan()
	if err != nil {
		return nil, err
	}
	return plan, nil
}

// SetLocalState is a helper for easily writing to the file the local backend
// uses for state in the working directory. This does not go through the
// actual local backend code, so processing such as management of serials
// does not apply and the given state will simply be written verbatim.
func (b *binary) SetLocalState(state *states.State) error {
	path := b.Path("terraform.tfstate")
	f, err := os.OpenFile(path, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, os.ModePerm)
	if err != nil {
		return fmt.Errorf("failed to create temporary state file %s: %w", path, err)
	}
	defer f.Close()

	sf := &statefile.File{
		Serial:  0,
		Lineage: "fake-for-testing",
		State:   state,
	}
	return statefile.Write(sf, f, encryption.StateEncryptionDisabled())
}

func GoBuild(pkgPath, tmpPrefix string) string {
	dir, prefix := filepath.Split(tmpPrefix)
	tmpFile, err := os.CreateTemp(dir, prefix)
	if err != nil {
		panic(err)
	}
	tmpFilename := tmpFile.Name()
	if err = tmpFile.Close(); err != nil {
		panic(err)
	}

	cmd := exec.Command(
		"go", "build",
		"-o", tmpFilename,
		pkgPath,
	)
	cmd.Stderr = os.Stderr
	cmd.Stdout = os.Stdout

	err = cmd.Run()
	if err != nil {
		// The go compiler will have already produced some error messages
		// on stderr by the time we get here.
		panic(fmt.Sprintf("failed to build executable: %s", err))
	}

	return tmpFilename
}

// WorkDir() returns the binary workdir
func (b *binary) WorkDir() string {
	return b.workDir
}
