package auth

import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"os/user"
	"path/filepath"
	"regexp"
	"strings"

	"github.com/docker/cli/cli/config/configfile"
	"github.com/docker/cli/cli/config/credentials"
	"github.com/docker/cli/cli/config/types"
	"github.com/docker/docker/pkg/homedir"

	"gitlab.com/gitlab-org/gitlab-runner/common"
)

const (
	// DefaultDockerRegistry is the name of the index
	DefaultDockerRegistry            = "docker.io"
	authConfigSourceNameUserVariable = "$DOCKER_AUTH_CONFIG"
	authConfigSourceNameJobPayload   = "job payload (GitLab Registry)"
)

var (
	HomeDirectory    = homedir.Get()
	errNoHomeDir     = errors.New("no home directory found")
	errPathTraversal = errors.New("path traversal is not allowed")
)

// RegistryInfo represents the source and authentication for a given registry.
type RegistryInfo struct {
	Source     string
	AuthConfig types.AuthConfig
}

type authConfigResolver func() (string, map[string]types.AuthConfig, error)

type DebugLogger interface {
	Debugln(args ...interface{})
}

// the parent directory of a path or ""
func parentPath(path string) string {
	index := strings.LastIndex(path, "/")
	if index == -1 {
		return ""
	}
	return path[:index]
}

// ResolveConfigForImage returns the auth configuration for a particular image.
// Returns nil on no config found.
// See ResolveConfigs for source information.
func ResolveConfigForImage(
	imageName, dockerAuthConfig, username string,
	credentials []common.Credentials, logger DebugLogger,
) (*RegistryInfo, error) {
	authConfigs, err := ResolveConfigs(dockerAuthConfig, username, credentials, logger)
	if len(authConfigs) == 0 || err != nil {
		return nil, err
	}

	path := dockerImageNamePath(imageName)
	for p := path; p != ""; p = parentPath(p) {
		info, ok := authConfigs[p]
		if ok {
			return &info, nil
		}
	}

	return nil, nil
}

// ResolveConfigs returns the authentication configuration for docker registries.
// Goes through several sources in this order:
// 1. DOCKER_AUTH_CONFIG
// 2. ~/.docker/config.json or .dockercfg
// 3. Build credentials
// Returns a map of registry hostname to RegistryInfo
func ResolveConfigs(
	dockerAuthConfig, username string,
	credentials []common.Credentials, logger DebugLogger,
) (map[string]RegistryInfo, error) {
	resolvers := []authConfigResolver{
		func() (string, map[string]types.AuthConfig, error) {
			return getUserConfiguration(dockerAuthConfig)
		},
		func() (string, map[string]types.AuthConfig, error) {
			return getHomeDirConfiguration(username)
		},
		func() (string, map[string]types.AuthConfig, error) {
			return getBuildConfiguration(credentials)
		},
	}
	res := make(map[string]RegistryInfo)

	for _, r := range resolvers {
		source, configs, err := r()
		if errors.Is(err, errPathTraversal) {
			return nil, err
		}

		var hostnames []string
		for registry, conf := range configs {
			registryPath := convertToRegistryPath(registry)
			hostnames = append(hostnames, registryPath)
			if _, ok := res[registryPath]; !ok {
				res[registryPath] = RegistryInfo{
					Source:     source,
					AuthConfig: conf,
				}
			}
		}

		// Source can be blank if there is no home dir configuration
		if source != "" {
			logger.Debugln(fmt.Sprintf("Loaded Docker credentials, source = %q, hostnames = %v, error = %v", source, hostnames, err))
		}
	}

	return res, nil
}

func getUserConfiguration(dockerAuthConfig string) (string, map[string]types.AuthConfig, error) {
	authConfigs, err := readConfigsFromReader(bytes.NewBufferString(dockerAuthConfig))
	if errors.Is(err, errPathTraversal) {
		return "", nil, err
	}
	if authConfigs == nil {
		return "", nil, nil
	}

	return authConfigSourceNameUserVariable, authConfigs, nil
}

func getHomeDirConfiguration(username string) (string, map[string]types.AuthConfig, error) {
	sourceFile, authConfigs, err := readDockerConfigsFromHomeDir(username)
	if errors.Is(err, errPathTraversal) {
		return "", nil, err
	}
	if authConfigs == nil {
		return "", nil, nil
	}

	return sourceFile, authConfigs, nil
}

// EncodeConfig constructs a token from an AuthConfig, suitable for
// authorizing against the Docker API with.
func EncodeConfig(authConfig *types.AuthConfig) (string, error) {
	if authConfig == nil {
		return "", nil
	}

	var buf bytes.Buffer
	if err := json.NewEncoder(&buf).Encode(authConfig); err != nil {
		return "", err
	}

	return base64.URLEncoding.EncodeToString(buf.Bytes()), nil
}

func getBuildConfiguration(credentials []common.Credentials) (string, map[string]types.AuthConfig, error) {
	authConfigs := make(map[string]types.AuthConfig)

	for _, credentials := range credentials {
		if credentials.Type != "registry" {
			continue
		}

		authConfigs[credentials.URL] = types.AuthConfig{
			Username:      credentials.Username,
			Password:      credentials.Password,
			ServerAddress: credentials.URL,
		}
	}

	return authConfigSourceNameJobPayload, authConfigs, nil
}

// Given a docker image reference get the path to lookup the authentication credentials
func dockerImageNamePath(imageName string) string {
	imageIndex := strings.LastIndex(imageName, "/")
	image := imageName
	if imageIndex != -1 {
		image = imageName[imageIndex+1:]
	}

	// remove tag
	image, _, _ = strings.Cut(image, ":")

	path := imageName[:imageIndex+1] + image

	nameParts := strings.SplitN(imageName, "/", 2)
	if len(nameParts) == 1 || (!strings.Contains(nameParts[0], ".") &&
		!strings.Contains(nameParts[0], ":") && nameParts[0] != "localhost") {
		// This is a Docker Index repos (ex: samalba/hipache or ubuntu)
		// 'docker.io'
		path = DefaultDockerRegistry + "/" + path
	} else if nameParts[0] == "index."+DefaultDockerRegistry {
		path, _ = strings.CutPrefix(path, "index.")
	}

	return pathWithLowerCaseHostname(path)
}

// readDockerConfigsFromHomeDir reads known docker config from home
// directory. If no username is provided it will get the home directory for the
// current user.
func readDockerConfigsFromHomeDir(userName string) (string, map[string]types.AuthConfig, error) {
	homeDir := HomeDirectory

	if userName != "" {
		u, err := user.Lookup(userName)
		if err != nil {
			return "", nil, err
		}
		homeDir = u.HomeDir
	}

	if homeDir == "" {
		return "", nil, errNoHomeDir
	}

	configFile := filepath.Join(homeDir, ".docker", "config.json")

	r, err := os.Open(configFile)
	if err != nil {
		configFile = filepath.Join(homeDir, ".dockercfg")
		r, err = os.Open(configFile)
		if err != nil && !os.IsNotExist(err) {
			return "", nil, err
		}
	}
	defer r.Close()

	if r == nil {
		return "", make(map[string]types.AuthConfig), nil
	}

	authConfigs, err := readConfigsFromReader(r)

	return configFile, authConfigs, err
}

func readConfigsFromReader(r io.Reader) (map[string]types.AuthConfig, error) {
	config := &configfile.ConfigFile{}

	if err := config.LoadFromReader(r); err != nil {
		if errors.Is(err, io.EOF) {
			err = nil
		}
		return nil, err
	}

	auths := make(map[string]types.AuthConfig)
	addAll(auths, config.AuthConfigs)

	if config.CredentialsStore != "" {
		authsFromCredentialsStore, err := readConfigsFromCredentialsStore(config)
		if err != nil {
			return nil, err
		}
		addAll(auths, authsFromCredentialsStore)
	}

	if config.CredentialHelpers != nil {
		authsFromCredentialsHelpers, err := readConfigsFromCredentialsHelper(config)
		if err != nil {
			return nil, err
		}
		addAll(auths, authsFromCredentialsHelpers)
	}

	return auths, nil
}

func readConfigsFromCredentialsStore(config *configfile.ConfigFile) (map[string]types.AuthConfig, error) {
	if config.CredentialsStore != filepath.Base(config.CredentialsStore) {
		// Fail processing if credential store attempting path traversal are detected
		return nil, errPathTraversal
	}

	store := credentials.NewNativeStore(config, config.CredentialsStore)
	newAuths, err := store.GetAll()
	if err != nil {
		return nil, err
	}

	return newAuths, nil
}

func readConfigsFromCredentialsHelper(config *configfile.ConfigFile) (map[string]types.AuthConfig, error) {
	helpersAuths := make(map[string]types.AuthConfig)

	for registry, helper := range config.CredentialHelpers {
		if helper != filepath.Base(helper) {
			// Fail processing if credential helpers attempting path traversal are detected
			return nil, errPathTraversal
		}

		store := credentials.NewNativeStore(config, helper)

		newAuths, err := store.Get(registry)
		if err != nil {
			return nil, err
		}

		helpersAuths[registry] = newAuths
	}

	return helpersAuths, nil
}

func addAll(to, from map[string]types.AuthConfig) {
	for reg, ac := range from {
		to[reg] = ac
	}
}

// convert hostname part to lower case.
// Since the hostname is case insensitive we convert it to lower case
// to allow matching with case sensitive comparison
func pathWithLowerCaseHostname(path string) string {
	nameParts := strings.SplitN(path, "/", 2)
	hostname := strings.ToLower(nameParts[0])
	if len(nameParts) == 1 {
		return hostname
	}

	return hostname + "/" + nameParts[1]
}

// Returns the normalized path for a docker registry reference for some credentials.
func convertToRegistryPath(imageRef string) string {
	protocol := regexp.MustCompile("(?i)^http[s]://")

	if protocol.MatchString(imageRef) {
		// old style with protocol and maybe suffix /v1/
		// just the use hostname
		path := protocol.ReplaceAllString(imageRef, "")

		nameParts := strings.SplitN(path, "/", 2)
		path = strings.ToLower(nameParts[0])

		if path == "index."+DefaultDockerRegistry {
			return DefaultDockerRegistry
		}

		return path
	}

	path := strings.TrimSuffix(imageRef, "/")

	tagIndex := strings.LastIndex(path, ":")
	pathIndex := strings.LastIndex(path, "/")
	// remove image tag from path
	if pathIndex != -1 && tagIndex > pathIndex {
		path = path[:strings.LastIndex(path, ":")]
	}

	return pathWithLowerCaseHostname(path)
}
