/*
Copyright 2019 The Knative Authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package cmd

import (
	"bytes"
	"os/exec"
	"strings"
	"sync"

	shell "github.com/kballard/go-shellquote"

	"knative.dev/pkg/test/helpers"
)

const (
	invalidInputErrorPrefix = "invalid input: "
	defaultErrCode          = 1
	separator               = "\n"
)

// These vars are defined for easy mocking in unit tests.
var (
	RunCommand            = runCommand
	RunCommands           = runCommands
	RunCommandsInParallel = runCommandsInParallel
)

// Option enables further configuration of a Cmd.
type Option func(cmd *exec.Cmd)

// WithEnvs returns an option that adds env vars for the given Cmd.
func WithEnvs(envs []string) Option {
	return func(c *exec.Cmd) {
		c.Env = envs
	}
}

// WithDir returns an option that adds dir for the given Cmd.
func WithDir(dir string) Option {
	return func(c *exec.Cmd) {
		c.Dir = dir
	}
}

// RunCommand will run the command and return the standard output, plus error if there is one.
func runCommand(cmdLine string, options ...Option) (string, error) {
	cmdSplit, err := shell.Split(cmdLine)
	if len(cmdSplit) == 0 || err != nil {
		return "", &CommandLineError{
			Command:     cmdLine,
			ErrorOutput: []byte(invalidInputErrorPrefix + cmdLine),
			ErrorCode:   defaultErrCode,
		}
	}

	cmdName := cmdSplit[0]
	args := cmdSplit[1:]
	cmd := exec.Command(cmdName, args...)
	for _, option := range options {
		option(cmd)
	}

	var eb bytes.Buffer
	cmd.Stderr = &eb

	out, err := cmd.Output()
	if err != nil {
		return string(out), &CommandLineError{
			Command:     cmdLine,
			ErrorOutput: eb.Bytes(),
			ErrorCode:   getErrorCode(err),
		}
	}

	return string(out), nil
}

// RunCommands will run the commands sequentially.
// If there is an error when running a command, it will return directly with all standard output so far and the error.
func runCommands(cmdLines ...string) (string, error) {
	var outputs []string
	for _, cmdLine := range cmdLines {
		output, err := RunCommand(cmdLine)
		outputs = append(outputs, output)
		if err != nil {
			return strings.Join(outputs, separator), err
		}
	}
	return strings.Join(outputs, separator), nil
}

// RunCommandsInParallel will run the commands in parallel.
// It will always finish running all commands, and return all standard output and errors together.
func runCommandsInParallel(cmdLines ...string) (string, error) {
	errCh := make(chan error, len(cmdLines))
	outputCh := make(chan string, len(cmdLines))
	mx := sync.Mutex{}
	wg := sync.WaitGroup{}
	for i := range cmdLines {
		cmdLine := cmdLines[i]
		wg.Add(1)
		go func() {
			defer wg.Done()
			output, err := RunCommand(cmdLine)
			mx.Lock()
			outputCh <- output
			errCh <- err
			mx.Unlock()
		}()
	}

	wg.Wait()
	close(outputCh)
	close(errCh)

	os := make([]string, 0, len(cmdLines))
	es := make([]error, 0, len(cmdLines))
	for o := range outputCh {
		os = append(os, o)
	}
	for e := range errCh {
		es = append(es, e)
	}

	return strings.Join(os, separator), helpers.CombineErrors(es)
}

// getErrorCode extracts the exit code of an *ExitError type
func getErrorCode(err error) int {
	errorCode := defaultErrCode
	if exitError, ok := err.(*exec.ExitError); ok {
		errorCode = exitError.ExitCode()
	}
	return errorCode
}
