package cveinfo

import (
	"context"
	"sort"
	"strings"
	"time"

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

	zerr "zotregistry.dev/zot/errors"
	zcommon "zotregistry.dev/zot/pkg/common"
	"zotregistry.dev/zot/pkg/compat"
	cvemodel "zotregistry.dev/zot/pkg/extensions/search/cve/model"
	"zotregistry.dev/zot/pkg/extensions/search/cve/trivy"
	"zotregistry.dev/zot/pkg/log"
	mTypes "zotregistry.dev/zot/pkg/meta/types"
	"zotregistry.dev/zot/pkg/storage"
)

type CveInfo interface {
	GetImageListForCVE(ctx context.Context, repo, cveID string) ([]cvemodel.TagInfo, error)
	GetImageListWithCVEFixed(ctx context.Context, repo, cveID string) ([]cvemodel.TagInfo, error)
	GetCVEListForImage(ctx context.Context, repo, tag string, searchedCVE, excludedCVE string, severity string,
		pageinput cvemodel.PageInput) ([]cvemodel.CVE, cvemodel.ImageCVESummary, zcommon.PageInfo, error)
	GetCVEDiffListForImages(ctx context.Context, minuend, subtrahend, searchedCVE, excludedCVE string,
		pageInput cvemodel.PageInput) ([]cvemodel.CVE, cvemodel.ImageCVESummary, zcommon.PageInfo, error)
	GetCVESummaryForImageMedia(ctx context.Context, repo, digestStr, mediaType string) (cvemodel.ImageCVESummary, error)
}

type Scanner interface {
	ScanImage(ctx context.Context, image string) (map[string]cvemodel.CVE, error)
	IsImageFormatScannable(repo, ref string) (bool, error)
	IsImageMediaScannable(repo, digestStr, mediaType string) (bool, error)
	IsResultCached(digestStr string) bool
	GetCachedResult(digestStr string) map[string]cvemodel.CVE
	UpdateDB(ctx context.Context) error
}

type BaseCveInfo struct {
	Log     log.Logger
	Scanner Scanner
	MetaDB  mTypes.MetaDB
}

func NewScanner(storeController storage.StoreController, metaDB mTypes.MetaDB,
	dbRepository, javaDBRepository string, log log.Logger,
) Scanner {
	return trivy.NewScanner(storeController, metaDB, dbRepository, javaDBRepository, log)
}

func NewCVEInfo(scanner Scanner, metaDB mTypes.MetaDB, log log.Logger) *BaseCveInfo {
	return &BaseCveInfo{
		Log:     log,
		Scanner: scanner,
		MetaDB:  metaDB,
	}
}

func (cveinfo BaseCveInfo) GetImageListForCVE(ctx context.Context, repo, cveID string) ([]cvemodel.TagInfo, error) {
	imgList := make([]cvemodel.TagInfo, 0)

	repoMeta, err := cveinfo.MetaDB.GetRepoMeta(ctx, repo)
	if err != nil {
		cveinfo.Log.Error().Err(err).Str("repository", repo).Str("cve-id", cveID).
			Msg("failed to get list of tags from repo")

		return imgList, err
	}

	for tag, descriptor := range repoMeta.Tags {
		if descriptor.MediaType == ispec.MediaTypeImageManifest ||
			descriptor.MediaType == ispec.MediaTypeImageIndex ||
			compat.IsCompatibleManifestMediaType(descriptor.MediaType) ||
			compat.IsCompatibleManifestListMediaType(descriptor.MediaType) {
			manifestDigestStr := descriptor.Digest

			manifestDigest := godigest.Digest(manifestDigestStr)

			isScanableImage, err := cveinfo.Scanner.IsImageFormatScannable(repo, manifestDigestStr)
			if !isScanableImage || err != nil {
				cveinfo.Log.Debug().Str("image", repo+":"+tag).Err(err).Msg("image is not scanable")

				continue
			}

			cveMap, err := cveinfo.Scanner.ScanImage(ctx, zcommon.GetFullImageName(repo, tag))
			if err != nil {
				if zcommon.IsContextDone(ctx) {
					return imgList, err
				}

				cveinfo.Log.Info().Str("image", repo+":"+tag).Err(err).Msg("image scan failed")

				continue
			}

			if _, hasCVE := cveMap[cveID]; hasCVE {
				imgList = append(imgList, cvemodel.TagInfo{
					Tag: tag,
					Descriptor: cvemodel.Descriptor{
						Digest:    manifestDigest,
						MediaType: descriptor.MediaType,
					},
				})
			}
		} else {
			cveinfo.Log.Debug().Str("image", repo+":"+tag).Str("mediaType", descriptor.MediaType).
				Msg("image media type not supported for scanning")
		}
	}

	return imgList, nil
}

func (cveinfo BaseCveInfo) GetImageListWithCVEFixed(ctx context.Context, repo, cveID string,
) ([]cvemodel.TagInfo, error) {
	repoMeta, err := cveinfo.MetaDB.GetRepoMeta(ctx, repo)
	if err != nil {
		cveinfo.Log.Error().Err(err).Str("repository", repo).Str("cve-id", cveID).
			Msg("failed to get list of tags from repo")

		return []cvemodel.TagInfo{}, err
	}

	vulnerableTags := make([]cvemodel.TagInfo, 0)
	allTags := make([]cvemodel.TagInfo, 0)

	for tag, descriptor := range repoMeta.Tags {
		if zcommon.IsContextDone(ctx) {
			return []cvemodel.TagInfo{}, ctx.Err()
		}

		//nolint:gocritic // cannot convert to switch-case
		if descriptor.MediaType == ispec.MediaTypeImageManifest ||
			compat.IsCompatibleManifestMediaType(descriptor.MediaType) {
			manifestDigestStr := descriptor.Digest

			tagInfo, err := getTagInfoForManifest(tag, manifestDigestStr, cveinfo.MetaDB)
			if err != nil {
				cveinfo.Log.Error().Err(err).Str("repository", repo).Str("tag", tag).
					Str("cve-id", cveID).Msg("failed to retrieve manifest and config")

				continue
			}

			allTags = append(allTags, tagInfo)

			if cveinfo.isManifestVulnerable(ctx, repo, tag, manifestDigestStr, cveID) {
				vulnerableTags = append(vulnerableTags, tagInfo)
			}
		} else if descriptor.MediaType == ispec.MediaTypeImageIndex ||
			compat.IsCompatibleManifestListMediaType(descriptor.MediaType) {
			indexDigestStr := descriptor.Digest

			indexContent, err := getIndexContent(cveinfo.MetaDB, indexDigestStr)
			if err != nil {
				continue
			}

			vulnerableManifests := []cvemodel.DescriptorInfo{}
			allManifests := []cvemodel.DescriptorInfo{}

			for _, manifest := range indexContent.Manifests {
				tagInfo, err := getTagInfoForManifest(tag, manifest.Digest.String(), cveinfo.MetaDB)
				if err != nil {
					cveinfo.Log.Error().Err(err).Str("repository", repo).Str("tag", tag).
						Str("cve-id", cveID).Msg("failed to retrieve manifest and config")

					continue
				}

				manifestDescriptorInfo := cvemodel.DescriptorInfo{
					Descriptor: tagInfo.Descriptor,
					Timestamp:  tagInfo.Timestamp,
				}

				allManifests = append(allManifests, manifestDescriptorInfo)

				if cveinfo.isManifestVulnerable(ctx, repo, tag, manifest.Digest.String(), cveID) {
					vulnerableManifests = append(vulnerableManifests, manifestDescriptorInfo)
				}
			}

			if len(allManifests) > 0 {
				allTags = append(allTags, cvemodel.TagInfo{
					Tag: tag,
					Descriptor: cvemodel.Descriptor{
						Digest:    godigest.Digest(indexDigestStr),
						MediaType: ispec.MediaTypeImageIndex,
					},
					Manifests: allManifests,
					Timestamp: mostRecentUpdate(allManifests),
				})
			}

			if len(vulnerableManifests) > 0 {
				vulnerableTags = append(vulnerableTags, cvemodel.TagInfo{
					Tag: tag,
					Descriptor: cvemodel.Descriptor{
						Digest:    godigest.Digest(indexDigestStr),
						MediaType: ispec.MediaTypeImageIndex,
					},
					Manifests: vulnerableManifests,
					Timestamp: mostRecentUpdate(vulnerableManifests),
				})
			}
		} else {
			cveinfo.Log.Debug().Str("mediaType", descriptor.MediaType).
				Msg("image media type not supported for scanning")
		}
	}

	var fixedTags []cvemodel.TagInfo

	if len(vulnerableTags) != 0 {
		cveinfo.Log.Info().Str("repository", repo).Str("cve-id", cveID).
			Interface("tags", vulnerableTags).Msg("vulnerable tags")

		fixedTags = GetFixedTags(allTags, vulnerableTags)
		cveinfo.Log.Info().Str("repository", repo).Str("cve-id", cveID).
			Interface("tags", fixedTags).Msg("fixed tags")
	} else {
		cveinfo.Log.Info().Str("repository", repo).Str("cve-id", cveID).
			Msg("image does not contain any tag that have given cve")

		fixedTags = allTags
	}

	return fixedTags, nil
}

func mostRecentUpdate(allManifests []cvemodel.DescriptorInfo) time.Time {
	if len(allManifests) == 0 {
		return time.Time{}
	}

	timeStamp := allManifests[0].Timestamp

	for i := range allManifests {
		if timeStamp.Before(allManifests[i].Timestamp) {
			timeStamp = allManifests[i].Timestamp
		}
	}

	return timeStamp
}

func getTagInfoForManifest(tag, manifestDigestStr string, metaDB mTypes.MetaDB) (cvemodel.TagInfo, error) {
	configContent, manifestDigest, err := getConfigAndDigest(metaDB, manifestDigestStr)
	if err != nil {
		return cvemodel.TagInfo{}, err
	}

	lastUpdated := zcommon.GetImageLastUpdated(configContent)

	return cvemodel.TagInfo{
		Tag:        tag,
		Descriptor: cvemodel.Descriptor{Digest: manifestDigest, MediaType: ispec.MediaTypeImageManifest},
		Manifests: []cvemodel.DescriptorInfo{
			{
				Descriptor: cvemodel.Descriptor{Digest: manifestDigest, MediaType: ispec.MediaTypeImageManifest},
				Timestamp:  lastUpdated,
			},
		},
		Timestamp: lastUpdated,
	}, nil
}

func (cveinfo *BaseCveInfo) isManifestVulnerable(ctx context.Context, repo, tag, manifestDigestStr, cveID string,
) bool {
	image := zcommon.GetFullImageName(repo, tag)

	isValidImage, err := cveinfo.Scanner.IsImageMediaScannable(repo, manifestDigestStr, ispec.MediaTypeImageManifest)
	if !isValidImage || err != nil {
		cveinfo.Log.Debug().Str("image", image).Str("cve-id", cveID).Err(err).
			Msg("image media type not supported for scanning, adding as a vulnerable image")

		return true
	}

	cveMap, err := cveinfo.Scanner.ScanImage(ctx, zcommon.GetFullImageName(repo, manifestDigestStr))
	if err != nil {
		cveinfo.Log.Debug().Str("image", image).Str("cve-id", cveID).
			Msg("scanning failed, adding as a vulnerable image")

		return true
	}

	hasCVE := false

	for id := range cveMap {
		if id == cveID {
			hasCVE = true

			break
		}
	}

	return hasCVE
}

func getIndexContent(metaDB mTypes.MetaDB, indexDigestStr string) (ispec.Index, error) {
	indexDigest, err := godigest.Parse(indexDigestStr)
	if err != nil {
		return ispec.Index{}, err
	}

	indexData, err := metaDB.GetImageMeta(indexDigest)
	if err != nil {
		return ispec.Index{}, err
	}

	if indexData.Index == nil {
		return ispec.Index{}, zerr.ErrUnexpectedMediaType
	}

	return *indexData.Index, nil
}

func getConfigAndDigest(metaDB mTypes.MetaDB, manifestDigestStr string) (ispec.Image, godigest.Digest, error) {
	manifestDigest, err := godigest.Parse(manifestDigestStr)
	if err != nil {
		return ispec.Image{}, "", err
	}

	manifestData, err := metaDB.GetImageMeta(manifestDigest)
	if err != nil {
		return ispec.Image{}, "", err
	}

	// we'll fail the execution if the config is not compatible with ispec.Image because we can't scan this type of images.
	configMediaType := manifestData.Manifests[0].Manifest.Config.MediaType
	if configMediaType != ispec.MediaTypeImageConfig && !compat.IsCompatibleConfigMediaType(configMediaType) {
		return ispec.Image{}, "", zerr.ErrUnexpectedMediaType
	}

	return manifestData.Manifests[0].Config, manifestDigest, err
}

func filterCVEMap(cveMap map[string]cvemodel.CVE, searchedCVE, excludedCVE, severity string,
	pageFinder *CvePageFinder,
) {
	searchedCVE = strings.ToUpper(searchedCVE)

	for _, cve := range cveMap {
		if severity != "" && (cvemodel.CompareSeverities(cve.Severity, severity) != 0) {
			continue
		}

		if excludedCVE != "" && cve.ContainsStr(excludedCVE) {
			continue
		}

		if cve.ContainsStr(searchedCVE) {
			pageFinder.Add(cve)
		}
	}
}

func filterCVEList(cveList []cvemodel.CVE, searchedCVE, excludedCVE, severity string, pageFinder *CvePageFinder) {
	searchedCVE = strings.ToUpper(searchedCVE)

	for _, cve := range cveList {
		if severity != "" && (cvemodel.CompareSeverities(cve.Severity, severity) != 0) {
			continue
		}

		if excludedCVE != "" && cve.ContainsStr(excludedCVE) {
			continue
		}

		if cve.ContainsStr(searchedCVE) {
			pageFinder.Add(cve)
		}
	}
}

func (cveinfo BaseCveInfo) GetCVEListForImage(ctx context.Context, repo, ref string, searchedCVE string,
	excludedCVE, severity string, pageInput cvemodel.PageInput,
) (
	[]cvemodel.CVE, cvemodel.ImageCVESummary, zcommon.PageInfo, error,
) {
	imageCVESummary := cvemodel.ImageCVESummary{
		MaxSeverity: cvemodel.SeverityNotScanned,
	}

	isValidImage, err := cveinfo.Scanner.IsImageFormatScannable(repo, ref)
	if !isValidImage {
		cveinfo.Log.Debug().Str("image", repo+":"+ref).Err(err).Msg("image is not scanable")

		return []cvemodel.CVE{}, imageCVESummary, zcommon.PageInfo{}, err
	}

	image := zcommon.GetFullImageName(repo, ref)

	cveMap, err := cveinfo.Scanner.ScanImage(ctx, image)
	if err != nil {
		return []cvemodel.CVE{}, imageCVESummary, zcommon.PageInfo{}, err
	}

	imageCVESummary = initCVESummaryFromCVEMap(cveMap)

	pageFinder, err := NewCvePageFinder(pageInput.Limit, pageInput.Offset, pageInput.SortBy)
	if err != nil {
		return []cvemodel.CVE{}, imageCVESummary, zcommon.PageInfo{}, err
	}

	filterCVEMap(cveMap, searchedCVE, excludedCVE, severity, pageFinder)

	cveList, pageInfo := pageFinder.Page()

	return cveList, imageCVESummary, pageInfo, nil
}

func (cveinfo BaseCveInfo) GetCVEDiffListForImages(ctx context.Context, minuend, subtrahend, searchedCVE string,
	excludedCVE string, pageInput cvemodel.PageInput,
) ([]cvemodel.CVE, cvemodel.ImageCVESummary, zcommon.PageInfo, error) {
	minuendRepo, minuendRef, _ := zcommon.GetImageDirAndReference(minuend)
	subtrahendRepo, subtrahendRef, _ := zcommon.GetImageDirAndReference(subtrahend)

	// get the CVEs of image and comparedImage
	minuendCVEList, _, _, err := cveinfo.GetCVEListForImage(ctx, minuendRepo, minuendRef, searchedCVE, excludedCVE,
		"", cvemodel.PageInput{})
	if err != nil {
		return nil, cvemodel.ImageCVESummary{}, zcommon.PageInfo{}, err
	}

	subtrahendCVEList, _, _, err := cveinfo.GetCVEListForImage(ctx, subtrahendRepo, subtrahendRef,
		searchedCVE, excludedCVE, "", cvemodel.PageInput{})
	if err != nil {
		return nil, cvemodel.ImageCVESummary{}, zcommon.PageInfo{}, err
	}

	subtrahendCVEMap := map[string]cvemodel.CVE{}

	for _, cve := range subtrahendCVEList {
		cve := cve
		subtrahendCVEMap[cve.ID] = cve
	}

	var (
		count         int
		unknownCount  int
		lowCount      int
		mediumCount   int
		highCount     int
		criticalCount int
		maxSeverity   string

		diffCVEs = []cvemodel.CVE{}
	)

	for i := range minuendCVEList {
		if _, ok := subtrahendCVEMap[minuendCVEList[i].ID]; !ok {
			diffCVEs = append(diffCVEs, minuendCVEList[i])

			switch minuendCVEList[i].Severity {
			case cvemodel.SeverityUnknown:
				unknownCount++
			case cvemodel.SeverityLow:
				lowCount++
			case cvemodel.SeverityMedium:
				mediumCount++
			case cvemodel.SeverityHigh:
				highCount++
			case cvemodel.SeverityCritical:
				criticalCount++
			}

			if cvemodel.CompareSeverities(maxSeverity, minuendCVEList[i].Severity) > 0 {
				maxSeverity = minuendCVEList[i].Severity
			}
		}
	}

	pageFinder, err := NewCvePageFinder(pageInput.Limit, pageInput.Offset, pageInput.SortBy)
	if err != nil {
		return nil, cvemodel.ImageCVESummary{}, zcommon.PageInfo{}, err
	}

	filterCVEList(diffCVEs, "", "", "", pageFinder)

	cveList, pageInfo := pageFinder.Page()

	count = unknownCount + lowCount + mediumCount + highCount + criticalCount

	diffCVESummary := cvemodel.ImageCVESummary{
		Count:         count,
		UnknownCount:  unknownCount,
		LowCount:      lowCount,
		MediumCount:   mediumCount,
		HighCount:     highCount,
		CriticalCount: criticalCount,
		MaxSeverity:   maxSeverity,
	}

	return cveList, diffCVESummary, pageInfo, nil
}

func (cveinfo BaseCveInfo) GetCVESummaryForImageMedia(ctx context.Context, repo, digestStr, mediaType string,
) (cvemodel.ImageCVESummary, error) {
	// There are several cases, expected returned values below:
	// not scanned yet                     - max severity ""            - cve count 0   - no Errors
	// not scannable                       - max severity ""            - cve count 0   - has Errors
	// scannable no issues found           - max severity "NONE"        - cve count 0   - no Errors
	// scannable issues found              - max severity from Scanner  - cve count >0  - no Errors
	// For this call we only look at the scanner cache, we skip the actual scanning to save time
	if !cveinfo.Scanner.IsResultCached(digestStr) {
		isValidImage, err := cveinfo.Scanner.IsImageMediaScannable(repo, digestStr, mediaType)
		if !isValidImage {
			cveinfo.Log.Debug().Str("digest", digestStr).Str("mediaType", mediaType).
				Err(err).Msg("image is not scannable")
		}

		// Counters are initialized with 0 by default
		imageCVESummary := cvemodel.ImageCVESummary{
			MaxSeverity: cvemodel.SeverityNotScanned,
		}

		return imageCVESummary, err
	}

	// We will make due with cached results
	cveMap := cveinfo.Scanner.GetCachedResult(digestStr)

	return initCVESummaryFromCVEMap(cveMap), nil
}

func GetFixedTags(allTags, vulnerableTags []cvemodel.TagInfo) []cvemodel.TagInfo {
	sort.Slice(allTags, func(i, j int) bool {
		return allTags[i].Timestamp.Before(allTags[j].Timestamp)
	})

	earliestVulnerable := vulnerableTags[0]
	vulnerableTagMap := make(map[string]cvemodel.TagInfo, len(vulnerableTags))

	for _, tag := range vulnerableTags {
		vulnerableTagMap[tag.Tag] = tag

		//nolint:gocritic // cannot convert to switch-case
		if tag.Descriptor.MediaType == ispec.MediaTypeImageManifest ||
			compat.IsCompatibleManifestMediaType(tag.Descriptor.MediaType) {
			if tag.Timestamp.Before(earliestVulnerable.Timestamp) {
				earliestVulnerable = tag
			}
		} else if tag.Descriptor.MediaType == ispec.MediaTypeImageIndex ||
			compat.IsCompatibleManifestListMediaType(tag.Descriptor.MediaType) {
			for _, manifestDesc := range tag.Manifests {
				if manifestDesc.Timestamp.Before(earliestVulnerable.Timestamp) {
					earliestVulnerable = tag
				}
			}
		} else {
			continue
		}
	}

	var fixedTags []cvemodel.TagInfo

	// There are some downsides to this logic
	// We assume there can't be multiple "branches" of the same
	// image built at different times containing different fixes
	// There may be older images which have a fix or
	// newer images which don't
	for _, tag := range allTags {
		//nolint:gocritic // cannot convert to switch-case
		if tag.Descriptor.MediaType == ispec.MediaTypeImageManifest ||
			compat.IsCompatibleManifestMediaType(tag.Descriptor.MediaType) {
			if tag.Timestamp.Before(earliestVulnerable.Timestamp) {
				// The vulnerability did not exist at the time this
				// image was built
				continue
			}
			// If the image is old enough for the vulnerability to
			// exist, but it was not detected, it means it contains
			// the fix
			if _, ok := vulnerableTagMap[tag.Tag]; !ok {
				fixedTags = append(fixedTags, tag)
			}
		} else if tag.Descriptor.MediaType == ispec.MediaTypeImageIndex ||
			compat.IsCompatibleManifestListMediaType(tag.Descriptor.MediaType) {
			fixedManifests := []cvemodel.DescriptorInfo{}

			// If the latest update inside the index is before the earliest vulnerability found then
			// the index can't contain a fix
			if tag.Timestamp.Before(earliestVulnerable.Timestamp) {
				continue
			}

			vulnTagInfo, indexHasVulnerableManifest := vulnerableTagMap[tag.Tag]

			for _, manifestDesc := range tag.Manifests {
				if manifestDesc.Timestamp.Before(earliestVulnerable.Timestamp) {
					// The vulnerability did not exist at the time this image was built
					continue
				}

				// check if the current manifest doesn't have the vulnerability
				if !indexHasVulnerableManifest || !containsDescriptorInfo(vulnTagInfo.Manifests, manifestDesc) {
					fixedManifests = append(fixedManifests, manifestDesc)
				}
			}

			if len(fixedManifests) > 0 {
				fixedTag := tag
				fixedTag.Manifests = fixedManifests

				fixedTags = append(fixedTags, fixedTag)
			}
		} else {
			continue
		}
	}

	return fixedTags
}

func containsDescriptorInfo(slice []cvemodel.DescriptorInfo, descriptorInfo cvemodel.DescriptorInfo) bool {
	for _, di := range slice {
		if di.Digest == descriptorInfo.Digest {
			return true
		}
	}

	return false
}

func initCVESummaryFromCVEMap(cveMap map[string]cvemodel.CVE) cvemodel.ImageCVESummary {
	// Counters are initialized with 0 by default
	imageCVESummary := cvemodel.ImageCVESummary{
		MaxSeverity: cvemodel.SeverityNotScanned,
	}

	imageCVESummary.Count = len(cveMap)
	if imageCVESummary.Count == 0 {
		imageCVESummary.MaxSeverity = cvemodel.SeverityNone

		return imageCVESummary
	}

	imageCVESummary.MaxSeverity = cvemodel.SeverityUnknown

	for _, cve := range cveMap {
		switch cve.Severity {
		case cvemodel.SeverityUnknown:
			imageCVESummary.UnknownCount += 1
		case cvemodel.SeverityLow:
			imageCVESummary.LowCount += 1
		case cvemodel.SeverityMedium:
			imageCVESummary.MediumCount += 1
		case cvemodel.SeverityHigh:
			imageCVESummary.HighCount += 1
		case cvemodel.SeverityCritical:
			imageCVESummary.CriticalCount += 1
		}

		if cvemodel.CompareSeverities(imageCVESummary.MaxSeverity, cve.Severity) > 0 {
			imageCVESummary.MaxSeverity = cve.Severity
		}
	}

	return imageCVESummary
}
