package utils

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"os"
	"regexp"
	"strings"
	"sync"
	"time"

	podman "github.com/containers/common/libnetwork/types"
	"github.com/docker/cli/cli/command"
	"github.com/docker/cli/cli/compose/loader"
	dockerConfig "github.com/docker/cli/cli/config"
	dockerFlags "github.com/docker/cli/cli/flags"
	"github.com/docker/cli/cli/streams"
	"github.com/docker/docker/api/types/container"
	"github.com/docker/docker/api/types/filters"
	"github.com/docker/docker/api/types/image"
	"github.com/docker/docker/api/types/mount"
	"github.com/docker/docker/api/types/network"
	"github.com/docker/docker/api/types/versions"
	"github.com/docker/docker/api/types/volume"
	"github.com/docker/docker/client"
	"github.com/docker/docker/errdefs"
	"github.com/docker/docker/pkg/jsonmessage"
	"github.com/docker/docker/pkg/stdcopy"
	"github.com/go-errors/errors"
	"github.com/spf13/viper"
	"go.opentelemetry.io/otel"
)

var Docker = NewDocker()

func NewDocker() *client.Client {
	// TODO: refactor to initialize lazily
	cli, err := command.NewDockerCli()
	if err != nil {
		log.Fatalln("Failed to create Docker client:", err)
	}
	// Silence otel errors as users don't care about docker metrics
	// 2024/08/12 23:11:12 1 errors occurred detecting resource:
	// 	* conflicting Schema URL: https://opentelemetry.io/schemas/1.21.0
	otel.SetErrorHandler(otel.ErrorHandlerFunc(func(cause error) {}))
	if err := cli.Initialize(&dockerFlags.ClientOptions{}); err != nil {
		log.Fatalln("Failed to initialize Docker client:", err)
	}
	return cli.Client().(*client.Client)
}

const (
	DinDHost            = "host.docker.internal"
	CliProjectLabel     = "com.supabase.cli.project"
	composeProjectLabel = "com.docker.compose.project"
)

func DockerNetworkCreateIfNotExists(ctx context.Context, mode container.NetworkMode, labels map[string]string) error {
	// Non-user defined networks should already exist
	if !isUserDefined(mode) {
		return nil
	}
	_, err := Docker.NetworkCreate(ctx, mode.NetworkName(), network.CreateOptions{Labels: labels})
	// if error is network already exists, no need to propagate to user
	if errdefs.IsConflict(err) || errors.Is(err, podman.ErrNetworkExists) {
		return nil
	}
	if err != nil {
		return errors.Errorf("failed to create docker network: %w", err)
	}
	return err
}

func WaitAll[T any](containers []T, exec func(container T) error) []error {
	var wg sync.WaitGroup
	result := make([]error, len(containers))
	for i, container := range containers {
		wg.Add(1)
		go func(i int, container T) {
			defer wg.Done()
			result[i] = exec(container)
		}(i, container)
	}
	wg.Wait()
	return result
}

// NoBackupVolume TODO: encapsulate this state in a class
var NoBackupVolume = false

func DockerRemoveAll(ctx context.Context, w io.Writer, projectId string) error {
	fmt.Fprintln(w, "Stopping containers...")
	args := CliProjectFilter(projectId)
	containers, err := Docker.ContainerList(ctx, container.ListOptions{
		All:     true,
		Filters: args,
	})
	if err != nil {
		return errors.Errorf("failed to list containers: %w", err)
	}
	// Gracefully shutdown containers
	var ids []string
	for _, c := range containers {
		if c.State == "running" {
			ids = append(ids, c.ID)
		}
	}
	result := WaitAll(ids, func(id string) error {
		if err := Docker.ContainerStop(ctx, id, container.StopOptions{}); err != nil {
			return errors.Errorf("failed to stop container: %w", err)
		}
		return nil
	})
	if err := errors.Join(result...); err != nil {
		return err
	}
	if report, err := Docker.ContainersPrune(ctx, args); err != nil {
		return errors.Errorf("failed to prune containers: %w", err)
	} else if viper.GetBool("DEBUG") {
		fmt.Fprintln(os.Stderr, "Pruned containers:", report.ContainersDeleted)
	}
	// Remove named volumes
	if NoBackupVolume {
		vargs := args.Clone()
		if versions.GreaterThanOrEqualTo(Docker.ClientVersion(), "1.42") {
			// Since docker engine 25.0.3, all flag is required to include named volumes.
			// https://github.com/docker/cli/blob/master/cli/command/volume/prune.go#L76
			vargs.Add("all", "true")
		}
		if report, err := Docker.VolumesPrune(ctx, vargs); err != nil {
			return errors.Errorf("failed to prune volumes: %w", err)
		} else if viper.GetBool("DEBUG") {
			fmt.Fprintln(os.Stderr, "Pruned volumes:", report.VolumesDeleted)
		}
	}
	// Remove networks.
	if report, err := Docker.NetworksPrune(ctx, args); err != nil {
		return errors.Errorf("failed to prune networks: %w", err)
	} else if viper.GetBool("DEBUG") {
		fmt.Fprintln(os.Stderr, "Pruned network:", report.NetworksDeleted)
	}
	return nil
}

func CliProjectFilter(projectId string) filters.Args {
	if len(projectId) == 0 {
		return filters.NewArgs(
			filters.Arg("label", CliProjectLabel),
		)
	}
	return filters.NewArgs(
		filters.Arg("label", CliProjectLabel+"="+projectId),
	)
}

var (
	// Only supports one registry per command invocation
	registryAuth string
	registryOnce sync.Once
)

func GetRegistryAuth() string {
	registryOnce.Do(func() {
		config := dockerConfig.LoadDefaultConfigFile(os.Stderr)
		// Ref: https://docs.docker.com/engine/api/sdk/examples/#pull-an-image-with-authentication
		auth, err := config.GetAuthConfig(GetRegistry())
		if err != nil {
			fmt.Fprintln(os.Stderr, "Failed to load registry credentials:", err)
			return
		}
		encoded, err := json.Marshal(auth)
		if err != nil {
			fmt.Fprintln(os.Stderr, "Failed to serialise auth config:", err)
			return
		}
		registryAuth = base64.URLEncoding.EncodeToString(encoded)
	})
	return registryAuth
}

// Defaults to Supabase public ECR for faster image pull
const defaultRegistry = "public.ecr.aws"

func GetRegistry() string {
	registry := viper.GetString("INTERNAL_IMAGE_REGISTRY")
	if len(registry) == 0 {
		return defaultRegistry
	}
	return strings.ToLower(registry)
}

func GetRegistryImageUrl(imageName string) string {
	registry := GetRegistry()
	if registry == "docker.io" {
		return imageName
	}
	// Configure mirror registry
	parts := strings.Split(imageName, "/")
	imageName = parts[len(parts)-1]
	return registry + "/supabase/" + imageName
}

func DockerImagePull(ctx context.Context, imageTag string, w io.Writer) error {
	out, err := Docker.ImagePull(ctx, imageTag, image.PullOptions{
		RegistryAuth: GetRegistryAuth(),
	})
	if err != nil {
		return errors.Errorf("failed to pull docker image: %w", err)
	}
	defer out.Close()
	if err := jsonmessage.DisplayJSONMessagesToStream(out, streams.NewOut(w), nil); err != nil {
		return errors.Errorf("failed to display json stream: %w", err)
	}
	return nil
}

// Used by unit tests
var timeUnit = time.Second

func DockerImagePullWithRetry(ctx context.Context, image string, retries int) error {
	err := DockerImagePull(ctx, image, os.Stderr)
	for i := 0; i < retries; i++ {
		if err == nil || errors.Is(ctx.Err(), context.Canceled) {
			break
		}
		fmt.Fprintln(os.Stderr, err)
		period := time.Duration(2<<(i+1)) * timeUnit
		fmt.Fprintf(os.Stderr, "Retrying after %v: %s\n", period, image)
		time.Sleep(period)
		err = DockerImagePull(ctx, image, os.Stderr)
	}
	return err
}

func DockerPullImageIfNotCached(ctx context.Context, imageName string) error {
	imageUrl := GetRegistryImageUrl(imageName)
	if _, _, err := Docker.ImageInspectWithRaw(ctx, imageUrl); err == nil {
		return nil
	} else if !client.IsErrNotFound(err) {
		return errors.Errorf("failed to inspect docker image: %w", err)
	}
	return DockerImagePullWithRetry(ctx, imageUrl, 2)
}

var suggestDockerInstall = "Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop"

func DockerStart(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string) (string, error) {
	// Pull container image
	if err := DockerPullImageIfNotCached(ctx, config.Image); err != nil {
		if client.IsErrConnectionFailed(err) {
			CmdSuggestion = suggestDockerInstall
		}
		return "", err
	}
	// Setup default config
	config.Image = GetRegistryImageUrl(config.Image)
	if config.Labels == nil {
		config.Labels = make(map[string]string, 2)
	}
	config.Labels[CliProjectLabel] = Config.ProjectId
	config.Labels[composeProjectLabel] = Config.ProjectId
	// Configure container network
	hostConfig.ExtraHosts = append(hostConfig.ExtraHosts, extraHosts...)
	if networkId := viper.GetString("network-id"); len(networkId) > 0 {
		hostConfig.NetworkMode = container.NetworkMode(networkId)
	} else if len(hostConfig.NetworkMode) == 0 {
		hostConfig.NetworkMode = container.NetworkMode(NetId)
	}
	if err := DockerNetworkCreateIfNotExists(ctx, hostConfig.NetworkMode, config.Labels); err != nil {
		return "", err
	}
	// Configure container volumes
	var binds, sources []string
	for _, bind := range hostConfig.Binds {
		spec, err := loader.ParseVolume(bind)
		if err != nil {
			return "", errors.Errorf("failed to parse docker volume: %w", err)
		}
		if spec.Type != string(mount.TypeVolume) {
			binds = append(binds, bind)
		} else if len(spec.Source) > 0 {
			sources = append(sources, spec.Source)
		}
	}
	// Skip named volume for BitBucket pipeline
	if os.Getenv("BITBUCKET_CLONE_DIR") != "" {
		hostConfig.Binds = binds
		// Bitbucket doesn't allow for --security-opt option to be set
		// https://support.atlassian.com/bitbucket-cloud/docs/run-docker-commands-in-bitbucket-pipelines/#Full-list-of-restricted-commands
		hostConfig.SecurityOpt = nil
	} else {
		// Create named volumes with labels
		for _, name := range sources {
			if _, err := Docker.VolumeCreate(ctx, volume.CreateOptions{
				Name:   name,
				Labels: config.Labels,
			}); err != nil {
				return "", errors.Errorf("failed to create volume: %w", err)
			}
		}
	}
	// Create container from image
	resp, err := Docker.ContainerCreate(ctx, &config, &hostConfig, &networkingConfig, nil, containerName)
	if err != nil {
		return "", errors.Errorf("failed to create docker container: %w", err)
	}
	// Run container in background
	err = Docker.ContainerStart(ctx, resp.ID, container.StartOptions{})
	if err != nil {
		if hostPort := parsePortBindError(err); len(hostPort) > 0 {
			CmdSuggestion = suggestDockerStop(ctx, hostPort)
			prefix := "Or configure"
			if len(CmdSuggestion) == 0 {
				prefix = "Try configuring"
			}
			name := containerName
			if endpoint, ok := networkingConfig.EndpointsConfig[NetId]; ok && len(endpoint.Aliases) > 0 {
				name = endpoint.Aliases[0]
			}
			CmdSuggestion += fmt.Sprintf("\n%s a different %s port in %s", prefix, name, Bold(ConfigPath))
		}
		err = errors.Errorf("failed to start docker container: %w", err)
	}
	return resp.ID, err
}

func DockerRemove(containerId string) {
	if err := Docker.ContainerRemove(context.Background(), containerId, container.RemoveOptions{
		RemoveVolumes: true,
		Force:         true,
	}); err != nil {
		fmt.Fprintln(os.Stderr, "Failed to remove container:", containerId, err)
	}
}

type DockerJob struct {
	Image string
	Env   []string
	Cmd   []string
}

func DockerRunJob(ctx context.Context, job DockerJob, stdout, stderr io.Writer) error {
	return DockerRunOnceWithStream(ctx, job.Image, job.Env, job.Cmd, stdout, stderr)
}

// Runs a container image exactly once, returning stdout and throwing error on non-zero exit code.
func DockerRunOnce(ctx context.Context, image string, env []string, cmd []string) (string, error) {
	stderr := GetDebugLogger()
	var out bytes.Buffer
	err := DockerRunOnceWithStream(ctx, image, env, cmd, &out, stderr)
	return out.String(), err
}

func DockerRunOnceWithStream(ctx context.Context, image string, env, cmd []string, stdout, stderr io.Writer) error {
	return DockerRunOnceWithConfig(ctx, container.Config{
		Image: image,
		Env:   env,
		Cmd:   cmd,
	}, container.HostConfig{}, network.NetworkingConfig{}, "", stdout, stderr)
}

func DockerRunOnceWithConfig(ctx context.Context, config container.Config, hostConfig container.HostConfig, networkingConfig network.NetworkingConfig, containerName string, stdout, stderr io.Writer) error {
	// Cannot rely on docker's auto remove because
	//   1. We must inspect exit code after container stops
	//   2. Context cancellation may happen after start
	container, err := DockerStart(ctx, config, hostConfig, networkingConfig, containerName)
	if err != nil {
		return err
	}
	defer DockerRemove(container)
	return DockerStreamLogs(ctx, container, stdout, stderr)
}

func DockerStreamLogs(ctx context.Context, containerId string, stdout, stderr io.Writer) error {
	// Stream logs
	logs, err := Docker.ContainerLogs(ctx, containerId, container.LogsOptions{
		ShowStdout: true,
		ShowStderr: true,
		Follow:     true,
	})
	if err != nil {
		return errors.Errorf("failed to read docker logs: %w", err)
	}
	defer logs.Close()
	if _, err := stdcopy.StdCopy(stdout, stderr, logs); err != nil {
		return errors.Errorf("failed to copy docker logs: %w", err)
	}
	// Check exit code
	resp, err := Docker.ContainerInspect(ctx, containerId)
	if err != nil {
		return errors.Errorf("failed to inspect docker container: %w", err)
	}
	if resp.State.ExitCode > 0 {
		return errors.Errorf("error running container: exit %d", resp.State.ExitCode)
	}
	return nil
}

func DockerStreamLogsOnce(ctx context.Context, containerId string, stdout, stderr io.Writer) error {
	logs, err := Docker.ContainerLogs(ctx, containerId, container.LogsOptions{
		ShowStdout: true,
		ShowStderr: true,
	})
	if err != nil {
		return errors.Errorf("failed to read docker logs: %w", err)
	}
	defer logs.Close()
	if _, err := stdcopy.StdCopy(stdout, stderr, logs); err != nil {
		return errors.Errorf("failed to copy docker logs: %w", err)
	}
	return nil
}

// Exec a command once inside a container, returning stdout and throwing error on non-zero exit code.
func DockerExecOnce(ctx context.Context, containerId string, env []string, cmd []string) (string, error) {
	stderr := io.Discard
	if viper.GetBool("DEBUG") {
		stderr = os.Stderr
	}
	var out bytes.Buffer
	err := DockerExecOnceWithStream(ctx, containerId, "", env, cmd, &out, stderr)
	return out.String(), err
}

func DockerExecOnceWithStream(ctx context.Context, containerId, workdir string, env, cmd []string, stdout, stderr io.Writer) error {
	// Reset shadow database
	exec, err := Docker.ContainerExecCreate(ctx, containerId, container.ExecOptions{
		Env:          env,
		Cmd:          cmd,
		WorkingDir:   workdir,
		AttachStderr: true,
		AttachStdout: true,
	})
	if err != nil {
		return errors.Errorf("failed to exec docker create: %w", err)
	}
	// Read exec output
	resp, err := Docker.ContainerExecAttach(ctx, exec.ID, container.ExecStartOptions{})
	if err != nil {
		return errors.Errorf("failed to exec docker attach: %w", err)
	}
	defer resp.Close()
	// Capture error details
	if _, err := stdcopy.StdCopy(stdout, stderr, resp.Reader); err != nil {
		return errors.Errorf("failed to copy docker logs: %w", err)
	}
	// Get the exit code
	iresp, err := Docker.ContainerExecInspect(ctx, exec.ID)
	if err != nil {
		return errors.Errorf("failed to exec docker inspect: %w", err)
	}
	if iresp.ExitCode > 0 {
		err = errors.New("error executing command")
	}
	return err
}

var portErrorPattern = regexp.MustCompile("Bind for (.*) failed: port is already allocated")

func parsePortBindError(err error) string {
	matches := portErrorPattern.FindStringSubmatch(err.Error())
	if len(matches) > 1 {
		return matches[len(matches)-1]
	}
	return ""
}

func suggestDockerStop(ctx context.Context, hostPort string) string {
	if containers, err := Docker.ContainerList(ctx, container.ListOptions{}); err == nil {
		for _, c := range containers {
			for _, p := range c.Ports {
				if fmt.Sprintf("%s:%d", p.IP, p.PublicPort) == hostPort {
					if project, ok := c.Labels[CliProjectLabel]; ok {
						return "\nTry stopping the running project with " + Aqua("supabase stop --project-id "+project)
					} else {
						name := c.ID
						if len(c.Names) > 0 {
							name = c.Names[0]
						}
						return "\nTry stopping the running container with " + Aqua("docker stop "+name)
					}
				}
			}
		}
	}
	return ""
}
