// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package getmodules

import (
	"archive/zip"
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"testing"

	"github.com/hashicorp/go-getter"
	ociDigest "github.com/opencontainers/go-digest"
	ociSpecs "github.com/opencontainers/image-spec/specs-go"
	ociv1 "github.com/opencontainers/image-spec/specs-go/v1"
	orasContent "oras.land/oras-go/v2/content"
	orasMemoryStore "oras.land/oras-go/v2/content/memory"
)

func TestGetterDecompressorsConsistent(t *testing.T) {
	// This test makes sure that the following three variables are
	// all defined consistently enough with one another to satisfy
	// the assumptions that ociDistributionGetter makes about them:
	// - goGetterDecompressors
	// - goGetterDecompressorMediaTypes
	// - ociBlobMediaTypePreference

	// Assumption 1: all entries in goGetterDecompressorMediaTypes have
	// a corresponding entry in goGetterDecompressors.
	for k, v := range goGetterDecompressorMediaTypes {
		_, ok := goGetterDecompressors[v]
		if !ok {
			t.Errorf("goGetterDecompressorMediaTypes[%q] refers to %q, which is not defined in goGetterDecompressors", k, v)
		}
	}

	// Assumption 2: every entry in goGetterDecompressorMediaTypes is
	// included somewhere in ociBlobMediaTypePreference, so that we
	// know which media type to prefer when multiple are present.
	if lenMT, lenPref := len(goGetterDecompressorMediaTypes), len(ociBlobMediaTypePreference); lenMT != lenPref {
		t.Errorf("goGetterDecompressorMediaTypes has %d elements, but ociBlobMediaTypePreference has %d; should be equal length", lenMT, lenPref)
	}
	for _, v := range ociBlobMediaTypePreference {
		_, ok := goGetterDecompressorMediaTypes[v]
		if !ok {
			t.Errorf("ociBlobMediaTypePreference includes %q, which is not present in goGetterDecompressorMediaTypes", v)
		}
	}
}

func TestOCIDistributionGetter(t *testing.T) {
	// For this test we'll use an in-memory-only repository store implementation,
	// although we need to use a local wrapper to work around a leaky abstraction
	// where the in-memory store doesn't behave quite the same as a real OCI
	// Distribution registry client would. :(
	//
	// In real use ociDistributionGetter is more likely to be used with ORAS-Go's
	// remote registry client implementation, but that's the caller's responsibility
	// to decide if so.
	mainStore := digestResolvingInMemoryOCIStore{
		orasMemoryStore.New(),
	}

	// We'll build some fake-but-valid module packages to put in this store so
	// that we can test various valid source address inputs.
	latestBlobDesc := ociPushFakeModulePackageBlob(t, "content of latest", mainStore)
	latestManifestDesc := ociPushFakeImageManifest(t, latestBlobDesc, ociIndexManifestArtifactType, mainStore)
	ociCreateTag(t, "latest", latestManifestDesc, mainStore)
	fooBlobDesc := ociPushFakeModulePackageBlob(t, "content of foo", mainStore)
	fooManifestDesc := ociPushFakeImageManifest(t, fooBlobDesc, ociIndexManifestArtifactType, mainStore)
	ociCreateTag(t, "foo", fooManifestDesc, mainStore)
	digestBlobDesc := ociPushFakeModulePackageBlob(t, "content of digest-only reference", mainStore)
	digestManifestDesc := ociPushFakeImageManifest(t, digestBlobDesc, ociIndexManifestArtifactType, mainStore)
	digestManifestDigestStr := digestManifestDesc.Digest.String()

	// We'll log the digests of the three manifests we're going to use in
	// the tests below just in case they appear as part of error messages,
	// so we can understand what failed.
	t.Logf("'latest' tag\nmanifest: %s\nblob:     %s", latestManifestDesc.Digest, latestBlobDesc.Digest)
	t.Logf("'foo' tag\nmanifest: %s\nblob:     %s", fooManifestDesc.Digest, fooBlobDesc.Digest)
	t.Logf("untagged manifest\nmanifest: %s\nblob:     %s", digestManifestDigestStr, digestBlobDesc.Digest)

	ociGetter := &ociDistributionGetter{
		getOCIRepositoryStore: func(ctx context.Context, registryDomain, repositoryName string) (OCIRepositoryStore, error) {
			if registryDomain != "example.com" {
				return nil, fmt.Errorf("no such registry")
			}
			switch repositoryName {
			case "main":
				return mainStore, nil
			case "empty":
				// We'll just return a completely empty store for this one
				return orasMemoryStore.New(), nil
			default:
				return nil, fmt.Errorf("no such repository")
			}
		},
	}

	tests := []struct {
		source          string
		wantFileContent string
		wantError       string
	}{
		{
			source:          "oci://example.com/main",
			wantFileContent: `content of latest`,
		},
		{
			source:          "oci://example.com/main?tag=foo",
			wantFileContent: `content of foo`,
		},
		{
			// NOTE: This particular test is currently relying on the workaround
			// applied by our store wrapper type [digestResolvingInMemoryOCIStore],
			// because the upstream ORAS-Go in-memory store does not implement
			// "Resolve" realistically as compared to a registry client implementation.
			source:          "oci://example.com/main?digest=" + digestManifestDigestStr,
			wantFileContent: `content of digest-only reference`,
		},

		// Various failure cases
		{
			source:    "oci://nonexist.example.com/boop",
			wantError: `error downloading 'oci://nonexist.example.com/boop': configuring client for nonexist.example.com/boop: no such registry`,
		},
		{
			source:    "oci://example.com/in$valid", // invalid repository name syntax, per OCI Distribution spec
			wantError: `error downloading 'oci://example.com/in$valid': invalid reference: invalid repository "in$valid"`,
		},
		{
			source:    "oci://example.com/empty",
			wantError: `error downloading 'oci://example.com/empty': resolving tag: latest: not found`,
		},
		{
			source:    "oci://example.com/empty?tag=baz",
			wantError: `error downloading 'oci://example.com/empty?tag=baz': resolving tag: baz: not found`,
		},
		{
			source:    "oci://example.com/empty?tag=in$valid", // invalid tag name syntax, per OCI distribution spec
			wantError: `error downloading 'oci://example.com/empty?tag=in%24valid': invalid reference: invalid tag "in$valid"`,
		},
		{
			source:    "oci://example.com/empty?digest=sha256:1d57d25084effd3fdfd902eca00020b34b1fb020253b84d7dd471301606015ac",
			wantError: `error downloading 'oci://example.com/empty?digest=sha256%3A1d57d25084effd3fdfd902eca00020b34b1fb020253b84d7dd471301606015ac': resolving digest: sha256:1d57d25084effd3fdfd902eca00020b34b1fb020253b84d7dd471301606015ac: not found`,
		},
		{
			source:    "oci://example.com/empty?tag=",
			wantError: `error downloading 'oci://example.com/empty?tag=': tag argument must not be empty`,
		},
		{
			source:    "oci://example.com/empty?tag=foo&tag=bar",
			wantError: `error downloading 'oci://example.com/empty?tag=foo&tag=bar': too many "tag" arguments`,
		},
		{
			source:    "oci://example.com/empty?digest=nope",
			wantError: `error downloading 'oci://example.com/empty?digest=nope': invalid digest: invalid checksum digest format`,
		},
		{
			source:    "oci://example.com/empty?digest=nope:nope",
			wantError: `error downloading 'oci://example.com/empty?digest=nope%3Anope': invalid digest: unsupported digest algorithm`,
		},
		{
			source:    "oci://example.com/empty?digest=",
			wantError: `error downloading 'oci://example.com/empty?digest=': digest argument must not be empty`,
		},
		{
			source:    "oci://example.com/empty?digest=nope&digest=nope",
			wantError: `error downloading 'oci://example.com/empty?digest=nope&digest=nope': too many "digest" arguments`,
		},
		{
			source:    "oci://example.com/empty?tag=foo&digest=sha256:1d57d25084effd3fdfd902eca00020b34b1fb020253b84d7dd471301606015ac",
			wantError: `error downloading 'oci://example.com/empty?digest=sha256%3A1d57d25084effd3fdfd902eca00020b34b1fb020253b84d7dd471301606015ac&tag=foo': cannot set both "tag" and "digest" arguments`,
		},
		{
			source:    "oci://example.com/empty?tag=foo&other=bar",
			wantError: `error downloading 'oci://example.com/empty?other=bar&tag=foo': unsupported argument "other"`,
		},
		{
			source:    "oci://example.com/empty?archive=zip",
			wantError: `the "archive" argument is not allowed for OCI sources, because the archive format is detected automatically from the image manifest`,
		},
	}

	for _, test := range tests {
		t.Run(test.source, func(t *testing.T) {
			instPath := t.TempDir()
			client := getter.Client{
				Src: test.source,
				Dst: instPath,
				Pwd: instPath,

				Mode: getter.ClientModeDir,

				Detectors: goGetterNoDetectors,
				Getters: map[string]getter.Getter{
					"oci": ociGetter,
				},
				Ctx: t.Context(),
			}
			err := client.Get()

			if test.wantError != "" {
				if err == nil {
					t.Fatalf("unexpected success\nwant error: %s", test.wantError)
				}
				if got := err.Error(); got != test.wantError {
					t.Fatalf("unexpected error\ngot:  %s\nwant: %s", got, test.wantError)
				}
				return
			}

			if err != nil {
				t.Fatalf("unexpected error: %s", err)
			}
			if test.wantFileContent == "" {
				return // no file content test required
			}

			gotContentRaw, err := os.ReadFile(filepath.Join(instPath, "test_content.txt"))
			if err != nil {
				t.Fatal(err)
			}
			gotContent := string(bytes.TrimSpace(gotContentRaw))
			if gotContent != test.wantFileContent {
				t.Errorf("wrong file content after successful install\ngot:  %s\nwant: %s", gotContent, test.wantFileContent)
			}
		})
	}

}

func ociPushFakeModulePackageBlob(t *testing.T, fakeContent string, store orasContent.Pusher) ociv1.Descriptor {
	t.Helper()

	var buf bytes.Buffer
	zr := zip.NewWriter(&buf)
	fw, err := zr.Create("test_content.txt")
	if err != nil {
		t.Fatalf("can't create file in fake module package: %s", err)
	}
	n, err := io.WriteString(fw, fakeContent)
	if err != nil {
		t.Fatalf("can't write to file in fake module package: %s", err)
	}
	if n != len(fakeContent) {
		t.Fatalf("incomplete write of fake content")
	}
	zr.Close()

	desc := ociv1.Descriptor{
		MediaType: "archive/zip",
		Digest:    ociDigest.FromBytes(buf.Bytes()),
		Size:      int64(buf.Len()),
	}
	err = store.Push(t.Context(), desc, &buf)
	if err != nil {
		t.Fatalf("can't push blob to store: %s", err)
	}
	return desc
}

func ociPushFakeImageManifest(t *testing.T, layerDesc ociv1.Descriptor, artifactType string, store orasContent.Pusher) ociv1.Descriptor {
	t.Helper()

	manifest := &ociv1.Manifest{
		Versioned: ociSpecs.Versioned{
			SchemaVersion: 2,
		},
		MediaType:    ociv1.MediaTypeImageManifest,
		ArtifactType: artifactType,
		Config:       ociv1.DescriptorEmptyJSON,
		Layers:       []ociv1.Descriptor{layerDesc},
	}
	manifestSrc, err := json.Marshal(manifest)
	if err != nil {
		t.Errorf("can't serialize manifest: %s", err)
	}

	desc := ociv1.Descriptor{
		MediaType:    manifest.MediaType,
		ArtifactType: manifest.ArtifactType,
		Digest:       ociDigest.FromBytes(manifestSrc),
		Size:         int64(len(manifestSrc)),
	}
	err = store.Push(t.Context(), desc, bytes.NewReader(manifestSrc))
	if err != nil {
		t.Fatalf("can't push manifest to store: %s", err)
	}
	return desc
}

func ociCreateTag(t *testing.T, tagName string, desc ociv1.Descriptor, store orasContent.Tagger) {
	t.Helper()

	err := store.Tag(t.Context(), desc, tagName)
	if err != nil {
		t.Fatalf("can't create tag %q: %s", tagName, err)
	}
}

// digestResolvingInMemoryOCIStore is a moderately-ugly hack to make
// ORAS-Go's in memory store behave slightly more like a realistic
// OCI Distribution registry server by allowing the resolution of
// raw digests into descriptors, whereas the upstream in-memory
// implementation only allows resolving tags.
//
// It's unfortunate that the various ORAS-Go "fake" implementations
// are not realistic as compared to a real registry, but this minor
// concession allows us to avoid all of the pain of running a _real_
// registry server to support our unit tests.
//
// (We also use an end-to-end test in the command/e2etest package
// that exercises similar behavior with ORAS-Go's real registry
// client implementation, to give us some insurance that this
// workaround stays realistic enough.)
type digestResolvingInMemoryOCIStore struct {
	*orasMemoryStore.Store
}

var _ OCIRepositoryStore = digestResolvingInMemoryOCIStore{}

func (s digestResolvingInMemoryOCIStore) Push(ctx context.Context, expected ociv1.Descriptor, content io.Reader) error {
	// First we'll delegate to the upstream implementation to get the blob
	// actually saved in the store.
	err := s.Store.Push(ctx, expected, content)
	if err != nil {
		return err
	}

	// If storage was successful _and_ if the descriptor suggests that this
	// was intended to be a manifest blob then we'll create a fake "tag"
	// whose name matches the digest, which is just enough to trick
	// the "Resolve" method into handling the lookup the same way a real
	// OCI Registry client would handle it.
	if expected.MediaType == ociv1.MediaTypeImageManifest {
		err = s.Store.Tag(ctx, expected, expected.Digest.String())
		if err != nil {
			return fmt.Errorf("while creating a weird tag to fake looking up by digest: %w", err)
		}
	}

	return nil
}
