package xaqt

import (
	"context"
	"fmt"
	"io"
	"io/ioutil"
	"log"
	"os"
	"path/filepath"
	"time"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/api/types/container"
	"github.com/docker/docker/client"
	uuid "github.com/satori/go.uuid"
)

const (
	TmpDirPrefix = "xaqt-"
)

// prepares for execution of user code by creating a temp directory for
// code and input.
//
type sandbox struct {
	// sandbox id (uuidV4)
	ID       string
	language ExecutionDetails
	code     string
	stdin    string
	options  options
	// docker client connection
	docker *client.Client
	// wait channel for successful container exit
	waitChan <-chan container.ContainerWaitOKBody
	// error channel for container error
	errChan <-chan error
}

// constructs a new sandbox given...
//
func newSandbox(l ExecutionDetails, code, stdin string, opts options) (*sandbox, error) {
	var (
		s   *sandbox
		err error
	)

	// set the API version to use in an environment variable
	// TODO it would be nice to configure based on the docker version
	// a user currently has.... not enough time right now so skipping that.
	err = os.Setenv("DOCKER_API_VERSION", "1.35")
	if err != nil {
		return nil, err
	}

	// init a docker api client
	dockerClient, err := client.NewEnvClient()
	if err != nil {
		// this could occur if docker has not been installed or started
		return nil, err
	}

	// TODO (cw|4.29.2018) if we are spinning up this sandbox from within another docker
	// container, we may want to define a bridge network between them (since they will be
	// sibling containers). I don't know if this is entirely necessary though...
	// THIS NETWORK SETUP SHOULD ACTUALLY GO IN A HIGHER SCOPE (within the struct which
	// actually constructs sandboxes). this way we aren't creating and destroying docker
	// networks all over the place. instead we should check to see if one has been created.

	// define unique network name
	// networkName := fmt.Sprintf("xaqt.%s", uuid.NewV4().String())

	// setup container bridge network if one doesn't already exist.
	// _, err = dockerClient.NetworkCreate(
	// 	context.TODO(),
	// 	networkName,
	// 	types.NetworkCreate{},
	// )
	// if err != nil {
	// 	return nil, err
	// }

	s = &sandbox{
		ID:       uuid.NewV4().String(),
		language: l,
		code:     code,
		stdin:    stdin,
		options:  opts,
		docker:   dockerClient,
	}

	return s, nil
}

// runs user code within the sandbox after preparing the execution environment.
//
func (s *sandbox) run() (string, error) {
	var (
		output string
		err    error
	)

	err = s.prepare()
	if err != nil {
		return "", err
	}

	output, err = s.execute()
	if err != nil {
		return "", err
	}

	return output, nil
}

// prepares the execution environment and the sandbox docker container.
//
func (s *sandbox) prepare() error {
	var err error

	err = s.PrepareTmpDir()
	if err != nil {
		return err
	}

	err = s.PrepareContainer()
	if err != nil {
		return err
	}

	return nil
}

// prepares the execution environment by copying all resources (user code, input files,
// and execution payload) into a temporary directory.
//
func (s *sandbox) PrepareTmpDir() error {
	// create tmp directory for keeping all code and inputs
	tmpFolder, err := ioutil.TempDir(s.options.folder, TmpDirPrefix)
	if err != nil {
		return err
	}

	// modify perms on tmp dir
	if err := os.Chmod(tmpFolder, 0777); err != nil {
		return err
	}

	// record tmpdir for easy deletion
	s.options.folder = tmpFolder

	// copy the Payload dir into the tmp dir. for more details on what the
	// Payload dir is, check out the TODO (cw|4.29.2018) fill this in...
	err = s.copyPayload()
	if err != nil {
		return err
	}

	// write source file into tmp dir
	// TODO (cw|4.29.2018) we should be able to write an arbitrary number of files
	// to the tmp dir.
	err = ioutil.WriteFile(tmpFolder+"/"+s.language.SourceFile, []byte(s.code), 0777)
	if err != nil {
		return err
	}

	// write a file for stdin
	err = ioutil.WriteFile(tmpFolder+"/inputFile", []byte(s.stdin), 0777)
	if err != nil {
		return err
	}

	return nil
}

// create docker container for running code and stream container's stdout to our stdout.
//
func (s *sandbox) PrepareContainer() error {
	var (
		ctx = context.Background()
		err error
	)

	// create docker container for executing user code
	_, err = s.docker.ContainerCreate(
		ctx,
		&container.Config{
			Image: s.options.image,
			Cmd: []string{
				"/usercode/script.sh",
				s.language.Compiler,
				s.language.SourceFile,
				s.language.OptionalExecutable,
				s.language.CompilerFlags,
			},
			// run the sandbox container as a specific user.
			User: "mysql", // TODO (cw|4.29.2018) change this to a constant
			// StopTimeout:  s.options.timeout, // TODO this needs to be a *int ...
			AttachStdout: true, // TODO (cw|8.21.2018) do we need this?
			AttachStderr: true, // TODO (cw|8.21.2018) do we need this?
			Tty:          true, // TODO (cw|8.21.2018) do we need this?
		},
		&container.HostConfig{
			// remove container from host once it exits
			AutoRemove: true,
			// specify the mount point(s) for the sandbox
			Binds: []string{s.options.folder + ":/usercode"},
		},
		nil, // no network config currently
		s.ID,
	)
	if err != nil {
		return err
	}

	// setup stdout stream from container
	// TODO (cw|8.21.2018) do we need this?
	hijackedResp, err := s.docker.ContainerAttach(
		ctx,
		s.ID,
		types.ContainerAttachOptions{
			Stream: true,
			Stdout: true,
			Stderr: true,
		},
	)
	if err != nil {
		return err
	}

	// start hijacking stdout/stderr
	// TODO (cw|8.21.2018) do we need this?
	go func() {
		defer hijackedResp.Close()

		io.Copy(os.Stdout, hijackedResp.Reader)
	}()

	// setup channels to wait for container to stop and be removed.
	// NOTE (cw|8.21.2018) we need WaitConditionRemoved since it is apparently
	// not enough to just wait for the process to stop. Waiting for the process
	// to merely stop resulted in race conditions between the stdout writer in the
	// container and this parent process...
	s.waitChan, s.errChan = s.docker.ContainerWait(
		context.Background(),
		s.ID,
		container.WaitConditionRemoved,
	)

	return nil
}

// executes user code within the sandbox docker container.
//
// returns TODO (cw|4.29.2018) ???
//
func (s *sandbox) execute() (string, error) {
	var (
		ctx = context.Background()
		err error
	)
	// delete temporary directory once we have finished execution
	defer os.RemoveAll(s.options.folder)

	// okay lets start the container...
	err = s.docker.ContainerStart(
		ctx,
		s.ID,
		types.ContainerStartOptions{},
	)
	if err != nil {
		return "", err
	}

	// wait for the container to stop
	select {
	case <-s.waitChan:
		// ok. the docker process has stopped and the container has been removed.

		// get the errors file
		errorBytes, err := ioutil.ReadFile(s.options.folder + "/errors")
		if err != nil {
			// there was an error reading the errors file, perhaps it is missing?
			return "", err
		}

		// did the process error?
		if len(errorBytes) > 0 {
			// the user code which was run in the container errored.
			err = fmt.Errorf("user code error: %s", errorBytes)

			return "", err
		}

		outputBytes, err := ioutil.ReadFile(s.options.folder + "/completed")
		if err != nil {
			// there was an error reading the completed file, perhaps it is missing?
			return "", err
		}

		// successfully completed

		return string(outputBytes), nil
	case err = <-s.errChan:
		// the damn container errored
		return "", err
	case <-time.After(s.options.timeout):
		// the damn container process timed out
		log.Printf("%s timed out", s.language.Compiler)
		return "", fmt.Errorf("Timed out")
	}
}

func (s *sandbox) copyPayload() error {
	source := filepath.Join(s.options.path, "Payload")
	dest := filepath.Join(s.options.folder)

	directory, err := os.Open(source)
	if err != nil {
		return err
	}

	files, err := directory.Readdir(-1)
	if err != nil {
		return err
	}

	for _, file := range files {
		// read the file
		destfile := dest + "/" + file.Name()
		sourcefile := source + "/" + file.Name()
		bytes, err := ioutil.ReadFile(sourcefile)
		if err != nil {
			return err
		}

		// write the file to tmp
		err = ioutil.WriteFile(destfile, bytes, 0777)
		if err != nil {
			return err
		}
	}

	return nil
}

// TODO (cw|4.29.2018) this cleanup should be in Context (which is initialized once in the
// calling code)
// func (s *sandbox) CleanUp() error {
// 	// remove the network
// 	err := s.docker.NetworkRemove(context.TODO(), executer.Network)
// 	if err != nil && !client.IsErrNotFound(err) {
// 		// something is very wrong here
// 		panic(err)
// 	}

// }