package manifest

import (
	"context"
	"encoding/json"
	"fmt"
	"io"

	"github.com/distribution/reference"
	"github.com/docker/cli/cli"
	"github.com/docker/cli/cli/command"
	"github.com/docker/cli/cli/manifest/types"
	"github.com/docker/cli/internal/registryclient"
	"github.com/docker/distribution"
	"github.com/docker/distribution/manifest/manifestlist"
	"github.com/docker/distribution/manifest/ocischema"
	"github.com/docker/distribution/manifest/schema2"
	"github.com/spf13/cobra"
)

type pushOpts struct {
	insecure bool
	purge    bool
	target   string
}

type mountRequest struct {
	ref      reference.Named
	manifest types.ImageManifest
}

type manifestBlob struct {
	canonical reference.Canonical
	os        string
}

type pushRequest struct {
	targetRef     reference.Named
	list          *manifestlist.DeserializedManifestList
	mountRequests []mountRequest
	manifestBlobs []manifestBlob
	insecure      bool
}

func newPushListCommand(dockerCLI command.Cli) *cobra.Command {
	opts := pushOpts{}

	cmd := &cobra.Command{
		Use:   "push [OPTIONS] MANIFEST_LIST",
		Short: "Push a manifest list to a repository",
		Args:  cli.ExactArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			opts.target = args[0]
			return runPush(cmd.Context(), dockerCLI, opts)
		},
		DisableFlagsInUseLine: true,
	}

	flags := cmd.Flags()
	flags.BoolVarP(&opts.purge, "purge", "p", false, "Remove the local manifest list after push")
	flags.BoolVar(&opts.insecure, "insecure", false, "Allow push to an insecure registry")
	return cmd
}

func runPush(ctx context.Context, dockerCli command.Cli, opts pushOpts) error {
	targetRef, err := normalizeReference(opts.target)
	if err != nil {
		return err
	}

	manifests, err := newManifestStore(dockerCli).GetList(targetRef)
	if err != nil {
		return err
	}
	if len(manifests) == 0 {
		return fmt.Errorf("%s not found", targetRef)
	}

	req, err := buildPushRequest(manifests, targetRef, opts.insecure)
	if err != nil {
		return err
	}

	if err := pushList(ctx, dockerCli, req); err != nil {
		return err
	}
	if opts.purge {
		return newManifestStore(dockerCli).Remove(targetRef)
	}
	return nil
}

func buildPushRequest(manifests []types.ImageManifest, targetRef reference.Named, insecure bool) (pushRequest, error) {
	req := pushRequest{targetRef: targetRef, insecure: insecure}

	var err error
	req.list, err = buildManifestList(manifests, targetRef)
	if err != nil {
		return req, err
	}

	targetRepoName := reference.Path(reference.TrimNamed(targetRef))

	for _, imageManifest := range manifests {
		manifestRepoName := reference.Path(reference.TrimNamed(imageManifest.Ref))
		repoName, _ := reference.WithName(manifestRepoName)
		if repoName.Name() != targetRepoName {
			blobs, err := buildBlobRequestList(imageManifest, repoName)
			if err != nil {
				return req, err
			}
			req.manifestBlobs = append(req.manifestBlobs, blobs...)

			manifestPush, err := buildPutManifestRequest(imageManifest, targetRef)
			if err != nil {
				return req, err
			}
			req.mountRequests = append(req.mountRequests, manifestPush)
		}
	}
	return req, nil
}

func buildManifestList(manifests []types.ImageManifest, targetRef reference.Named) (*manifestlist.DeserializedManifestList, error) {
	targetRepo := reference.TrimNamed(targetRef)
	descriptors := make([]manifestlist.ManifestDescriptor, 0, len(manifests))
	for _, imageManifest := range manifests {
		if imageManifest.Descriptor.Platform == nil ||
			imageManifest.Descriptor.Platform.Architecture == "" ||
			imageManifest.Descriptor.Platform.OS == "" {
			return nil, fmt.Errorf("manifest %s must have an OS and Architecture to be pushed to a registry", imageManifest.Ref)
		}
		descriptor, err := buildManifestDescriptor(targetRepo, imageManifest)
		if err != nil {
			return nil, err
		}
		descriptors = append(descriptors, descriptor)
	}

	return manifestlist.FromDescriptors(descriptors)
}

func buildManifestDescriptor(targetRepo reference.Named, imageManifest types.ImageManifest) (manifestlist.ManifestDescriptor, error) {
	manifestRepoHostname := reference.Domain(reference.TrimNamed(imageManifest.Ref))
	targetRepoHostname := reference.Domain(reference.TrimNamed(targetRepo))
	if manifestRepoHostname != targetRepoHostname {
		return manifestlist.ManifestDescriptor{}, fmt.Errorf("cannot use source images from a different registry than the target image: %s != %s", manifestRepoHostname, targetRepoHostname)
	}

	manifest := manifestlist.ManifestDescriptor{
		Descriptor: distribution.Descriptor{
			Digest:    imageManifest.Descriptor.Digest,
			Size:      imageManifest.Descriptor.Size,
			MediaType: imageManifest.Descriptor.MediaType,
		},
	}

	platform := types.PlatformSpecFromOCI(imageManifest.Descriptor.Platform)
	if platform != nil {
		manifest.Platform = *platform
	}

	if err := manifest.Descriptor.Digest.Validate(); err != nil {
		return manifestlist.ManifestDescriptor{}, fmt.Errorf("digest parse of image %q failed: %w", imageManifest.Ref, err)
	}

	return manifest, nil
}

func buildBlobRequestList(imageManifest types.ImageManifest, repoName reference.Named) ([]manifestBlob, error) {
	blobs := imageManifest.Blobs()
	blobReqs := make([]manifestBlob, 0, len(blobs))
	for _, blobDigest := range blobs {
		canonical, err := reference.WithDigest(repoName, blobDigest)
		if err != nil {
			return nil, err
		}
		var os string
		if imageManifest.Descriptor.Platform != nil {
			os = imageManifest.Descriptor.Platform.OS
		}
		blobReqs = append(blobReqs, manifestBlob{canonical: canonical, os: os})
	}
	return blobReqs, nil
}

func buildPutManifestRequest(imageManifest types.ImageManifest, targetRef reference.Named) (mountRequest, error) {
	refWithoutTag, err := reference.WithName(targetRef.Name())
	if err != nil {
		return mountRequest{}, err
	}
	mountRef, err := reference.WithDigest(refWithoutTag, imageManifest.Descriptor.Digest)
	if err != nil {
		return mountRequest{}, err
	}

	// Attempt to reconstruct indentation of the manifest to ensure sha parity
	// with the registry - if we haven't preserved the raw content.
	//
	// This is necessary because our previous internal storage format did not
	// preserve whitespace. If we don't have the newer format present, we can
	// attempt the reconstruction like before, but explicitly error if the
	// reconstruction failed!
	switch {
	case imageManifest.SchemaV2Manifest != nil:
		dt := imageManifest.Raw
		if len(dt) == 0 {
			dt, err = json.MarshalIndent(imageManifest.SchemaV2Manifest, "", "   ")
			if err != nil {
				return mountRequest{}, err
			}
		}

		dig := imageManifest.Descriptor.Digest
		if dig2 := dig.Algorithm().FromBytes(dt); dig != dig2 {
			return mountRequest{}, fmt.Errorf("internal digest mismatch for %s: expected %s, got %s", imageManifest.Ref, dig, dig2)
		}

		var manifest schema2.DeserializedManifest
		if err = manifest.UnmarshalJSON(dt); err != nil {
			return mountRequest{}, err
		}
		imageManifest.SchemaV2Manifest = &manifest
	case imageManifest.OCIManifest != nil:
		dt := imageManifest.Raw
		if len(dt) == 0 {
			dt, err = json.MarshalIndent(imageManifest.OCIManifest, "", "  ")
			if err != nil {
				return mountRequest{}, err
			}
		}

		dig := imageManifest.Descriptor.Digest
		if dig2 := dig.Algorithm().FromBytes(dt); dig != dig2 {
			return mountRequest{}, fmt.Errorf("internal digest mismatch for %s: expected %s, got %s", imageManifest.Ref, dig, dig2)
		}

		var manifest ocischema.DeserializedManifest
		if err = manifest.UnmarshalJSON(dt); err != nil {
			return mountRequest{}, err
		}
		imageManifest.OCIManifest = &manifest
	}

	return mountRequest{ref: mountRef, manifest: imageManifest}, err
}

func pushList(ctx context.Context, dockerCLI command.Cli, req pushRequest) error {
	registryClient := newRegistryClient(dockerCLI, req.insecure)

	if err := mountBlobs(ctx, registryClient, req.targetRef, req.manifestBlobs); err != nil {
		return err
	}
	if err := pushReferences(ctx, dockerCLI.Out(), registryClient, req.mountRequests); err != nil {
		return err
	}
	dgst, err := registryClient.PutManifest(ctx, req.targetRef, req.list)
	if err != nil {
		return err
	}

	_, _ = fmt.Fprintln(dockerCLI.Out(), dgst.String())
	return nil
}

func pushReferences(ctx context.Context, out io.Writer, client registryclient.RegistryClient, mounts []mountRequest) error {
	for _, mount := range mounts {
		newDigest, err := client.PutManifest(ctx, mount.ref, mount.manifest)
		if err != nil {
			return err
		}
		_, _ = fmt.Fprintf(out, "Pushed ref %s with digest: %s\n", mount.ref, newDigest)
	}
	return nil
}

func mountBlobs(ctx context.Context, client registryclient.RegistryClient, ref reference.Named, blobs []manifestBlob) error {
	for _, blob := range blobs {
		err := client.MountBlob(ctx, blob.canonical, ref)
		switch err.(type) {
		case nil:
		case registryclient.ErrBlobCreated:
			if blob.os != "windows" {
				return fmt.Errorf("error mounting %s to %s", blob.canonical, ref)
			}
		default:
			return err
		}
	}
	return nil
}
