package grype

import (
	"errors"
	"fmt"
	"runtime/debug"
	"slices"
	"strings"

	"github.com/wagoodman/go-partybus"
	"github.com/wagoodman/go-progress"

	"github.com/anchore/grype/grype/event"
	"github.com/anchore/grype/grype/event/monitor"
	"github.com/anchore/grype/grype/grypeerr"
	"github.com/anchore/grype/grype/match"
	"github.com/anchore/grype/grype/matcher/stock"
	"github.com/anchore/grype/grype/pkg"
	"github.com/anchore/grype/grype/vex"
	"github.com/anchore/grype/grype/vulnerability"
	"github.com/anchore/grype/internal/bus"
	"github.com/anchore/grype/internal/log"
	syftPkg "github.com/anchore/syft/syft/pkg"
)

const (
	branch = "├──"
	leaf   = "└──"
)

type VulnerabilityMatcher struct {
	VulnerabilityProvider vulnerability.Provider
	ExclusionProvider     match.ExclusionProvider
	Matchers              []match.Matcher
	IgnoreRules           []match.IgnoreRule
	FailSeverity          *vulnerability.Severity
	NormalizeByCVE        bool
	VexProcessor          *vex.Processor
}

func (m *VulnerabilityMatcher) FailAtOrAboveSeverity(severity *vulnerability.Severity) *VulnerabilityMatcher {
	m.FailSeverity = severity
	return m
}

func (m *VulnerabilityMatcher) WithMatchers(matchers []match.Matcher) *VulnerabilityMatcher {
	m.Matchers = matchers
	return m
}

func (m *VulnerabilityMatcher) WithIgnoreRules(ignoreRules []match.IgnoreRule) *VulnerabilityMatcher {
	m.IgnoreRules = ignoreRules
	return m
}

func (m *VulnerabilityMatcher) FindMatches(pkgs []pkg.Package, context pkg.Context) (remainingMatches *match.Matches, ignoredMatches []match.IgnoredMatch, err error) {
	progressMonitor := trackMatcher(len(pkgs))

	defer func() {
		progressMonitor.Ignored.Set(int64(len(ignoredMatches)))
		progressMonitor.SetCompleted()
		if err != nil {
			progressMonitor.MatchesDiscovered.SetError(err)
		}
	}()

	remainingMatches, ignoredMatches, err = m.findDBMatches(pkgs, progressMonitor)
	if err != nil {
		err = fmt.Errorf("unable to find matches against vulnerability database: %w", err)
		return remainingMatches, ignoredMatches, err
	}

	remainingMatches, ignoredMatches, err = m.findVEXMatches(context, remainingMatches, ignoredMatches, progressMonitor)
	if err != nil {
		err = fmt.Errorf("unable to find matches against VEX sources: %w", err)
		return remainingMatches, ignoredMatches, err
	}

	if m.FailSeverity != nil && hasSeverityAtOrAbove(m.VulnerabilityProvider, *m.FailSeverity, *remainingMatches) {
		err = grypeerr.ErrAboveSeverityThreshold
		return remainingMatches, ignoredMatches, err
	}

	logListSummary(progressMonitor)

	logIgnoredMatches(ignoredMatches)

	return remainingMatches, ignoredMatches, nil
}

func (m *VulnerabilityMatcher) findDBMatches(pkgs []pkg.Package, progressMonitor *monitorWriter) (*match.Matches, []match.IgnoredMatch, error) {
	var ignoredMatches []match.IgnoredMatch

	log.Trace("finding matches against DB")
	matches, err := m.searchDBForMatches(pkgs, progressMonitor)
	if err != nil {
		if match.IsFatalError(err) {
			return nil, nil, err
		}

		// other errors returned from matchers during searchDBForMatches were being
		// logged and not returned, so just log them here
		log.WithFields("error", err).Debug("error(s) returned from searchDBForMatches")
	}

	matches, ignoredMatches = m.applyIgnoreRules(matches)

	if m.NormalizeByCVE {
		normalizedMatches := match.NewMatches()
		for originalMatch := range matches.Enumerate() {
			normalizedMatches.Add(m.normalizeByCVE(originalMatch))
		}

		// we apply the ignore rules again in case any of the transformations done during normalization
		// regresses the results (relative to the already applied ignore rules). Why do we additionally apply
		// the ignore rules before normalizing? In case the user has a rule that ignores a non-normalized
		// vulnerability ID, we wantMatches to ensure that the rule is honored.
		originalIgnoredMatches := ignoredMatches
		matches, ignoredMatches = m.applyIgnoreRules(normalizedMatches)
		ignoredMatches = m.mergeIgnoredMatches(originalIgnoredMatches, ignoredMatches)
	}

	return &matches, ignoredMatches, nil
}

func (m *VulnerabilityMatcher) mergeIgnoredMatches(allIgnoredMatches ...[]match.IgnoredMatch) []match.IgnoredMatch {
	var out []match.IgnoredMatch
	for _, ignoredMatches := range allIgnoredMatches {
		for _, ignored := range ignoredMatches {
			if m.NormalizeByCVE {
				ignored.Match = m.normalizeByCVE(ignored.Match)
			}
			out = append(out, ignored)
		}
	}
	return out
}

//nolint:funlen
func (m *VulnerabilityMatcher) searchDBForMatches(
	packages []pkg.Package,
	progressMonitor *monitorWriter,
) (match.Matches, error) {
	var allMatches []match.Match
	var allIgnorers []match.IgnoreFilter
	matcherIndex, defaultMatcher := newMatcherIndex(m.Matchers)

	if defaultMatcher == nil {
		defaultMatcher = stock.NewStockMatcher(stock.MatcherConfig{UseCPEs: true})
	}

	var matcherErrs []error
	for _, p := range packages {
		progressMonitor.PackagesProcessed.Increment()
		log.WithFields("package", displayPackage(p)).Trace("searching for vulnerability matches")

		matchAgainst, ok := matcherIndex[p.Type]
		if !ok {
			matchAgainst = []match.Matcher{defaultMatcher}
		}
		for _, theMatcher := range matchAgainst {
			matches, ignorers, err := callMatcherSafely(theMatcher, m.VulnerabilityProvider, p)
			if err != nil {
				if match.IsFatalError(err) {
					return match.Matches{}, err
				}

				log.WithFields("error", err, "package", displayPackage(p)).Warn("matcher returned error")
				matcherErrs = append(matcherErrs, err)
			}

			allIgnorers = append(allIgnorers, ignorers...)

			// Filter out matches based on records in the database exclusion table and hard-coded rules
			filtered, dropped := match.ApplyExplicitIgnoreRules(m.ExclusionProvider, match.NewMatches(matches...))

			additionalMatches := filtered.Sorted()
			logPackageMatches(p, additionalMatches)
			logExplicitDroppedPackageMatches(p, dropped)
			allMatches = append(allMatches, additionalMatches...)

			progressMonitor.MatchesDiscovered.Add(int64(len(additionalMatches)))

			// note: there is a difference between "ignore" and "dropped" matches.
			// ignored: matches that are filtered out due to user-provided ignore rules
			// dropped: matches that are filtered out due to hard-coded rules
			updateVulnerabilityList(progressMonitor, additionalMatches, nil, dropped, m.VulnerabilityProvider)
		}
	}

	// apply ignores based on matchers returning ignore rules
	filtered, dropped := match.ApplyIgnoreFilters(allMatches, ignoredMatchFilter(allIgnorers))
	logIgnoredMatches(dropped)

	// get deduplicated set of matches
	res := match.NewMatches(filtered...)

	// update the total discovered matches after removing all duplicates and ignores
	progressMonitor.MatchesDiscovered.Set(int64(res.Count()))

	return res, errors.Join(matcherErrs...)
}

func callMatcherSafely(m match.Matcher, vp vulnerability.Provider, p pkg.Package) (matches []match.Match, ignoredMatches []match.IgnoreFilter, err error) {
	// handle individual matcher panics
	defer func() {
		if e := recover(); e != nil {
			err = match.NewFatalError(m.Type(), fmt.Errorf("%v at:\n%s", e, string(debug.Stack())))
		}
	}()
	return m.Match(vp, p)
}

func (m *VulnerabilityMatcher) findVEXMatches(context pkg.Context, remainingMatches *match.Matches, ignoredMatches []match.IgnoredMatch, progressMonitor *monitorWriter) (*match.Matches, []match.IgnoredMatch, error) {
	if m.VexProcessor == nil {
		log.Trace("no VEX documents provided, skipping VEX matching")
		return remainingMatches, ignoredMatches, nil
	}

	log.Trace("finding matches against available VEX documents")
	matchesAfterVex, ignoredMatchesAfterVex, err := m.VexProcessor.ApplyVEX(&context, remainingMatches, ignoredMatches)
	if err != nil {
		return nil, nil, fmt.Errorf("unable to find matches against VEX documents: %w", err)
	}

	diffMatches := matchesAfterVex.Diff(*remainingMatches)
	// note: this assumes that the diff can only be additive
	diffIgnoredMatches := ignoredMatchesDiff(ignoredMatchesAfterVex, ignoredMatches)

	updateVulnerabilityList(progressMonitor, diffMatches.Sorted(), diffIgnoredMatches, nil, m.VulnerabilityProvider)

	return matchesAfterVex, ignoredMatchesAfterVex, nil
}

// applyIgnoreRules applies the user-provided ignore rules, splitting ignored matches into a separate set
func (m *VulnerabilityMatcher) applyIgnoreRules(matches match.Matches) (match.Matches, []match.IgnoredMatch) {
	var ignoredMatches []match.IgnoredMatch
	if len(m.IgnoreRules) == 0 {
		return matches, ignoredMatches
	}

	matches, ignoredMatches = match.ApplyIgnoreRules(matches, m.IgnoreRules)

	if count := len(ignoredMatches); count > 0 {
		log.Infof("ignoring %d matches due to user-provided ignore rules", count)
	}
	return matches, ignoredMatches
}

func (m *VulnerabilityMatcher) normalizeByCVE(match match.Match) match.Match {
	if isCVE(match.Vulnerability.ID) {
		return match
	}

	var effectiveCVERecordRefs []vulnerability.Reference
	for _, ref := range match.Vulnerability.RelatedVulnerabilities {
		if isCVE(ref.ID) {
			effectiveCVERecordRefs = append(effectiveCVERecordRefs, ref)
			break
		}
	}

	switch len(effectiveCVERecordRefs) {
	case 0:
		log.WithFields(
			"vuln", match.Vulnerability.ID,
			"package", displayPackage(match.Package),
		).Trace("unable to find CVE record for vulnerability, skipping normalization")
		return match
	case 1:
		break
	default:
		log.WithFields(
			"refs", fmt.Sprintf("%+v", effectiveCVERecordRefs),
			"vuln", match.Vulnerability.ID,
			"package", displayPackage(match.Package),
		).Trace("found multiple CVE records for vulnerability, skipping normalization")
		return match
	}

	ref := effectiveCVERecordRefs[0]

	upstreamMetadata, err := m.VulnerabilityProvider.VulnerabilityMetadata(ref) //nolint:staticcheck // deprecated API still used internally
	if err != nil {
		log.WithFields("id", ref.ID, "namespace", ref.Namespace, "error", err).Warn("unable to fetch effective CVE metadata")
		return match
	}

	if upstreamMetadata == nil {
		return match
	}

	originalRef := vulnerability.Reference{
		ID:        match.Vulnerability.ID,
		Namespace: match.Vulnerability.Namespace,
	}

	match.Vulnerability.ID = upstreamMetadata.ID
	match.Vulnerability.Namespace = upstreamMetadata.Namespace
	match.Vulnerability.RelatedVulnerabilities = []vulnerability.Reference{originalRef}

	return match
}

// ignoreRulesByIndex implements match.IgnoreFilter to filter each matching
// package that overlaps by location and have the same vulnerability ID (CVE)
type ignoreRulesByIndex struct {
	remainingFilters       []match.IgnoreFilter
	locationIgnoreRules    map[string][]match.IgnoreRule
	packageNameIgnoreRules map[string][]match.IgnoreRule
}

func (i ignoreRulesByIndex) IgnoreMatch(m match.Match) []match.IgnoreRule {
	if nameRules := i.packageNameIgnoreRules[m.Package.Name]; nameRules != nil {
		for _, rule := range nameRules {
			if matched := rule.IgnoreMatch(m); matched != nil {
				return matched
			}
		}
	}
	for _, l := range m.Package.Locations.ToSlice() {
		for _, rule := range i.locationIgnoreRules[l.RealPath] {
			if matched := rule.IgnoreMatch(m); matched != nil {
				return matched
			}
		}
	}
	for _, f := range i.remainingFilters {
		if matched := f.IgnoreMatch(m); matched != nil {
			return matched
		}
	}
	return nil
}

// ignoredMatchFilter creates an ignore filter based on location-based IgnoredMatches to filter out "the same"
// vulnerabilities reported by other matchers based on overlapping file locations
func ignoredMatchFilter(ignores []match.IgnoreFilter) match.IgnoreFilter {
	out := ignoreRulesByIndex{
		locationIgnoreRules:    map[string][]match.IgnoreRule{},
		packageNameIgnoreRules: map[string][]match.IgnoreRule{},
	}
	// the returned slice of remaining rules are not location-based rules
	out.remainingFilters = slices.DeleteFunc(ignores, func(ignore match.IgnoreFilter) bool {
		if rule, ok := ignore.(match.IgnoreRule); ok {
			// return true to remove rules handled with index lookups from the remaining filter list
			if rule.Package.Location != "" && !strings.ContainsRune(rule.Package.Location, '*') {
				out.locationIgnoreRules[rule.Package.Location] = append(out.locationIgnoreRules[rule.Package.Location], rule)
				return true
			}
			if rule.Package.Name != "" {
				// this rule is handled with location lookups, remove it from the remaining filter list
				out.packageNameIgnoreRules[rule.Package.Name] = append(out.packageNameIgnoreRules[rule.Package.Name], rule)
				return true
			}
		}
		return false
	})
	return out
}

func displayPackage(p pkg.Package) string {
	if p.PURL != "" {
		return p.PURL
	}
	ty := p.Type
	if p.Type == "" {
		ty = "unknown"
	}

	return fmt.Sprintf("%s@%s (type=%s)", p.Name, p.Version, ty)
}

func ignoredMatchesDiff(subject []match.IgnoredMatch, other []match.IgnoredMatch) []match.IgnoredMatch {
	// TODO(alex): the downside with this implementation is that it does not account for the same ignored match being
	// ignored for different reasons (the appliedIgnoreRules field).

	otherMap := make(map[match.Fingerprint]struct{})
	for _, a := range other {
		otherMap[a.Fingerprint()] = struct{}{}
	}

	var diff []match.IgnoredMatch
	for _, b := range subject {
		if _, ok := otherMap[b.Fingerprint()]; !ok {
			diff = append(diff, b)
		}
	}

	return diff
}

func newMatcherIndex(matchers []match.Matcher) (map[syftPkg.Type][]match.Matcher, match.Matcher) {
	matcherIndex := make(map[syftPkg.Type][]match.Matcher)
	var defaultMatcher match.Matcher
	for _, m := range matchers {
		if m.Type() == match.StockMatcher {
			defaultMatcher = m
			continue
		}
		for _, t := range m.PackageTypes() {
			if _, ok := matcherIndex[t]; !ok {
				matcherIndex[t] = make([]match.Matcher, 0)
			}

			matcherIndex[t] = append(matcherIndex[t], m)
			log.Tracef("adding matcher: %+v", t)
		}
	}

	return matcherIndex, defaultMatcher
}

func isCVE(id string) bool {
	return strings.HasPrefix(strings.ToLower(id), "cve-")
}

//nolint:staticcheck // MetadataProvider is deprecated but still used internally
func hasSeverityAtOrAbove(store vulnerability.MetadataProvider, severity vulnerability.Severity, matches match.Matches) bool {
	if severity == vulnerability.UnknownSeverity {
		return false
	}
	for m := range matches.Enumerate() {
		metadata, err := store.VulnerabilityMetadata(m.Vulnerability.Reference) //nolint:staticcheck // deprecated API still used internally
		if err != nil {
			continue
		}

		if vulnerability.ParseSeverity(metadata.Severity) >= severity {
			return true
		}
	}
	return false
}

func logListSummary(vl *monitorWriter) {
	log.Infof("found %d vulnerability matches across %d packages", vl.MatchesDiscovered.Current(), vl.PackagesProcessed.Current())
	log.Debugf("  ├── fixed: %d", vl.Fixed.Current())
	log.Debugf("  ├── ignored: %d (due to user-provided rule)", vl.Ignored.Current())
	log.Debugf("  ├── dropped: %d (due to hard-coded correction)", vl.Dropped.Current())
	log.Debugf("  └── matched: %d", vl.MatchesDiscovered.Current())

	var unknownCount int64
	if count, ok := vl.BySeverity[vulnerability.UnknownSeverity]; ok {
		unknownCount = count.Current()
	}
	log.Debugf("      ├── %s: %d", vulnerability.UnknownSeverity.String(), unknownCount)

	allSeverities := vulnerability.AllSeverities()
	for idx, sev := range allSeverities {
		arm := selectArm(idx, len(allSeverities))
		log.Debugf("      %s %s: %d", arm, sev.String(), vl.BySeverity[sev].Current())
	}
}

//nolint:staticcheck // MetadataProvider is deprecated but still used internally
func updateVulnerabilityList(mon *monitorWriter, matches []match.Match, ignores []match.IgnoredMatch, dropped []match.IgnoredMatch, metadataProvider vulnerability.MetadataProvider) {
	for _, m := range matches {
		metadata, err := metadataProvider.VulnerabilityMetadata(m.Vulnerability.Reference) //nolint:staticcheck // deprecated API still used internally
		if err != nil || metadata == nil {
			mon.BySeverity[vulnerability.UnknownSeverity].Increment()
			continue
		}

		sevManualProgress, ok := mon.BySeverity[vulnerability.ParseSeverity(metadata.Severity)]
		if !ok {
			mon.BySeverity[vulnerability.UnknownSeverity].Increment()
			continue
		}
		sevManualProgress.Increment()

		if m.Vulnerability.Fix.State == vulnerability.FixStateFixed {
			mon.Fixed.Increment()
		}
	}

	mon.Ignored.Add(int64(len(ignores)))
	mon.Dropped.Add(int64(len(dropped)))
}

func logPackageMatches(p pkg.Package, matches []match.Match) {
	if len(matches) == 0 {
		return
	}

	log.WithFields("package", displayPackage(p)).Debugf("found %d vulnerabilities", len(matches))
	for idx, m := range matches {
		arm := selectArm(idx, len(matches))
		log.WithFields("vuln", m.Vulnerability.ID, "namespace", m.Vulnerability.Namespace).Tracef("  %s", arm)
	}
}

func selectArm(idx, total int) string {
	if idx == total-1 {
		return leaf
	}
	return branch
}

func logExplicitDroppedPackageMatches(p pkg.Package, ignored []match.IgnoredMatch) {
	if len(ignored) == 0 {
		return
	}

	log.WithFields("package", displayPackage(p)).Debugf("dropped %d vulnerability matches due to hard-coded correction", len(ignored))
	for idx, i := range ignored {
		arm := selectArm(idx, len(ignored))

		log.WithFields("vuln", i.Match.Vulnerability.ID, "rules", len(i.AppliedIgnoreRules)).Tracef("  %s", arm)
	}
}

func logIgnoredMatches(ignored []match.IgnoredMatch) {
	if len(ignored) == 0 {
		return
	}

	log.Infof("ignored %d vulnerability matches", len(ignored))
	for idx, i := range ignored {
		arm := selectArm(idx, len(ignored))
		rule := ""
		if len(i.AppliedIgnoreRules) > 0 {
			rule = i.AppliedIgnoreRules[0].Reason
			if rule == "" {
				rule = i.AppliedIgnoreRules[0].Vulnerability
			}
		}
		vulnerability.LogDropped(i.Vulnerability.ID, "ignoreRules", rule, i)
		log.WithFields("vuln", i.Match.Vulnerability.ID, "rules", len(i.AppliedIgnoreRules), "package", displayPackage(i.Package)).Debugf("  %s", arm)
	}
}

type monitorWriter struct {
	PackagesProcessed *progress.Manual
	MatchesDiscovered *progress.Manual
	Fixed             *progress.Manual
	Ignored           *progress.Manual
	Dropped           *progress.Manual
	BySeverity        map[vulnerability.Severity]*progress.Manual
}

func newMonitor(pkgCount int) (monitorWriter, monitor.Matching) {
	manualBySev := make(map[vulnerability.Severity]*progress.Manual)
	for _, severity := range vulnerability.AllSeverities() {
		manualBySev[severity] = progress.NewManual(-1)
	}
	manualBySev[vulnerability.UnknownSeverity] = progress.NewManual(-1)

	m := monitorWriter{
		PackagesProcessed: progress.NewManual(int64(pkgCount)),
		MatchesDiscovered: progress.NewManual(-1),
		Fixed:             progress.NewManual(-1),
		Ignored:           progress.NewManual(-1),
		Dropped:           progress.NewManual(-1),
		BySeverity:        manualBySev,
	}

	monitorableBySev := make(map[vulnerability.Severity]progress.Monitorable)
	for sev, manual := range manualBySev {
		monitorableBySev[sev] = manual
	}

	return m, monitor.Matching{
		PackagesProcessed: m.PackagesProcessed,
		MatchesDiscovered: m.MatchesDiscovered,
		Fixed:             m.Fixed,
		Ignored:           m.Ignored,
		Dropped:           m.Dropped,
		BySeverity:        monitorableBySev,
	}
}

func (m *monitorWriter) SetCompleted() {
	m.PackagesProcessed.SetCompleted()
	m.MatchesDiscovered.SetCompleted()
	m.Fixed.SetCompleted()
	m.Ignored.SetCompleted()
	m.Dropped.SetCompleted()
	for _, v := range m.BySeverity {
		v.SetCompleted()
	}
}

func trackMatcher(pkgCount int) *monitorWriter {
	writer, reader := newMonitor(pkgCount)

	bus.Publish(partybus.Event{
		Type:  event.VulnerabilityScanningStarted,
		Value: reader,
	})

	return &writer
}
