package image

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"

	"github.com/buildpacks/pack/pkg/archive"
	"github.com/go-logr/logr"
	"github.com/google/go-containerregistry/pkg/authn"
	"github.com/google/go-containerregistry/pkg/authn/k8schain"
	"github.com/google/go-containerregistry/pkg/name"
	"github.com/google/go-containerregistry/pkg/v1/empty"
	"github.com/google/go-containerregistry/pkg/v1/mutate"
	"github.com/google/go-containerregistry/pkg/v1/remote"
	"github.com/google/go-containerregistry/pkg/v1/remote/transport"
	"github.com/google/go-containerregistry/pkg/v1/tarball"
	"k8s.io/client-go/kubernetes"
	"k8s.io/utils/net"
	ctrl "sigs.k8s.io/controller-runtime"
)

type Client struct {
	clientset kubernetes.Interface
	logger    logr.Logger
}

type Creds struct {
	Namespace string
	// At most one of SecretNames and ServiceAccountName should be set.
	// If both unset, the fallback auth approach will be used.
	SecretNames        []string
	ServiceAccountName string
}

type Config struct {
	Labels       map[string]string
	User         string
	ExposedPorts []int32
}

func NewClient(clietnset kubernetes.Interface) Client {
	return Client{
		clientset: clietnset,
		logger:    ctrl.Log.WithName("image.client"),
	}
}

func (c Client) Push(ctx context.Context, creds Creds, repoRef string, zipReader io.Reader, tags ...string) (string, error) {
	tmpFile, err := os.CreateTemp(os.TempDir(), "sourceimg-%s")
	if err != nil {
		return "", fmt.Errorf("failed to create a temp file for image: %w", err)
	}
	defer tmpFile.Close()

	if _, err = io.Copy(tmpFile, zipReader); err != nil {
		return "", fmt.Errorf("failed to copy image source into temp file '%s' %w", tmpFile.Name(), err)
	}

	layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) {
		return archive.ReadZipAsTar(tmpFile.Name(), "/", 0, 0, -1, true, nil), nil
	})
	if err != nil {
		return "", fmt.Errorf("failed to create a layer out of '%s': %w", tmpFile.Name(), err)
	}

	image, err := mutate.AppendLayers(empty.Image, layer)
	if err != nil {
		return "", fmt.Errorf("failed to append layer: %w", err)
	}

	ref, err := name.ParseReference(repoRef)
	if err != nil {
		return "", fmt.Errorf("error parsing repository reference %s: %w", repoRef, err)
	}

	authOpt, err := c.authOpt(ctx, creds)
	if err != nil {
		return "", fmt.Errorf("error creating keychain: %w", err)
	}

	if err = remote.Write(ref, image, authOpt); err != nil {
		return "", fmt.Errorf("failed to upload image: %w", err)
	}

	for _, tag := range tags {
		err = remote.Tag(ref.Context().Tag(tag), image, authOpt)
		if err != nil {
			return "", fmt.Errorf("failed to tag image: %w", err)
		}
	}

	imgDigest, err := image.Digest()
	if err != nil {
		return "", fmt.Errorf("failed to get image digest: %w", err)
	}

	refWithDigest, err := name.NewDigest(fmt.Sprintf("%s@%s", ref.Context().Name(), imgDigest.String()))
	if err != nil {
		return "", fmt.Errorf("failed to create digest: %w", err)
	}

	return refWithDigest.Name(), nil
}

func (c Client) Config(ctx context.Context, creds Creds, imageRef string) (Config, error) {
	ref, err := name.ParseReference(imageRef)
	if err != nil {
		return Config{}, fmt.Errorf("error parsing repository reference %s: %w", imageRef, err)
	}

	authOpt, err := c.authOpt(ctx, creds)
	if err != nil {
		return Config{}, fmt.Errorf("error creating keychain: %w", err)
	}

	img, err := remote.Image(ref, authOpt)
	if err != nil {
		return Config{}, fmt.Errorf("failed to get image: %w", err)
	}

	cfgFile, err := img.ConfigFile()
	if err != nil {
		return Config{}, fmt.Errorf("error getting image config file: %w", err)
	}

	ports := []int32{}
	for _, p := range parseExposedPorts(cfgFile.Config.ExposedPorts) {
		parsed, err := net.ParsePort(p, false)
		if err != nil {
			return Config{}, fmt.Errorf("error getting exposed ports: %w", err)
		}
		ports = append(ports, int32(parsed)) // #nosec G115
	}

	return Config{
		Labels:       cfgFile.Config.Labels,
		User:         cfgFile.Config.User,
		ExposedPorts: ports,
	}, nil
}

func parseExposedPorts(ports map[string]struct{}) []string {
	result := []string{}
	for p := range ports {
		result = append(result, strings.Split(p, "/")[0])
	}

	return result
}

func (c Client) Delete(ctx context.Context, creds Creds, imageRef string, tagsToDelete ...string) error {
	c.logger.V(1).Info("deleting", "ref", imageRef)
	ref, err := name.ParseReference(imageRef)
	if err != nil {
		return err
	}

	authOpt, err := c.authOpt(ctx, creds)
	if err != nil {
		return fmt.Errorf("error creating keychain: %w", err)
	}

	allTagSet, err := c.getTagSet(ref, authOpt)
	if err != nil {
		return fmt.Errorf("failed to list tags: %w", err)
	}

	for _, tag := range tagsToDelete {
		if !allTagSet[tag] {
			c.logger.Info("tag not found for this ref", "tag", tag, "ref", imageRef)
			continue
		}

		if err = c.deleteTag(ref, tag, authOpt); err != nil {
			c.logger.Info("failed to delete tag", "reason", err)
			continue
		}
		delete(allTagSet, tag)
	}

	// The latest tag is set automatically by registries. If it is the only
	// remaining tag, remove it to prevent digest deletion errors
	latestTag := "latest"
	if len(allTagSet) == 1 && allTagSet[latestTag] {
		if err = c.deleteTag(ref, latestTag, authOpt); err != nil {
			c.logger.Info("failed to delete tag", "reason", err)
		} else {
			delete(allTagSet, latestTag)
		}
	}

	if len(allTagSet) == 0 {
		err = remote.Delete(ref, authOpt)
		if err != nil {
			if structuredErr, ok := err.(*transport.Error); ok && structuredErr.StatusCode == http.StatusNotFound {
				c.logger.V(1).Info("manifest disappeared - continuing", "reason", err)
				return nil
			}
		}
	}

	return err
}

func (c Client) getTagSet(ref name.Reference, authOpt remote.Option) (map[string]bool, error) {
	allTags, err := remote.List(ref.Context(), authOpt)
	if err != nil {
		c.logger.V(1).Info("failed to list tags - skipping tag deletion", "reason", err)
		return nil, err
	}

	allTagSet := map[string]bool{}
	for _, t := range allTags {
		var tagRef name.Reference
		tagRef, err = name.ParseReference(ref.Context().String() + ":" + t)
		if err != nil {
			return nil, fmt.Errorf("couldn't create a tag ref: %w", err)
		}

		var descriptor *remote.Descriptor
		descriptor, err = remote.Get(tagRef, authOpt)
		if err != nil {
			return nil, fmt.Errorf("couldn't get tag: %w", err)
		}

		if descriptor.Digest.String() == ref.Identifier() {
			allTagSet[t] = true
		}
	}

	return allTagSet, nil
}

func (c Client) deleteTag(ref name.Reference, tag string, authOpt remote.Option) error {
	tagRef, err := name.ParseReference(ref.Context().String() + ":" + tag)
	if err != nil {
		return fmt.Errorf("couldn't create a tag ref: %w", err)
	}
	var descriptor *remote.Descriptor
	descriptor, err = remote.Get(tagRef, authOpt)
	if err != nil {
		c.logger.V(1).Info("failed get tag - continuing", "reason", err)
		return nil
	}

	if descriptor.Digest.String() == ref.Identifier() {
		c.logger.V(1).Info("deleting tag", "tag", tag)
		err = remote.Delete(tagRef, authOpt)
		if err != nil {
			c.logger.V(1).Info("failed to delete tag", "reason", err)
		}
	}

	return nil
}

func (c Client) authOpt(ctx context.Context, creds Creds) (remote.Option, error) {
	var keychain authn.Keychain
	var err error

	if len(creds.SecretNames) > 0 {
		keychain, err = k8schain.New(ctx, c.clientset, k8schain.Options{
			Namespace:        creds.Namespace,
			ImagePullSecrets: creds.SecretNames,
		})
	} else if creds.ServiceAccountName != "" {
		keychain, err = k8schain.New(ctx, c.clientset, k8schain.Options{
			Namespace:          creds.Namespace,
			ServiceAccountName: creds.ServiceAccountName,
		})
	} else {
		keychain, err = k8schain.NewNoClient(ctx)
	}
	if err != nil {
		return nil, err
	}

	return remote.WithAuthFromKeychain(keychain), nil
}
