package docker

import (
	"fmt"
	"strings"
	"time"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/api/types/container"
	"github.com/docker/docker/api/types/mount"
	"github.com/docker/go-connections/nat"

	specs "github.com/opencontainers/image-spec/specs-go/v1"
)

// PorterDB is used for enumerating DB types
type PorterDB int

// The supported DB types
const (
	Postgres PorterDB = iota
	SQLite
)

// PorterStartOpts are the options for starting the Porter stack
type PorterStartOpts struct {
	ProcessID      string
	ServerImageTag string
	ServerPort     int
	DB             PorterDB
	Env            []string
}

// StartPorter creates a new Docker agent using the host environment, and creates a
// new Porter instance
func StartPorter(opts *PorterStartOpts) (agent *Agent, id string, err error) {
	agent, err = NewAgentFromEnv()

	if err != nil {
		return nil, "", err
	}

	// the volume mounts to use
	mounts := make([]mount.Mount, 0)

	// the volumes passed to the Porter container
	volumesMap := make(map[string]struct{})

	netID, err := agent.CreateBridgeNetworkIfNotExist("porter_network_" + opts.ProcessID)
	if err != nil {
		return nil, "", err
	}

	switch opts.DB {
	case SQLite:
		// check if sqlite volume exists, create it if not
		vol, err := agent.CreateLocalVolumeIfNotExist("porter_sqlite_" + opts.ProcessID)
		if err != nil {
			return nil, "", err
		}

		// create mount
		mount := mount.Mount{
			Type:        mount.TypeVolume,
			Source:      vol.Name,
			Target:      "/sqlite",
			ReadOnly:    false,
			Consistency: mount.ConsistencyFull,
		}

		mounts = append(mounts, mount)
		volumesMap[vol.Name] = struct{}{}

		opts.Env = append(opts.Env, []string{
			"SQL_LITE=true",
			"SQL_LITE_PATH=/sqlite/porter.db",
		}...)
	case Postgres:
		// check if postgres volume exists, create it if not
		vol, err := agent.CreateLocalVolumeIfNotExist("porter_postgres_" + opts.ProcessID)
		if err != nil {
			return nil, "", err
		}

		// pgMount is mount for postgres container
		pgMount := []mount.Mount{
			{
				Type:        mount.TypeVolume,
				Source:      vol.Name,
				Target:      "/var/lib/postgresql/data",
				ReadOnly:    false,
				Consistency: mount.ConsistencyFull,
			},
		}

		// create postgres container with mount
		startOpts := PostgresOpts{
			Name:   "porter_postgres_" + opts.ProcessID,
			Image:  "postgres:latest",
			Mounts: pgMount,
			VolumeMap: map[string]struct{}{
				"porter_postgres": {},
			},
			NetworkID: netID,
			Env: []string{
				"POSTGRES_USER=porter",
				"POSTGRES_PASSWORD=porter",
				"POSTGRES_DB=porter",
			},
		}

		pgID, err := agent.StartPostgresContainer(startOpts)
		if err != nil {
			return nil, "", err
		}

		err = agent.WaitForContainerHealthy(pgID, 10)

		if err != nil {
			return nil, "", err
		}

		opts.Env = append(opts.Env, []string{
			"SQL_LITE=false",
			"DB_USER=porter",
			"DB_PASS=porter",
			"DB_NAME=porter",
			"DB_HOST=porter_postgres_" + opts.ProcessID,
			"DB_PORT=5432",
		}...)
	}

	opts.Env = append(opts.Env, "REDIS_ENABLED=false")

	// create Porter container
	startOpts := PorterServerStartOpts{
		Name:          "porter_server_" + opts.ProcessID,
		Image:         "porter1/porter:" + opts.ServerImageTag,
		HostPort:      uint(opts.ServerPort),
		ContainerPort: 8080,
		Mounts:        mounts,
		VolumeMap:     volumesMap,
		NetworkID:     netID,
		Env:           opts.Env,
	}

	id, err = agent.StartPorterContainer(startOpts)

	if err != nil {
		return nil, "", err
	}

	err = agent.WaitForContainerHealthy(id, 10)

	if err != nil {
		return nil, "", err
	}

	return agent, id, nil
}

// PorterServerStartOpts are the options for starting the Porter server
type PorterServerStartOpts struct {
	Name          string
	Image         string
	StartCmd      []string
	HostPort      uint
	ContainerPort uint
	Mounts        []mount.Mount
	VolumeMap     map[string]struct{}
	Env           []string
	NetworkID     string
}

// StartPorterContainer pulls a specific Porter image and starts a container
// using the Docker engine. It returns the container ID
func (a *Agent) StartPorterContainer(opts PorterServerStartOpts) (string, error) {
	id, err := a.upsertPorterContainer(opts)
	if err != nil {
		return "", err
	}

	err = a.startPorterContainer(id)

	if err != nil {
		return "", err
	}

	// attach container to network
	err = a.ConnectContainerToNetwork(opts.NetworkID, id, opts.Name)

	if err != nil {
		return "", err
	}

	return id, nil
}

// detect if container exists and is running, and stop
// if spec has changed, remove and recreate container
// if container does not exist, create the container
// otherwise, return stopped container
func (a *Agent) upsertPorterContainer(opts PorterServerStartOpts) (id string, err error) {
	containers, err := a.getContainersCreatedByStart()

	// remove the matching container
	for _, container := range containers {
		if len(container.Names) > 0 && container.Names[0] == "/"+opts.Name {
			timeout, _ := time.ParseDuration("15s")

			err := a.ContainerStop(a.ctx, container.ID, &timeout)
			if err != nil {
				return "", a.handleDockerClientErr(err, "Could not stop container "+container.ID)
			}

			err = a.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})

			if err != nil {
				return "", a.handleDockerClientErr(err, "Could not remove container "+container.ID)
			}
		}
	}

	return a.pullAndCreatePorterContainer(opts)
}

// create the container and return its id
func (a *Agent) pullAndCreatePorterContainer(opts PorterServerStartOpts) (id string, err error) {
	a.PullImage(opts.Image)

	// format the port array for binding to host machine
	ports := []string{fmt.Sprintf("127.0.0.1:%d:%d/tcp", opts.HostPort, opts.ContainerPort)}

	_, portBindings, err := nat.ParsePortSpecs(ports)
	if err != nil {
		return "", fmt.Errorf("Unable to parse port specification %s", ports)
	}

	labels := make(map[string]string)
	labels[a.label] = "true"

	// create the container with a label specifying this was created via the CLI
	resp, err := a.ContainerCreate(a.ctx, &container.Config{
		Image:   opts.Image,
		Cmd:     opts.StartCmd,
		Tty:     false,
		Labels:  labels,
		Volumes: opts.VolumeMap,
		Env:     opts.Env,
		Healthcheck: &container.HealthConfig{
			Test:     []string{"CMD-SHELL", "/porter/ready"},
			Interval: 10 * time.Second,
			Timeout:  5 * time.Second,
			Retries:  3,
		},
	}, &container.HostConfig{
		PortBindings: portBindings,
		Mounts:       opts.Mounts,
	}, nil, &specs.Platform{}, opts.Name)
	if err != nil {
		return "", a.handleDockerClientErr(err, "Could not create Porter container")
	}

	return resp.ID, nil
}

// start the container
func (a *Agent) startPorterContainer(id string) error {
	if err := a.ContainerStart(a.ctx, id, types.ContainerStartOptions{}); err != nil {
		return a.handleDockerClientErr(err, "Could not start Porter container")
	}

	return nil
}

// PostgresOpts are the options for starting the Postgres DB
type PostgresOpts struct {
	Name      string
	Image     string
	Env       []string
	VolumeMap map[string]struct{}
	Mounts    []mount.Mount
	NetworkID string
}

// StartPostgresContainer pulls a specific Porter image and starts a container
// using the Docker engine
func (a *Agent) StartPostgresContainer(opts PostgresOpts) (string, error) {
	id, err := a.upsertPostgresContainer(opts)
	if err != nil {
		return "", err
	}

	err = a.startPostgresContainer(id)

	if err != nil {
		return "", err
	}

	// attach container to network
	err = a.ConnectContainerToNetwork(opts.NetworkID, id, opts.Name)

	if err != nil {
		return "", err
	}

	return id, nil
}

// detect if container exists and is running, and stop
// if it is running, stop it
// if it is stopped, return id
// if it does not exist, create it and return it
func (a *Agent) upsertPostgresContainer(opts PostgresOpts) (id string, err error) {
	containers, err := a.getContainersCreatedByStart()

	// stop the matching container and return it
	for _, container := range containers {
		if len(container.Names) > 0 && container.Names[0] == "/"+opts.Name {
			timeout, _ := time.ParseDuration("15s")

			err := a.ContainerStop(a.ctx, container.ID, &timeout)
			if err != nil {
				return "", a.handleDockerClientErr(err, "Could not stop postgres container "+container.ID)
			}

			return container.ID, nil
		}
	}

	return a.pullAndCreatePostgresContainer(opts)
}

// create the container and return it
func (a *Agent) pullAndCreatePostgresContainer(opts PostgresOpts) (id string, err error) {
	a.PullImage(opts.Image)

	labels := make(map[string]string)
	labels[a.label] = "true"

	// create the container with a label specifying this was created via the CLI
	resp, err := a.ContainerCreate(a.ctx, &container.Config{
		Image:   opts.Image,
		Tty:     false,
		Labels:  labels,
		Volumes: opts.VolumeMap,
		Env:     opts.Env,
		ExposedPorts: nat.PortSet{
			"5432": struct{}{},
		},
		Healthcheck: &container.HealthConfig{
			Test:     []string{"CMD-SHELL", "pg_isready"},
			Interval: 10 * time.Second,
			Timeout:  5 * time.Second,
			Retries:  3,
		},
	}, &container.HostConfig{
		Mounts: opts.Mounts,
	}, nil, &specs.Platform{}, opts.Name)
	if err != nil {
		return "", a.handleDockerClientErr(err, "Could not create Porter container")
	}

	return resp.ID, nil
}

// start the container in the background
func (a *Agent) startPostgresContainer(id string) error {
	if err := a.ContainerStart(a.ctx, id, types.ContainerStartOptions{}); err != nil {
		return a.handleDockerClientErr(err, "Could not start Postgres container")
	}

	return nil
}

// StopPorterContainers finds all containers that were started via the CLI and stops them
// -- removes the container if remove is set to true
func (a *Agent) StopPorterContainers(remove bool) error {
	containers, err := a.getContainersCreatedByStart()
	if err != nil {
		return err
	}

	// remove all Porter containers
	for _, container := range containers {
		timeout, _ := time.ParseDuration("15s")

		err := a.ContainerStop(a.ctx, container.ID, &timeout)
		if err != nil {
			return a.handleDockerClientErr(err, "Could not stop container "+container.ID)
		}

		if remove {
			err = a.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})

			if err != nil {
				return a.handleDockerClientErr(err, "Could not remove container "+container.ID)
			}
		}
	}

	return nil
}

// StopPorterContainersWithProcessID finds all containers that were started via the CLI
// and have a given process id and stops them -- removes the container if remove is set
// to true
func (a *Agent) StopPorterContainersWithProcessID(processID string, remove bool) error {
	containers, err := a.getContainersCreatedByStart()
	if err != nil {
		return err
	}

	// remove all Porter containers
	for _, container := range containers {
		if strings.Contains(container.Names[0], "_"+processID) {
			timeout, _ := time.ParseDuration("15s")

			err := a.ContainerStop(a.ctx, container.ID, &timeout)
			if err != nil {
				return a.handleDockerClientErr(err, "Could not stop container "+container.ID)
			}

			if remove {
				err = a.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})

				if err != nil {
					return a.handleDockerClientErr(err, "Could not remove container "+container.ID)
				}
			}
		}
	}

	return nil
}

// getContainersCreatedByStart gets all containers that were created by the "porter start"
// command by looking for the label "CreatedByPorterCLI" (or .label of the agent)
func (a *Agent) getContainersCreatedByStart() ([]types.Container, error) {
	containers, err := a.ContainerList(a.ctx, types.ContainerListOptions{
		All: true,
	})
	if err != nil {
		return nil, a.handleDockerClientErr(err, "Could not list containers")
	}

	res := make([]types.Container, 0)

	for _, container := range containers {
		if contains, ok := container.Labels[a.label]; ok && contains == "true" {
			res = append(res, container)
		}
	}

	return res, nil
}
