//go:build go1.18
// +build go1.18

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package recording

import (
	"archive/tar"
	"archive/zip"
	"compress/gzip"
	"fmt"
	"io"
	"log"
	"math/rand"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"
	"time"
)

const proxyManualStartEnv = "PROXY_MANUAL_START"

type TestProxyInstance struct {
	Cmd     *exec.Cmd
	Options *RecordingOptions
}

func getTestProxyDownloadFile() (string, error) {
	switch {
	case runtime.GOOS == "windows":
		// No ARM binaries for Windows, so return x64
		return "test-proxy-standalone-win-x64.zip", nil
	case runtime.GOOS == "linux" && runtime.GOARCH == "amd64":
		return "test-proxy-standalone-linux-x64.tar.gz", nil
	case runtime.GOOS == "linux" && runtime.GOARCH == "arm64":
		return "test-proxy-standalone-linux-arm64.tar.gz", nil
	case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64":
		return "test-proxy-standalone-osx-x64.zip", nil
	case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64":
		return "test-proxy-standalone-osx-arm64.zip", nil
	default:
		return "", fmt.Errorf("unsupported OS/Arch combination: %s/%s", runtime.GOOS, runtime.GOARCH)
	}
}

// Modified from https://stackoverflow.com/a/24792688
func extractTestProxyZip(src string, dest string) error {
	r, err := zip.OpenReader(src)
	if err != nil {
		return err
	}
	defer func() {
		if err := r.Close(); err != nil {
			panic(err)
		}
	}()

	if err := os.MkdirAll(dest, 0755); err != nil {
		return err
	}

	// Closure to address file descriptors issue with all the deferred .Close() methods
	extractAndWriteFile := func(f *zip.File) error {
		rc, err := f.Open()
		if err != nil {
			return err
		}
		defer func() {
			if err := rc.Close(); err != nil {
				panic(err)
			}
		}()

		path := filepath.Join(dest, f.Name)
		log.Println("Extracting", path)

		// Check for ZipSlip (Directory traversal)
		if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) {
			return fmt.Errorf("illegal file path: %s", path)
		}

		if f.FileInfo().IsDir() {
			if err := os.MkdirAll(path, f.Mode()); err != nil {
				return err
			}
		} else {
			if err := os.MkdirAll(filepath.Dir(path), f.Mode()); err != nil {
				return err
			}
			f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
			if err != nil {
				return err
			}
			defer func() {
				if err := f.Close(); err != nil {
					panic(err)
				}
			}()

			_, err = io.Copy(f, rc)
			if err != nil {
				return err
			}
		}

		return nil
	}

	for _, f := range r.File {
		err := extractAndWriteFile(f)
		if err != nil {
			return err
		}
	}

	return nil
}

func extractTestProxyArchive(archivePath string, outputDir string) error {
	log.Printf("Extracting %s\n", archivePath)
	file, err := os.Open(archivePath)
	if err != nil {
		return err
	}
	defer file.Close()
	gzipReader, err := gzip.NewReader(file)
	if err != nil {
		return err
	}
	defer gzipReader.Close()

	tarReader := tar.NewReader(gzipReader)

	for {
		header, err := tarReader.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			return err
		}

		targetPath := filepath.Join(outputDir, header.Name)
		if !strings.HasPrefix(targetPath, filepath.Clean(outputDir)) {
			return fmt.Errorf("illegal file path: %q", header.Name)
		}

		log.Println("Extracting", targetPath)

		switch header.Typeflag {
		case tar.TypeDir:
			if err := os.MkdirAll(targetPath, 0755); err != nil {
				return err
			}
		case tar.TypeReg:
			file, err := os.Create(targetPath)
			if err != nil {
				return err
			}
			defer file.Close()

			if _, err := io.Copy(file, tarReader); err != nil {
				return err
			}
		default:
			log.Printf("Unable to extract type %c in file %s\n", header.Typeflag, header.Name)
		}
	}

	return nil
}

func installTestProxy(archivePath string, outputDir string, proxyPath string) error {
	var err error
	if filepath.Ext(archivePath) == ".zip" {
		err = extractTestProxyZip(archivePath, outputDir)
	} else {
		err = extractTestProxyArchive(archivePath, outputDir)
	}
	if err != nil {
		return err
	}

	err = os.Chmod(proxyPath, 0755)
	if err != nil {
		return err
	}
	err = os.Remove(archivePath)
	if err != nil {
		return err
	}

	return nil
}

func restoreRecordings(proxyPath string, pathToRecordings string) error {
	if pathToRecordings == "" {
		return nil
	}
	absAssetLocation, _, err := getAssetsConfigLocation(pathToRecordings)
	if err != nil {
		return err
	}

	log.Printf("Running test proxy command: %s restore -a %s\n", proxyPath, absAssetLocation)
	cmd := exec.Command(proxyPath, "restore", "-a", absAssetLocation)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Start(); err != nil {
		return err
	}
	return cmd.Wait()
}

func ensureTestProxyInstalled(proxyVersion string, proxyPath string, proxyDir string, pathToRecordings string) error {
	lockFile := filepath.Join(os.TempDir(), "test-proxy-install.lock")
	log.Printf("Waiting to acquire test proxy install lock %s\n", lockFile)
	maxTries := 600 // Wait 1 minute
	var i int
	for i = 0; i < maxTries; i++ {
		lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_EXCL, 0600)
		if err != nil {
			time.Sleep(100 * time.Millisecond)
			continue
		}

		// NOTE: the lockfile will not be removed on ctrl-c during download.
		// Go test seems to send an os.Interrupt signal on test setup completion, so if we
		// call os.Exit(1) on ctrl-c the tests will never run. If we don't call os.Exit(1),
		// the tests cannot be canceled.
		// Therefore, if ctrl-c is pressed during download, the user will have to manually
		// remove the lockfile in order to get the tests running again.
		defer func() {
			lock.Close()
			os.Remove(lockFile)
		}()

		break
	}

	if i >= maxTries {
		return fmt.Errorf("timed out waiting to acquire test proxy install lock. Ensure %s does not exist", lockFile)
	}

	cmd := exec.Command(proxyPath, "--version")
	out, err := cmd.Output()
	if err != nil {
		log.Printf("Test proxy not detected at %s, downloading...\n", proxyPath)
	} else {
		// TODO: fix proxy CLI tool versioning output to match the actual version we download
		installedVersion := "1.0.0-dev." + strings.TrimSpace(string(out))
		if installedVersion == proxyVersion {
			log.Printf("Test proxy version %s already installed\n", proxyVersion)
			return restoreRecordings(proxyPath, pathToRecordings)
		} else {
			log.Printf("Test proxy version %s does not match required version %s\n",
				installedVersion, proxyVersion)
		}
	}

	proxyFile, err := getTestProxyDownloadFile()
	if err != nil {
		return err
	}

	proxyDownloadPath := filepath.Join(proxyDir, proxyFile)
	archive, err := os.Create(proxyDownloadPath)
	if err != nil {
		return err
	}

	log.Printf("Downloading test proxy version %s to %s for %s/%s\n",
		proxyVersion, proxyPath, runtime.GOOS, runtime.GOARCH)
	proxyUrl := fmt.Sprintf("https://github.com/Azure/azure-sdk-tools/releases/download/Azure.Sdk.Tools.TestProxy_%s/%s",
		proxyVersion, proxyFile)
	resp, err := http.Get(proxyUrl)
	if err != nil {
		return err
	}

	_, err = io.Copy(archive, resp.Body)
	if err != nil {
		return err
	}
	err = resp.Body.Close()
	if err != nil {
		return err
	}
	err = archive.Close()
	if err != nil {
		return err
	}

	err = installTestProxy(proxyDownloadPath, proxyDir, proxyPath)
	if err != nil {
		return err
	}

	return restoreRecordings(proxyPath, pathToRecordings)
}

func getProxyLog() (*os.File, error) {
	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
	const letters = "abcdefghijklmnopqrstuvwxyz"
	suffix := make([]byte, 6)
	for i := range suffix {
		suffix[i] = letters[rng.Intn(len(letters))]
	}
	proxyLogName := fmt.Sprintf("test-proxy.log.%s", suffix)
	proxyLog, err := os.Create(filepath.Join(os.TempDir(), proxyLogName))
	if err != nil {
		return nil, err
	}
	return proxyLog, nil
}

func getProxyVersion(gitRoot string) (string, error) {
	proxyVersionConfig := filepath.Join(gitRoot, "eng/common/testproxy/target_version.txt")
	overrideProxyVersionConfig := filepath.Join(gitRoot, "eng/target_proxy_version.txt")

	if _, err := os.Stat(overrideProxyVersionConfig); err == nil {
		version, err := os.ReadFile(overrideProxyVersionConfig)
		if err == nil {
			proxyVersion := strings.TrimSpace(string(version))
			return proxyVersion, nil
		}
	}

	version, err := os.ReadFile(proxyVersionConfig)
	if err != nil {
		return "", err
	}
	proxyVersion := strings.TrimSpace(string(version))

	return proxyVersion, nil
}

func setTestProxyEnv(gitRoot string) {
	devCertPath := filepath.Join(gitRoot, "eng/common/testproxy/dotnet-devcert.pfx")
	os.Setenv("ASPNETCORE_Kestrel__Certificates__Default__Path", devCertPath)
	os.Setenv("ASPNETCORE_Kestrel__Certificates__Default__Password", "password")
}

func waitForProxyStart(cmd *exec.Cmd, options *RecordingOptions) (*TestProxyInstance, error) {
	maxTries := 50
	// Extend sleep time in devops pipeline, proxy takes longer to start up
	if os.Getenv("SYSTEM_TEAMPROJECTID") != "" {
		maxTries = 200
	}
	log.Printf("Started test proxy instance (PID %d) on %s\n", cmd.Process.Pid, options.baseURL())
	client, _ := GetHTTPClient(nil)
	client.Timeout = 1 * time.Second

	log.Printf("Waiting up to %d seconds for test-proxy server to respond...\n", (maxTries / 10))
	var i int
	for i = 0; i < maxTries; i++ {
		uri := fmt.Sprintf("https://localhost:%d/Admin/IsAlive", options.ProxyPort)
		req, _ := http.NewRequest("GET", uri, nil)
		req.Close = true

		resp, err := client.Do(req)
		if resp != nil && resp.Body != nil {
			resp.Body.Close()
		}
		if err != nil {
			time.Sleep(100 * time.Millisecond)
			continue
		}
		return &TestProxyInstance{Cmd: cmd, Options: options}, nil
	}

	return nil, fmt.Errorf("test proxy server did not become available in the allotted time")
}

func inCI() bool {
	return os.Getenv("TF_BUILD") != "" || os.Getenv("GITHUB_ACTIONS") != ""
}

func StartTestProxy(pathToRecordings string, options *RecordingOptions) (*TestProxyInstance, error) {
	manualStart := strings.ToLower(os.Getenv(proxyManualStartEnv))
	if manualStart == "true" && !inCI() {
		log.Printf("%s env variable is set to true, not starting test proxy...\n", proxyManualStartEnv)
		return nil, nil
	}

	cwd, err := os.Getwd()
	if err != nil {
		return nil, err
	}
	gitRoot, err := getGitRoot(cwd)
	if err != nil {
		return nil, err
	}

	proxyVersion, err := getProxyVersion(gitRoot)
	if err != nil {
		return nil, err
	}
	proxyDir := filepath.Join(gitRoot, ".proxy")
	if err := os.MkdirAll(proxyDir, 0755); err != nil {
		return nil, err
	}
	proxyPath := filepath.Join(proxyDir, "Azure.Sdk.Tools.TestProxy")
	if runtime.GOOS == "windows" {
		proxyPath += ".exe"
	}
	err = ensureTestProxyInstalled(proxyVersion, proxyPath, proxyDir, pathToRecordings)
	if err != nil {
		return nil, err
	}

	proxyLog, err := getProxyLog()
	if err != nil {
		return nil, err
	}
	defer proxyLog.Close()

	setTestProxyEnv(gitRoot)

	if options == nil {
		options = defaultOptions()
	}
	insecure := ""
	if options.insecure {
		insecure = "--insecure"
	}
	args := []string{"start", "--storage-location", gitRoot, insecure, "--", "--urls=" + options.baseURL()}
	log.Printf("Running test proxy command: %s %s", proxyPath, strings.Join(args, " "))
	log.Printf("Test proxy log location: %s\n", proxyLog.Name())
	cmd := exec.Command(proxyPath, args...)

	cmd.Stdout = proxyLog
	cmd.Stderr = proxyLog

	if err := cmd.Start(); err != nil {
		return nil, err
	}

	done := make(chan error, 1)
	go func() {
		done <- cmd.Wait()
	}()

	return waitForProxyStart(cmd, options)
}

// NOTE: The process will be killed if the user hits ctrl-c mid-way through tests, as go will
// kill child processes when the main process does not exit cleanly. No os.Interrupt handlers
// need to be added after starting the proxy server in tests.
func StopTestProxy(proxyInstance *TestProxyInstance) error {
	if proxyInstance == nil || proxyInstance.Cmd == nil || proxyInstance.Cmd.Process == nil {
		return nil
	}
	log.Printf("Stopping test proxy instance (PID %d) on %s\n", proxyInstance.Cmd.Process.Pid, proxyInstance.Options.baseURL())
	err := proxyInstance.Cmd.Process.Kill()
	if err != nil {
		return err
	}
	return nil
}
