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

// protobuf-compile is a helper tool for running protoc against all of the
// .proto files in this repository using specific versions of protoc and
// protoc-gen-go, to ensure consistent results across all development
// environments.
//
// protoc itself isn't a Go tool, so we need to use a custom strategy to
// install and run it. The official releases are built only for a subset of
// platforms that Go can potentially target, so this tool will fail if you
// are using a platform other than the ones this wrapper tool has explicit
// support for. In that case you'll need to either run this tool on a supported
// platform or to recreate what it does manually using a protoc you've built
// and installed yourself.
package main

import (
	"fmt"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"

	"github.com/hashicorp/go-getter"
)

const protocVersion = "32.1"

// We also use protoc-gen-go and its grpc addon, but since these are Go tools
// in Go modules our version selection for these comes from our top-level
// go.mod, as with all other Go dependencies. If you want to switch to a newer
// version of either tool then you can upgrade their modules in the usual way.
const protocGenGoPackage = "google.golang.org/protobuf/cmd/protoc-gen-go"
const protocGenGoGrpcPackage = "google.golang.org/grpc/cmd/protoc-gen-go-grpc"

type protocStep struct {
	DisplayName string
	WorkDir     string
	Args        []string
}

var protocSteps = []protocStep{
	{
		"tfplugin5 (provider wire protocol version 5)",
		"internal/tfplugin5",
		[]string{"--go_out=.", "--go_opt=paths=source_relative", "--go-grpc_out=.", "--go-grpc_opt=paths=source_relative", "./tfplugin5.proto"},
	},
	{
		"tfplugin6 (provider wire protocol version 6)",
		"internal/tfplugin6",
		[]string{"--go_out=.", "--go_opt=paths=source_relative", "--go-grpc_out=.", "--go-grpc_opt=paths=source_relative", "./tfplugin6.proto"},
	},
	{
		"tfplan (plan file serialization)",
		"internal/plans/internal/planproto",
		[]string{"--go_out=.", "--go_opt=paths=source_relative", "planfile.proto"},
	},
	{
		"opentofu_execgraph (execution graph serialization)",
		"internal/engine/internal/execgraph/execgraphproto",
		[]string{"--go_out=.", "--go_opt=paths=source_relative", "execgraph.proto"},
	},
}

func main() {
	if len(os.Args) != 2 {
		log.Fatal("Usage: go tool protobuf-compile <basedir>")
	}
	baseDir := os.Args[1]
	workDir := filepath.Join(baseDir, "tools/protobuf-compile/.workdir")

	protocLocalDir := filepath.Join(workDir, "protoc-v"+protocVersion)
	if _, err := os.Stat(protocLocalDir); os.IsNotExist(err) {
		err := downloadProtoc(protocVersion, protocLocalDir)
		if err != nil {
			log.Fatal(err)
		}
	} else {
		log.Printf("already have protoc v%s in %s", protocVersion, protocLocalDir)
	}

	protocExec := filepath.Join(protocLocalDir, "bin/protoc")

	protocGenGoExec, err := buildProtocGenGo(workDir)
	if err != nil {
		log.Fatal(err)
	}
	protocGenGoGrpcExec, err := buildProtocGenGoGrpc(workDir)
	if err != nil {
		log.Fatal(err)
	}

	protocExec, err = filepath.Abs(protocExec)
	if err != nil {
		log.Fatal(err)
	}
	protocGenGoExec, err = filepath.Abs(protocGenGoExec)
	if err != nil {
		log.Fatal(err)
	}
	protocGenGoGrpcExec, err = filepath.Abs(protocGenGoGrpcExec)
	if err != nil {
		log.Fatal(err)
	}

	// For all of our steps we'll run our localized protoc with our localized
	// protoc-gen-go and protoc-gen-go-grpc.
	baseCmdLine := []string{protocExec, "--plugin=" + protocGenGoExec, "--plugin=" + protocGenGoGrpcExec}

	for _, step := range protocSteps {
		log.Printf("working on %s", step.DisplayName)

		cmdLine := make([]string, 0, len(baseCmdLine)+len(step.Args))
		cmdLine = append(cmdLine, baseCmdLine...)
		cmdLine = append(cmdLine, step.Args...)

		cmd := &exec.Cmd{
			Path:   cmdLine[0],
			Args:   cmdLine,
			Dir:    step.WorkDir,
			Env:    os.Environ(),
			Stdout: os.Stdout,
			Stderr: os.Stderr,
		}
		err := cmd.Run()
		if err != nil {
			log.Printf("failed to compile: %s", err)
		}
	}

}

// downloadProtoc downloads the given version of protoc into the given
// directory.
func downloadProtoc(version string, localDir string) error {
	protocURL, err := protocDownloadURL(version)
	if err != nil {
		return err
	}

	log.Printf("downloading and extracting protoc v%s from %s into %s", version, protocURL, localDir)

	// For convenience, we'll be using go-getter to actually download this
	// thing, so we need to turn the real URL into the funny sort of pseudo-URL
	// thing that go-getter wants.
	goGetterURL := protocURL + "?archive=zip"

	err = getter.Get(localDir, goGetterURL)
	if err != nil {
		return fmt.Errorf("failed to download or extract the package: %s", err)
	}

	return nil
}

// buildProtocGenGo uses the Go toolchain to fetch the module containing
// protoc-gen-go and then build an executable into the working directory.
//
// If successful, it returns the location of the executable.
func buildProtocGenGo(workDir string) (string, error) {
	exeSuffixRaw, err := exec.Command("go", "env", "GOEXE").Output()
	if err != nil {
		return "", fmt.Errorf("failed to determine executable suffix: %s", err)
	}
	exeSuffix := strings.TrimSpace(string(exeSuffixRaw))
	exePath := filepath.Join(workDir, "protoc-gen-go"+exeSuffix)
	log.Printf("building %s as %s", protocGenGoPackage, exePath)

	cmd := exec.Command("go", "build", "-o", exePath, protocGenGoPackage)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	err = cmd.Run()
	if err != nil {
		return "", fmt.Errorf("failed to build %s: %s", protocGenGoPackage, err)
	}

	return exePath, nil
}

// buildProtocGenGoGrpc uses the Go toolchain to fetch the module containing
// protoc-gen-go-grpc and then build an executable into the working directory.
//
// If successful, it returns the location of the executable.
func buildProtocGenGoGrpc(workDir string) (string, error) {
	exeSuffixRaw, err := exec.Command("go", "env", "GOEXE").Output()
	if err != nil {
		return "", fmt.Errorf("failed to determine executable suffix: %s", err)
	}
	exeSuffix := strings.TrimSpace(string(exeSuffixRaw))
	exePath := filepath.Join(workDir, "protoc-gen-go-grpc"+exeSuffix)
	log.Printf("building %s as %s", protocGenGoGrpcPackage, exePath)

	cmd := exec.Command("go", "build", "-o", exePath, protocGenGoGrpcPackage)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	err = cmd.Run()
	if err != nil {
		return "", fmt.Errorf("failed to build %s: %s", protocGenGoGrpcPackage, err)
	}

	return exePath, nil
}

// protocDownloadURL returns the URL to try to download the protoc package
// for the current platform or an error if there's no known URL for the
// current platform.
func protocDownloadURL(version string) (string, error) {
	platformKW := protocPlatform()
	if platformKW == "" {
		return "", fmt.Errorf("don't know where to find protoc for %s on %s", runtime.GOOS, runtime.GOARCH)
	}
	return fmt.Sprintf("https://github.com/protocolbuffers/protobuf/releases/download/v%s/protoc-%s-%s.zip", protocVersion, protocVersion, platformKW), nil
}

// protocPlatform returns the package name substring for the current platform
// in the naming convention used by official protoc packages, or an empty
// string if we don't know how protoc packaging would describe current
// platform.
func protocPlatform() string {
	goPlatform := runtime.GOOS + "_" + runtime.GOARCH

	switch goPlatform {
	case "linux_amd64":
		return "linux-x86_64"
	case "linux_arm64":
		return "linux-aarch_64"
	case "darwin_amd64":
		return "osx-x86_64"
	case "darwin_arm64":
		// As of 3.15.6 there isn't yet an osx-aarch_64 package available,
		// so we'll install the x86_64 version and hope Rosetta can handle it.
		return "osx-x86_64"
	case "windows_amd64":
		return "win64" // for some reason the windows packages don't have a CPU architecture part
	default:
		return ""
	}
}
