package meta_test

import (
	"context"
	"errors"
	"testing"

	godigest "github.com/opencontainers/go-digest"
	ispec "github.com/opencontainers/image-spec/specs-go/v1"
	. "github.com/smartystreets/goconvey/convey"

	zerr "zotregistry.dev/zot/v2/errors"
	"zotregistry.dev/zot/v2/pkg/extensions/monitoring"
	"zotregistry.dev/zot/v2/pkg/log"
	"zotregistry.dev/zot/v2/pkg/meta"
	"zotregistry.dev/zot/v2/pkg/meta/boltdb"
	mTypes "zotregistry.dev/zot/v2/pkg/meta/types"
	"zotregistry.dev/zot/v2/pkg/storage"
	"zotregistry.dev/zot/v2/pkg/storage/local"
	stypes "zotregistry.dev/zot/v2/pkg/storage/types"
	. "zotregistry.dev/zot/v2/pkg/test/image-utils"
	"zotregistry.dev/zot/v2/pkg/test/mocks"
)

var (
	errDeleteAfterMetaHookTest        = errors.New("delete manifest after meta failure hook test")
	errDigestTagsSetRepoReferenceFail = errors.New("injected SetRepoReference failure for digest-tags rollback tests")
	errGetRepoMetaForDigestTags       = errors.New("get repo meta failed for digest tags test")
	errSetRepoRefForHookTest          = errors.New("set repo reference failed for hook test")
)

// setRepoRefFailMetaDB delegates to an inner MetaDB but fails SetRepoReference for one tag (used to
// exercise multi-tag digest rollback after a partial OnUpdateManifest success).
type setRepoRefFailMetaDB struct {
	mTypes.MetaDB

	failRef string
}

func (w *setRepoRefFailMetaDB) SetRepoReference(
	ctx context.Context, repo, ref string, imageMeta mTypes.ImageMeta,
) error {
	if ref == w.failRef {
		return errDigestTagsSetRepoReferenceFail
	}

	return w.MetaDB.SetRepoReference(ctx, repo, ref, imageMeta)
}

// failDeleteImageStore delegates to an inner ImageStore but forces DeleteImageManifest to return deleteErr.
type failDeleteImageStore struct {
	stypes.ImageStore

	deleteErr error
}

func (f *failDeleteImageStore) DeleteImageManifest(repo, reference string, detectCollision bool) error {
	return f.deleteErr
}

func TestOnUpdateManifestDigestTags_emptyTags(t *testing.T) {
	Convey("OnUpdateManifestDigestTags with no tags is a no-op (nil MetaDB: no GetRepoMeta/SetRepoReference path)",
		t, func() {
			log := log.NewTestLogger()

			err := meta.OnUpdateManifestDigestTags(context.Background(), "repo", nil, ispec.MediaTypeImageManifest,
				godigest.Digest(""), nil, storage.StoreController{}, nil, log)
			So(err, ShouldBeNil)
		})
}

func TestOnUpdateManifestDigestTags_success(t *testing.T) {
	Convey("OnUpdateManifestDigestTags updates metadb for each digest query tag", t, func() {
		rootDir := t.TempDir()
		storeController := storage.StoreController{}
		log := log.NewTestLogger()
		metrics := monitoring.NewMetricsServer(false, log)

		defer metrics.Stop()
		storeController.DefaultStore = local.NewImageStore(rootDir, true, true, log, metrics, nil, nil, nil, nil)

		params := boltdb.DBParameters{RootDir: rootDir}
		boltDriver, err := boltdb.GetBoltDriver(params)
		So(err, ShouldBeNil)

		metaDB, err := boltdb.New(boltDriver, log)
		So(err, ShouldBeNil)

		image := CreateDefaultImage()
		mediaType := image.ManifestDescriptor.MediaType
		if mediaType == "" {
			mediaType = ispec.MediaTypeImageManifest
		}

		manifestBody := image.ManifestDescriptor.Data
		manifestDigest := image.Digest()

		err = WriteImageToFileSystem(image, "repo", "seed", storeController)
		So(err, ShouldBeNil)

		err = meta.OnUpdateManifest(context.Background(), "repo", "seed", mediaType, manifestDigest, manifestBody,
			storeController, metaDB, log)
		So(err, ShouldBeNil)

		imgStore := storeController.GetImageStore("repo")
		_, _, err = imgStore.PutImageManifest("repo", manifestDigest.String(), mediaType, manifestBody,
			[]string{"ta", "tb"})
		So(err, ShouldBeNil)

		err = meta.OnUpdateManifestDigestTags(context.Background(), "repo", []string{"ta", "tb"}, mediaType,
			manifestDigest, manifestBody, storeController, metaDB, log)
		So(err, ShouldBeNil)

		wantDigest := manifestDigest.String()

		repoMeta, err := metaDB.GetRepoMeta(context.Background(), "repo")
		So(err, ShouldBeNil)
		So(repoMeta.Tags, ShouldContainKey, "ta")
		So(repoMeta.Tags, ShouldContainKey, "tb")
		So(repoMeta.Tags, ShouldContainKey, "seed")
		So(repoMeta.Tags["ta"].Digest, ShouldEqual, wantDigest)
		So(repoMeta.Tags["tb"].Digest, ShouldEqual, wantDigest)
		So(repoMeta.Tags["seed"].Digest, ShouldEqual, wantDigest)
	})
}

func TestOnUpdateManifestDigestTags_rollbackPartialMeta(t *testing.T) {
	Convey("OnUpdateManifestDigestTags rollback deletes all new index tags; meta rollback only for applied tags",
		t, func() {
			rootDir := t.TempDir()
			storeController := storage.StoreController{}
			log := log.NewTestLogger()
			metrics := monitoring.NewMetricsServer(false, log)

			defer metrics.Stop()
			storeController.DefaultStore = local.NewImageStore(rootDir, true, true, log, metrics, nil, nil, nil, nil)

			params := boltdb.DBParameters{RootDir: rootDir}
			boltDriver, err := boltdb.GetBoltDriver(params)
			So(err, ShouldBeNil)

			metaDB, err := boltdb.New(boltDriver, log)
			So(err, ShouldBeNil)

			image := CreateDefaultImage()
			mediaType := image.ManifestDescriptor.MediaType
			if mediaType == "" {
				mediaType = ispec.MediaTypeImageManifest
			}

			manifestBody := image.ManifestDescriptor.Data
			manifestDigest := image.Digest()

			err = WriteImageToFileSystem(image, "repo", "seed", storeController)
			So(err, ShouldBeNil)

			err = meta.OnUpdateManifest(context.Background(), "repo", "seed", mediaType, manifestDigest, manifestBody,
				storeController, metaDB, log)
			So(err, ShouldBeNil)

			imgStore := storeController.GetImageStore("repo")
			_, _, err = imgStore.PutImageManifest("repo", manifestDigest.String(), mediaType, manifestBody,
				[]string{"ta", "tb"})
			So(err, ShouldBeNil)

			repoMetaBefore, err := metaDB.GetRepoMeta(context.Background(), "repo")
			So(err, ShouldBeNil)
			seedDigestBefore := repoMetaBefore.Tags["seed"].Digest
			So(seedDigestBefore, ShouldEqual, manifestDigest.String())

			wrapped := &setRepoRefFailMetaDB{MetaDB: metaDB, failRef: "tb"}

			err = meta.OnUpdateManifestDigestTags(context.Background(), "repo", []string{"ta", "tb"}, mediaType,
				manifestDigest, manifestBody, storeController, wrapped, log)
			So(err, ShouldEqual, errDigestTagsSetRepoReferenceFail)

			_, _, _, err = imgStore.GetImageManifest("repo", "ta")
			So(errors.Is(err, zerr.ErrManifestNotFound), ShouldBeTrue)
			_, _, _, err = imgStore.GetImageManifest("repo", "tb")
			So(errors.Is(err, zerr.ErrManifestNotFound), ShouldBeTrue)

			seedBody, _, _, err := imgStore.GetImageManifest("repo", "seed")
			So(err, ShouldBeNil)
			So(godigest.FromBytes(seedBody).String(), ShouldEqual, manifestDigest.String())

			repoMeta, err := metaDB.GetRepoMeta(context.Background(), "repo")
			So(err, ShouldBeNil)
			So(repoMeta.Tags, ShouldNotContainKey, "ta")
			So(repoMeta.Tags, ShouldNotContainKey, "tb")
			So(repoMeta.Tags, ShouldContainKey, "seed")
			So(repoMeta.Tags["seed"].Digest, ShouldEqual, seedDigestBefore)
		})
}

func TestOnUpdateManifestDigestTags_rollbackRestoresMovedTag(t *testing.T) {
	Convey("rollback restores a tag moved from digest A to digest B back to digest A when MetaDB fails later",
		t, func() {
			rootDir := t.TempDir()
			storeController := storage.StoreController{}
			log := log.NewTestLogger()
			metrics := monitoring.NewMetricsServer(false, log)

			defer metrics.Stop()
			storeController.DefaultStore = local.NewImageStore(rootDir, true, true, log, metrics, nil, nil, nil, nil)

			params := boltdb.DBParameters{RootDir: rootDir}
			boltDriver, err := boltdb.GetBoltDriver(params)
			So(err, ShouldBeNil)

			metaDB, err := boltdb.New(boltDriver, log)
			So(err, ShouldBeNil)

			imageA := CreateDefaultImage()
			imageB := CreateRandomImage()
			So(imageA.Digest(), ShouldNotEqual, imageB.Digest())

			mediaTypeA := imageA.ManifestDescriptor.MediaType
			if mediaTypeA == "" {
				mediaTypeA = ispec.MediaTypeImageManifest
			}

			mediaTypeB := imageB.ManifestDescriptor.MediaType
			if mediaTypeB == "" {
				mediaTypeB = ispec.MediaTypeImageManifest
			}

			bodyA := imageA.ManifestDescriptor.Data
			digestA := imageA.Digest()
			bodyB := imageB.ManifestDescriptor.Data
			digestB := imageB.Digest()

			err = WriteImageToFileSystem(imageA, "repo", "movable", storeController)
			So(err, ShouldBeNil)

			err = meta.OnUpdateManifest(context.Background(), "repo", "movable", mediaTypeA, digestA, bodyA,
				storeController, metaDB, log)
			So(err, ShouldBeNil)

			err = WriteImageToFileSystem(imageB, "repo", "yardB", storeController)
			So(err, ShouldBeNil)

			err = meta.OnUpdateManifest(context.Background(), "repo", "yardB", mediaTypeB, digestB, bodyB,
				storeController, metaDB, log)
			So(err, ShouldBeNil)

			imgStore := storeController.GetImageStore("repo")

			_, _, err = imgStore.PutImageManifest("repo", digestB.String(), mediaTypeB, bodyB,
				[]string{"movable", "onlyB"})
			So(err, ShouldBeNil)

			wrapped := &setRepoRefFailMetaDB{MetaDB: metaDB, failRef: "onlyB"}

			err = meta.OnUpdateManifestDigestTags(context.Background(), "repo", []string{"movable", "onlyB"}, mediaTypeB,
				digestB, bodyB, storeController, wrapped, log)
			So(err, ShouldEqual, errDigestTagsSetRepoReferenceFail)

			movableBody, movableD, _, err := imgStore.GetImageManifest("repo", "movable")
			So(err, ShouldBeNil)
			So(movableD.String(), ShouldEqual, digestA.String())
			So(godigest.FromBytes(movableBody).String(), ShouldEqual, digestA.String())

			_, _, _, err = imgStore.GetImageManifest("repo", "onlyB")
			So(errors.Is(err, zerr.ErrManifestNotFound), ShouldBeTrue)

			repoMeta, err := metaDB.GetRepoMeta(context.Background(), "repo")
			So(err, ShouldBeNil)
			So(repoMeta.Tags["movable"].Digest, ShouldEqual, digestA.String())
			So(repoMeta.Tags["yardB"].Digest, ShouldEqual, digestB.String())
			So(repoMeta.Tags, ShouldNotContainKey, "onlyB")
		})
}

func TestOnUpdateManifestDigestTags_getRepoMetaError(t *testing.T) {
	Convey("OnUpdateManifestDigestTags returns when GetRepoMeta fails with a non-ErrRepoMetaNotFound error", t, func() {
		log := log.NewTestLogger()
		metaDB := mocks.MetaDBMock{
			GetRepoMetaFn: func(context.Context, string) (mTypes.RepoMeta, error) {
				return mTypes.RepoMeta{}, errGetRepoMetaForDigestTags
			},
		}

		d := godigest.FromString("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")

		err := meta.OnUpdateManifestDigestTags(context.Background(), "repo", []string{"a"}, ispec.MediaTypeImageManifest,
			d, []byte("{}"), storage.StoreController{}, metaDB, log)
		So(errors.Is(err, errGetRepoMetaForDigestTags), ShouldBeTrue)
	})
}

func TestOnUpdateManifestDigestTags_whenRepoMetaMissing(t *testing.T) {
	Convey("ErrRepoMetaNotFound during snapshot still allows digest query tag meta updates", t, func() {
		rootDir := t.TempDir()
		storeController := storage.StoreController{}
		log := log.NewTestLogger()
		metrics := monitoring.NewMetricsServer(false, log)

		defer metrics.Stop()
		storeController.DefaultStore = local.NewImageStore(rootDir, true, true, log, metrics, nil, nil, nil, nil)

		params := boltdb.DBParameters{RootDir: rootDir}
		boltDriver, err := boltdb.GetBoltDriver(params)
		So(err, ShouldBeNil)

		metaDB, err := boltdb.New(boltDriver, log)
		So(err, ShouldBeNil)

		image := CreateDefaultImage()
		mediaType := image.ManifestDescriptor.MediaType
		if mediaType == "" {
			mediaType = ispec.MediaTypeImageManifest
		}

		manifestBody := image.ManifestDescriptor.Data
		manifestDigest := image.Digest()

		err = WriteImageToFileSystem(image, "repo", "seed", storeController)
		So(err, ShouldBeNil)

		_, err = metaDB.GetRepoMeta(context.Background(), "repo")
		So(errors.Is(err, zerr.ErrRepoMetaNotFound), ShouldBeTrue)

		imgStore := storeController.GetImageStore("repo")
		_, _, err = imgStore.PutImageManifest("repo", manifestDigest.String(), mediaType, manifestBody,
			[]string{"ta", "tb"})
		So(err, ShouldBeNil)

		err = meta.OnUpdateManifestDigestTags(context.Background(), "repo", []string{"ta", "tb"}, mediaType,
			manifestDigest, manifestBody, storeController, metaDB, log)
		So(err, ShouldBeNil)

		wantDigest := manifestDigest.String()

		repoMeta, err := metaDB.GetRepoMeta(context.Background(), "repo")
		So(err, ShouldBeNil)
		So(repoMeta.Tags, ShouldContainKey, "ta")
		So(repoMeta.Tags, ShouldContainKey, "tb")
		So(repoMeta.Tags, ShouldNotContainKey, "seed")
		So(repoMeta.Tags["ta"].Digest, ShouldEqual, wantDigest)
		So(repoMeta.Tags["tb"].Digest, ShouldEqual, wantDigest)
	})
}

func TestOnUpdateManifest_setRepoReferenceFailsRemovesManifest(t *testing.T) {
	Convey("OnUpdateManifest deletes the manifest from the store when SetRepoReference fails", t, func() {
		rootDir := t.TempDir()
		storeController := storage.StoreController{}
		log := log.NewTestLogger()
		metrics := monitoring.NewMetricsServer(false, log)

		defer metrics.Stop()
		storeController.DefaultStore = local.NewImageStore(rootDir, true, true, log, metrics, nil, nil, nil, nil)

		metaDB := mocks.MetaDBMock{
			SetRepoReferenceFn: func(context.Context, string, string, mTypes.ImageMeta) error {
				return errSetRepoRefForHookTest
			},
		}

		image := CreateDefaultImage()
		mediaType := image.ManifestDescriptor.MediaType
		if mediaType == "" {
			mediaType = ispec.MediaTypeImageManifest
		}

		err := WriteImageToFileSystem(image, "repo", "tag1", storeController)
		So(err, ShouldBeNil)

		imgStore := storeController.GetImageStore("repo")

		err = meta.OnUpdateManifest(context.Background(), "repo", "tag1", mediaType, image.Digest(),
			image.ManifestDescriptor.Data, storeController, metaDB, log)
		So(errors.Is(err, errSetRepoRefForHookTest), ShouldBeTrue)

		_, _, _, err = imgStore.GetImageManifest("repo", "tag1")
		So(errors.Is(err, zerr.ErrManifestNotFound), ShouldBeTrue)
	})
}

func TestOnUpdateManifest_whenDeleteAfterMetaFailureFails(t *testing.T) {
	Convey("OnUpdateManifest returns the delete error when meta fails and store cleanup fails", t, func() {
		rootDir := t.TempDir()
		storeController := storage.StoreController{}
		log := log.NewTestLogger()
		metrics := monitoring.NewMetricsServer(false, log)

		defer metrics.Stop()
		baseStore := local.NewImageStore(rootDir, true, true, log, metrics, nil, nil, nil, nil)
		storeController.DefaultStore = &failDeleteImageStore{
			ImageStore: baseStore,
			deleteErr:  errDeleteAfterMetaHookTest,
		}

		metaDB := mocks.MetaDBMock{
			SetRepoReferenceFn: func(context.Context, string, string, mTypes.ImageMeta) error {
				return errSetRepoRefForHookTest
			},
		}

		image := CreateDefaultImage()
		mediaType := image.ManifestDescriptor.MediaType
		if mediaType == "" {
			mediaType = ispec.MediaTypeImageManifest
		}

		err := WriteImageToFileSystem(image, "repo", "tag1", storeController)
		So(err, ShouldBeNil)

		err = meta.OnUpdateManifest(context.Background(), "repo", "tag1", mediaType, image.Digest(),
			image.ManifestDescriptor.Data, storeController, metaDB, log)
		So(errors.Is(err, errDeleteAfterMetaHookTest), ShouldBeTrue)
	})
}

func TestOnUpdateManifest(t *testing.T) {
	Convey("On UpdateManifest", t, func() {
		rootDir := t.TempDir()
		storeController := storage.StoreController{}
		log := log.NewTestLogger()
		metrics := monitoring.NewMetricsServer(false, log)

		defer metrics.Stop() // Clean up metrics server to prevent resource leaks
		storeController.DefaultStore = local.NewImageStore(rootDir, true, true, log, metrics, nil, nil, nil, nil)

		params := boltdb.DBParameters{
			RootDir: rootDir,
		}
		boltDriver, err := boltdb.GetBoltDriver(params)
		So(err, ShouldBeNil)

		metaDB, err := boltdb.New(boltDriver, log)
		So(err, ShouldBeNil)

		image := CreateDefaultImage()

		err = WriteImageToFileSystem(CreateDefaultImage(), "repo", "tag1", storeController)
		So(err, ShouldBeNil)

		err = meta.OnUpdateManifest(context.Background(), "repo", "tag1", ispec.MediaTypeImageManifest, image.Digest(),
			image.ManifestDescriptor.Data, storeController, metaDB, log)
		So(err, ShouldBeNil)

		repoMeta, err := metaDB.GetRepoMeta(context.Background(), "repo")
		So(err, ShouldBeNil)

		So(repoMeta.Tags, ShouldContainKey, "tag1")
	})
}

func TestUpdateErrors(t *testing.T) {
	Convey("Update operations", t, func() {
		imageStore := mocks.MockedImageStore{}
		storeController := storage.StoreController{DefaultStore: &imageStore}
		metaDB := mocks.MetaDBMock{}
		log := log.NewTestLogger()

		Convey("IsReferrersTag true update", func() {
			err := meta.OnUpdateManifest(context.Background(), "repo", "sha256-123", "digest", "media", []byte("bad"),
				storeController, metaDB, log)
			So(err, ShouldBeNil)
		})
		Convey("IsReferrersTag true delete", func() {
			err := meta.OnDeleteManifest("repo", "sha256-123", "digest", "media", []byte("bad"),
				storeController, metaDB, log)
			So(err, ShouldBeNil)
		})
	})
}
