package testutil

import (
	"errors"
	"flag"
	"fmt"
	"io/ioutil"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"
)

// TestHelper provides helpers for running the conduit integration tests.
type TestHelper struct {
	conduit    string
	version    string
	namespace  string
	httpClient http.Client
	KubernetesHelper
}

// NewTestHelper creates a new instance of TestHelper for the current test run.
// The new TestHelper can be configured via command line flags.
func NewTestHelper() *TestHelper {
	exit := func(code int, msg string) {
		fmt.Fprintln(os.Stderr, msg)
		os.Exit(code)
	}

	conduit := flag.String("conduit", "", "path to the conduit binary to test")
	namespace := flag.String("conduit-namespace", "conduit", "the namespace where conduit is installed")
	runTests := flag.Bool("integration-tests", false, "must be provided to run the integration tests")
	flag.Parse()

	if !*runTests {
		exit(0, "integration tests not enabled: enable with -integration-tests")
	}

	if *conduit == "" {
		exit(1, "-conduit flag is required")
	}

	if !filepath.IsAbs(*conduit) {
		exit(1, "-conduit path must be absolute")
	}

	_, err := os.Stat(*conduit)
	if err != nil {
		exit(1, "-conduit binary does not exist")
	}

	testHelper := &TestHelper{
		conduit:   *conduit,
		namespace: *namespace,
	}

	version, err := testHelper.ConduitRun("version", "--client", "--short")
	if err != nil {
		exit(1, "error getting conduit version")
	}
	testHelper.version = strings.TrimSpace(version)

	kubernetesHelper, err := NewKubernetesHelper()
	if err != nil {
		exit(1, "error creating kubernetes helper: "+err.Error())
	}
	testHelper.KubernetesHelper = *kubernetesHelper

	testHelper.httpClient = http.Client{
		Timeout: 10 * time.Second,
	}

	return testHelper
}

// GetVersion returns the version of conduit to test. This version corresponds
// to the client version of the conduit binary provided via the -conduit command
// line flag.
func (h *TestHelper) GetVersion() string {
	return h.version
}

// GetConduitNamespace returns the namespace where conduit is installed. Set the
// namespace using the -conduit-namespace command line flag.
func (h *TestHelper) GetConduitNamespace() string {
	return h.namespace
}

// GetTestNamespace returns the namespace for the given test. The test namespace
// is prefixed with the conduit namespace.
func (h *TestHelper) GetTestNamespace(testName string) string {
	return h.namespace + "-" + testName
}

// CombinedOutput executes a shell command and returns the output.
func (h *TestHelper) CombinedOutput(name string, arg ...string) (string, error) {
	command := exec.Command(name, arg...)
	bytes, err := command.CombinedOutput()
	if err != nil {
		return string(bytes), err
	}

	return string(bytes), nil
}

// ConduitRun executes a conduit command appended with the --conduit-namespace
// flag.
func (h *TestHelper) ConduitRun(arg ...string) (string, error) {
	withNamespace := append(arg, "--conduit-namespace", h.namespace)
	return h.CombinedOutput(h.conduit, withNamespace...)
}

// ConduitRunStream initiates a conduit command appended with the
// --conduit-namespace flag, and returns a Stream that can be used to read the
// command's output while it is still executing.
func (h *TestHelper) ConduitRunStream(arg ...string) (*Stream, error) {
	withNamespace := append(arg, "--conduit-namespace", h.namespace)
	cmd := exec.Command(h.conduit, withNamespace...)

	cmdReader, err := cmd.StdoutPipe()
	if err != nil {
		return nil, err
	}

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

	time.Sleep(500 * time.Millisecond)

	if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
		return nil, fmt.Errorf("Process exited: %s", cmd.ProcessState)
	}

	return &Stream{cmd: cmd, out: cmdReader}, nil
}

// ValidateOutput validates a string against the contents of a file in the
// test's testdata directory.
func (h *TestHelper) ValidateOutput(out, fixtureFile string) error {
	b, err := ioutil.ReadFile("testdata/" + fixtureFile)
	if err != nil {
		return err
	}
	expected := string(b)

	if out != expected {
		return fmt.Errorf(
			"Expected:\n%s\nActual:\n%s", expected, out)
	}

	return nil
}

// CheckVersion validates the the output of the "conduit version" command.
func (h *TestHelper) CheckVersion(serverVersion string) error {
	out, err := h.ConduitRun("version")
	if err != nil {
		return fmt.Errorf("Unexpected error: %s\n%s", err.Error(), out)
	}
	if !strings.Contains(out, fmt.Sprintf("Client version: %s", h.version)) {
		return fmt.Errorf("Expected client version [%s], got:\n%s", h.version, out)
	}
	if !strings.Contains(out, fmt.Sprintf("Server version: %s", serverVersion)) {
		return fmt.Errorf("Expected server version [%s], got:\n%s", serverVersion, out)
	}
	return nil
}

// BlockUntilTrue retries a given function every second until the function
// returns true or a timeout is reached. If the timeout is reached, it returns
// an error.
func (h *TestHelper) BlockUntilTrue(timeout time.Duration, fn func() bool) error {
	if fn() {
		return nil
	}

	timeoutAfter := time.After(timeout)
	retryAfter := time.Tick(time.Second)

	for {
		select {
		case <-timeoutAfter:
			return errors.New("timed out waiting for condition")
		case <-retryAfter:
			if fn() {
				return nil
			}
		}
	}
}

// RetryFor retries a given function every second until the function returns
// without an error, or a timeout is reached. If the timeout is reached, it
// returns the last error received from the function.
func (h *TestHelper) RetryFor(timeout time.Duration, fn func() error) error {
	err := fn()
	if err == nil {
		return nil
	}

	timeoutAfter := time.After(timeout)
	retryAfter := time.Tick(time.Second)

	for {
		select {
		case <-timeoutAfter:
			return err
		case <-retryAfter:
			err = fn()
			if err == nil {
				return nil
			}
		}
	}
}

// HTTPGetURL sends a GET request to the given URL. It returns the response body
// in the event of a successful 200 response. In the event of a non-200
// response, it returns an error.
func (h *TestHelper) HTTPGetURL(url string) (string, error) {
	resp, err := h.httpClient.Get(url)
	if err != nil {
		// retry once on timeout error; workaround for GKE loadbalancers
		if strings.Contains(err.Error(), "Client.Timeout") {
			resp, err = h.httpClient.Get(url)
		}
	}
	if err != nil {
		return "", err
	}

	defer resp.Body.Close()
	bytes, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("Error reading response body: %v", err)
	}
	body := string(bytes)

	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("GET request to [%s] returned status [%d]\n%s", url, resp.StatusCode, body)
	}

	return body, nil
}

// GetURLForService returns the external URL for a service in a namespace.
func (h *TestHelper) GetURLForService(namespace string, serviceName string) (string, error) {
	var url string
	err := h.RetryFor(3*time.Minute, func() error {
		// first try fetching the url from kubectl
		cmd := exec.Command("kubectl", "-n", namespace, "get", "svc", serviceName, "-o",
			"jsonpath={.status.loadBalancer.ingress[0].*}:{.spec.ports[0].port}")
		out, err := cmd.Output()
		if err != nil {
			return fmt.Errorf("kubectl get svc error: %s\n%s", out, err)
		}
		addr := strings.TrimSpace(string(out))
		if !strings.HasPrefix(addr, ":") {
			url = "http://" + addr
			return nil
		}

		// fallback to minikube
		cmd = exec.Command("minikube", "-n", namespace, "service", serviceName, "--url")
		out, err = cmd.Output()
		if err != nil {
			return fmt.Errorf("minikube service error: %s\n%s", out, err)
		}
		url = strings.TrimSpace(string(out))
		return nil
	})
	return url, err
}
