package grype

import (
	"errors"
	"testing"
	"time"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
	"github.com/google/uuid"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/wagoodman/go-partybus"

	"github.com/anchore/grype/grype/distro"
	"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"
	matcherMock "github.com/anchore/grype/grype/matcher/mock"
	"github.com/anchore/grype/grype/matcher/ruby"
	"github.com/anchore/grype/grype/pkg"
	"github.com/anchore/grype/grype/pkg/qualifier"
	"github.com/anchore/grype/grype/version"
	"github.com/anchore/grype/grype/vex"
	"github.com/anchore/grype/grype/vulnerability"
	"github.com/anchore/grype/grype/vulnerability/mock"
	"github.com/anchore/grype/internal/bus"
	"github.com/anchore/syft/syft/artifact"
	"github.com/anchore/syft/syft/cpe"
	"github.com/anchore/syft/syft/file"
	syftPkg "github.com/anchore/syft/syft/pkg"
	"github.com/anchore/syft/syft/source"
)

func testVulnerabilities() []vulnerability.Vulnerability {
	return []vulnerability.Vulnerability{
		{
			Reference: vulnerability.Reference{
				ID:        "CVE-2014-fake-1",
				Namespace: "debian:distro:debian:8",
				Internal: vulnerability.Metadata{
					Severity: "medium",
				},
			},
			PackageName: "neutron",
			Constraint:  version.MustGetConstraint("< 2014.1.3-6", version.DebFormat),
		},
		{
			Reference: vulnerability.Reference{
				ID:        "CVE-2013-fake-2",
				Namespace: "debian:distro:debian:8",
			},
			PackageName: "neutron",
			Constraint:  version.MustGetConstraint("< 2013.0.2-1", version.DebFormat),
		},
		{
			Reference: vulnerability.Reference{
				ID:        "GHSA-2014-fake-3",
				Namespace: "github:language:ruby",
				Internal: vulnerability.Metadata{
					Severity: "medium",
				},
			},
			PackageName: "activerecord",
			Constraint:  version.MustGetConstraint("< 3.7.6", version.UnknownFormat),
			RelatedVulnerabilities: []vulnerability.Reference{
				{
					ID:        "CVE-2014-fake-3",
					Namespace: "nvd:cpe",
				},
			},
		},
		{
			Reference: vulnerability.Reference{
				ID:        "CVE-2014-fake-3",
				Namespace: "nvd:cpe",
				Internal: vulnerability.Metadata{
					Severity: "critical",
				},
			},
			PackageName: "activerecord",
			Constraint:  version.MustGetConstraint("< 3.7.6", version.UnknownFormat),
			CPEs: []cpe.CPE{
				cpe.Must("cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*", ""),
			},
		},
		{
			Reference: vulnerability.Reference{
				ID:        "CVE-2014-fake-4",
				Namespace: "nvd:cpe",
			},
			PackageName: "activerecord",
			Constraint:  version.MustGetConstraint("< 3.7.4", version.UnknownFormat),
			CPEs: []cpe.CPE{
				cpe.Must("cpe:2.3:*:activerecord:activerecord:*:*:something:*:*:ruby:*:*", ""),
			},
		},
		{
			Reference: vulnerability.Reference{
				ID:        "CVE-2014-fake-5",
				Namespace: "nvd:cpe",
			},
			PackageName: "activerecord",
			Constraint:  version.MustGetConstraint("= 4.0.1", version.UnknownFormat),
			CPEs: []cpe.CPE{
				cpe.Must("cpe:2.3:*:couldntgetthisrightcouldyou:activerecord:4.0.1:*:*:*:*:*:*:*", ""),
			},
		},
		{
			Reference: vulnerability.Reference{
				ID:        "CVE-2014-fake-6",
				Namespace: "nvd:cpe",
			},
			PackageName: "activerecord",
			Constraint:  version.MustGetConstraint("< 98SP3", version.UnknownFormat),
			CPEs: []cpe.CPE{
				cpe.Must("cpe:2.3:*:awesome:awesome:*:*:*:*:*:*:*:*", ""),
			},
		},
	}
}

func Test_HasSeverityAtOrAbove(t *testing.T) {
	thePkg := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "the-package",
		Version: "v0.1",
		Type:    syftPkg.RpmPkg,
	}

	matches := match.NewMatches()
	matches.Add(match.Match{
		Vulnerability: vulnerability.Vulnerability{
			Reference: vulnerability.Reference{
				ID:        "CVE-2014-fake-1",
				Namespace: "debian:distro:debian:8",
			},
		},
		Package: thePkg,
		Details: match.Details{
			{
				Type: match.ExactDirectMatch,
			},
		},
	})

	tests := []struct {
		name           string
		failOnSeverity string
		matches        match.Matches
		expectedResult bool
	}{
		{
			name:           "no-severity-set",
			failOnSeverity: "",
			matches:        matches,
			expectedResult: false,
		},
		{
			name:           "below-threshold",
			failOnSeverity: "high",
			matches:        matches,
			expectedResult: false,
		},
		{
			name:           "at-threshold",
			failOnSeverity: "medium",
			matches:        matches,
			expectedResult: true,
		},
		{
			name:           "above-threshold",
			failOnSeverity: "low",
			matches:        matches,
			expectedResult: true,
		},
	}

	metadataProvider := mock.VulnerabilityProvider(testVulnerabilities()...)

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			var failOnSeverity vulnerability.Severity
			if test.failOnSeverity != "" {
				sev := vulnerability.ParseSeverity(test.failOnSeverity)
				if sev == vulnerability.UnknownSeverity {
					t.Fatalf("could not parse severity")
				}
				failOnSeverity = sev
			}

			actual := hasSeverityAtOrAbove(metadataProvider, failOnSeverity, test.matches)

			if test.expectedResult != actual {
				t.Errorf("expected: %v got : %v", test.expectedResult, actual)
			}
		})
	}
}

func TestVulnerabilityMatcher_FindMatches(t *testing.T) {
	vp := mock.VulnerabilityProvider(testVulnerabilities()...)

	neutron2013Pkg := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "neutron",
		Version: "2013.1.1-1",
		Type:    syftPkg.DebPkg,
		Distro: &distro.Distro{
			Type:    "debian",
			Version: "8",
		},
	}

	mustCPE := func(c string) cpe.CPE {
		cp, err := cpe.New(c, "")
		if err != nil {
			t.Fatal(err)
		}
		return cp
	}

	activerecordPkg := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "activerecord",
		Version: "3.7.5",
		CPEs: []cpe.CPE{
			mustCPE("cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*"),
		},
		Type:     syftPkg.GemPkg,
		Language: syftPkg.Ruby,
	}

	openvexProcessor, _ := vex.NewProcessor(vex.ProcessorOptions{
		Documents: []string{
			"vex/testdata/vex-docs/openvex-debian.json",
		},
		IgnoreRules: []match.IgnoreRule{
			{
				VexStatus: "fixed",
			},
		},
	})

	type fields struct {
		Matchers       []match.Matcher
		IgnoreRules    []match.IgnoreRule
		FailSeverity   *vulnerability.Severity
		NormalizeByCVE bool
		VexProcessor   *vex.Processor
	}
	type args struct {
		pkgs    []pkg.Package
		context pkg.Context
	}

	tests := []struct {
		name               string
		fields             fields
		args               args
		wantMatches        match.Matches
		wantIgnoredMatches []match.IgnoredMatch
		wantErr            error
	}{
		{
			name: "no matches",
			fields: fields{
				Matchers: matcher.NewDefaultMatchers(matcher.Config{}),
			},
			args: args{
				pkgs: []pkg.Package{
					{
						ID:      pkg.ID(uuid.NewString()),
						Name:    "neutrino",
						Version: "2099.1.1-1",
						Type:    syftPkg.DebPkg,
					},
				},
				context: pkg.Context{},
			},
		},
		{
			name: "matches by exact-direct match (OS)",
			fields: fields{
				Matchers: matcher.NewDefaultMatchers(matcher.Config{}),
			},
			args: args{
				pkgs: []pkg.Package{
					neutron2013Pkg,
				},
				context: pkg.Context{},
			},
			wantMatches: match.NewMatches(
				match.Match{
					Vulnerability: vulnerability.Vulnerability{
						PackageName: "neutron",
						Constraint:  version.MustGetConstraint("< 2014.1.3-6", version.DebFormat),
						Reference: vulnerability.Reference{
							ID:        "CVE-2014-fake-1",
							Namespace: "debian:distro:debian:8",
						},
						PackageQualifiers: []qualifier.Qualifier{},
						CPEs:              []cpe.CPE{},
						Advisories:        []vulnerability.Advisory{},
					},
					Package: neutron2013Pkg,
					Details: match.Details{
						{
							Type: match.ExactDirectMatch,
							SearchedBy: match.DistroParameters{
								Distro:    match.DistroIdentification{Type: "debian", Version: "8"},
								Namespace: "debian:distro:debian:8",
								Package:   match.PackageParameter{Name: "neutron", Version: "2013.1.1-1"},
							},
							Found: match.DistroResult{
								VulnerabilityID:   "CVE-2014-fake-1",
								VersionConstraint: "< 2014.1.3-6 (deb)",
							},
							Matcher:    "dpkg-matcher",
							Confidence: 1,
						},
					},
				},
			),
			wantIgnoredMatches: nil,
			wantErr:            nil,
		},
		{
			name: "fail on severity threshold",
			fields: fields{
				Matchers: matcher.NewDefaultMatchers(matcher.Config{}),
				FailSeverity: func() *vulnerability.Severity {
					x := vulnerability.LowSeverity
					return &x
				}(),
			},
			args: args{
				pkgs: []pkg.Package{
					neutron2013Pkg,
				},
				context: pkg.Context{},
			},
			wantMatches: match.NewMatches(
				match.Match{
					Vulnerability: vulnerability.Vulnerability{
						PackageName: "neutron",
						Constraint:  version.MustGetConstraint("< 2014.1.3-6", version.DebFormat),
						Reference: vulnerability.Reference{
							ID:        "CVE-2014-fake-1",
							Namespace: "debian:distro:debian:8",
						},
						PackageQualifiers: []qualifier.Qualifier{},
						CPEs:              []cpe.CPE{},
						Advisories:        []vulnerability.Advisory{},
					},
					Package: neutron2013Pkg,
					Details: match.Details{
						{
							Type: match.ExactDirectMatch,
							SearchedBy: match.DistroParameters{
								Distro:    match.DistroIdentification{Type: "debian", Version: "8"},
								Namespace: "debian:distro:debian:8",
								Package:   match.PackageParameter{Name: "neutron", Version: "2013.1.1-1"},
							},
							Found: match.DistroResult{
								VulnerabilityID:   "CVE-2014-fake-1",
								VersionConstraint: "< 2014.1.3-6 (deb)",
							},
							Matcher:    "dpkg-matcher",
							Confidence: 1,
						},
					},
				},
			),
			wantIgnoredMatches: nil,
			wantErr:            grypeerr.ErrAboveSeverityThreshold,
		},
		{
			name: "pass on severity threshold with VEX",
			fields: fields{
				Matchers: matcher.NewDefaultMatchers(matcher.Config{}),
				FailSeverity: func() *vulnerability.Severity {
					x := vulnerability.LowSeverity
					return &x
				}(),
				VexProcessor: openvexProcessor,
			},
			args: args{
				pkgs: []pkg.Package{
					neutron2013Pkg,
				},
				context: pkg.Context{
					Source: &source.Description{
						Name:    "debian",
						Version: "2013.1.1-1",
						Metadata: source.ImageMetadata{
							RepoDigests: []string{
								"debian@sha256:124c7d2707904eea7431fffe91522a01e5a861a624ee31d03372cc1d138a3126",
							},
						},
					},
				},
			},
			wantMatches: match.NewMatches(),
			wantIgnoredMatches: []match.IgnoredMatch{
				{
					AppliedIgnoreRules: []match.IgnoreRule{
						{
							Namespace: "vex",
							VexStatus: "fixed",
						},
					},
					Match: match.Match{
						Vulnerability: vulnerability.Vulnerability{
							PackageName: "neutron",
							Constraint:  version.MustGetConstraint("< 2014.1.3-6", version.DebFormat),
							Reference: vulnerability.Reference{
								ID:        "CVE-2014-fake-1",
								Namespace: "debian:distro:debian:8",
							},
							PackageQualifiers: []qualifier.Qualifier{},
							CPEs:              []cpe.CPE{},
							Advisories:        []vulnerability.Advisory{},
						},
						Package: neutron2013Pkg,
						Details: match.Details{
							{
								Type: match.ExactDirectMatch,
								SearchedBy: match.DistroParameters{
									Distro:    match.DistroIdentification{Type: "debian", Version: "8"},
									Namespace: "debian:distro:debian:8",
									Package:   match.PackageParameter{Name: "neutron", Version: "2013.1.1-1"},
								},
								Found: match.DistroResult{
									VulnerabilityID:   "CVE-2014-fake-1",
									VersionConstraint: "< 2014.1.3-6 (deb)",
								},
								Matcher:    "dpkg-matcher",
								Confidence: 1,
							},
						},
					},
				},
			},
			wantErr: nil,
		},
		{
			name: "matches by exact-direct match (language)",
			fields: fields{
				Matchers: matcher.NewDefaultMatchers(matcher.Config{
					Ruby: ruby.MatcherConfig{
						UseCPEs: true,
					},
				}),
			},
			args: args{
				pkgs: []pkg.Package{
					activerecordPkg,
				},
				context: pkg.Context{},
			},
			wantMatches: match.NewMatches(
				match.Match{
					Vulnerability: vulnerability.Vulnerability{
						PackageName: "activerecord",
						Constraint:  version.MustGetConstraint("< 3.7.6", version.UnknownFormat),
						Reference: vulnerability.Reference{
							ID:        "CVE-2014-fake-3",
							Namespace: "nvd:cpe",
						},
						CPEs: []cpe.CPE{
							mustCPE("cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*"),
						},
						PackageQualifiers: []qualifier.Qualifier{},
						Advisories:        []vulnerability.Advisory{},
					},
					Package: activerecordPkg,
					Details: match.Details{
						{
							Type: match.CPEMatch,
							SearchedBy: match.CPEParameters{
								Namespace: "nvd:cpe",
								CPEs: []string{
									"cpe:2.3:*:activerecord:activerecord:3.7.5:*:*:*:*:rails:*:*",
								},
								Package: match.PackageParameter{
									Name:    "activerecord",
									Version: "3.7.5",
								},
							},
							Found: match.CPEResult{
								VulnerabilityID:   "CVE-2014-fake-3",
								VersionConstraint: "< 3.7.6 (unknown)",
								CPEs: []string{
									"cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*",
								},
							},
							Matcher:    "ruby-gem-matcher",
							Confidence: 0.9,
						},
					},
				},
				match.Match{
					Vulnerability: vulnerability.Vulnerability{
						PackageName: "activerecord",
						Constraint:  version.MustGetConstraint("< 3.7.6", version.UnknownFormat),
						Reference: vulnerability.Reference{
							ID:        "GHSA-2014-fake-3",
							Namespace: "github:language:ruby",
						},
						RelatedVulnerabilities: []vulnerability.Reference{
							{
								ID:        "CVE-2014-fake-3",
								Namespace: "nvd:cpe",
							},
						},
						PackageQualifiers: []qualifier.Qualifier{},
						Advisories:        []vulnerability.Advisory{},
						CPEs:              []cpe.CPE{},
					},
					Package: activerecordPkg,
					Details: match.Details{
						{
							Type: match.ExactDirectMatch,
							SearchedBy: match.EcosystemParameters{
								Language:  "ruby",
								Namespace: "github:language:ruby",
								Package:   match.PackageParameter{Name: "activerecord", Version: "3.7.5"},
							},
							Found: match.EcosystemResult{
								VulnerabilityID:   "GHSA-2014-fake-3",
								VersionConstraint: "< 3.7.6 (unknown)",
							},
							Matcher:    "ruby-gem-matcher",
							Confidence: 1,
						},
					},
				},
			),
			wantIgnoredMatches: nil,
			wantErr:            nil,
		},
		{
			name: "normalize by cve",
			fields: fields{
				Matchers: matcher.NewDefaultMatchers(
					matcher.Config{
						Ruby: ruby.MatcherConfig{
							UseCPEs: true,
						},
					},
				),
				NormalizeByCVE: true, // IMPORTANT!
			},
			args: args{
				pkgs: []pkg.Package{
					activerecordPkg,
				},
				context: pkg.Context{},
			},
			wantMatches: match.NewMatches(
				match.Match{
					Vulnerability: vulnerability.Vulnerability{
						PackageName: "activerecord",
						Constraint:  version.MustGetConstraint("< 3.7.6", version.UnknownFormat),
						Reference: vulnerability.Reference{
							ID:        "CVE-2014-fake-3",
							Namespace: "nvd:cpe",
						},
						CPEs: []cpe.CPE{
							mustCPE("cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*"),
						},
						PackageQualifiers: []qualifier.Qualifier{},
						Advisories:        []vulnerability.Advisory{},
						RelatedVulnerabilities: []vulnerability.Reference{
							{
								ID:        "GHSA-2014-fake-3",
								Namespace: "github:language:ruby",
							},
						},
					},
					Package: activerecordPkg,
					Details: match.Details{
						{
							Type: match.ExactDirectMatch,
							SearchedBy: match.EcosystemParameters{
								Language:  "ruby",
								Namespace: "github:language:ruby",
								Package:   match.PackageParameter{Name: "activerecord", Version: "3.7.5"},
							},
							Found: match.EcosystemResult{
								VulnerabilityID:   "GHSA-2014-fake-3",
								VersionConstraint: "< 3.7.6 (unknown)",
							},
							Matcher:    "ruby-gem-matcher",
							Confidence: 1,
						},
						{
							Type: match.CPEMatch,
							SearchedBy: match.CPEParameters{
								Namespace: "nvd:cpe",
								CPEs: []string{
									"cpe:2.3:*:activerecord:activerecord:3.7.5:*:*:*:*:rails:*:*",
								},
								Package: match.PackageParameter{
									Name:    "activerecord",
									Version: "3.7.5",
								},
							},
							Found: match.CPEResult{
								VulnerabilityID:   "CVE-2014-fake-3",
								VersionConstraint: "< 3.7.6 (unknown)",
								CPEs: []string{
									"cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*",
								},
							},
							Matcher:    "ruby-gem-matcher",
							Confidence: 0.9,
						},
					},
				},
			),
			wantIgnoredMatches: nil,
			wantErr:            nil,
		},
		{
			name: "normalize by cve -- ignore GHSA",
			fields: fields{
				Matchers: matcher.NewDefaultMatchers(
					matcher.Config{
						Ruby: ruby.MatcherConfig{
							UseCPEs: true,
						},
					},
				),
				IgnoreRules: []match.IgnoreRule{
					{
						Vulnerability: "GHSA-2014-fake-3",
					},
				},
				NormalizeByCVE: true, // IMPORTANT!
			},
			args: args{
				pkgs: []pkg.Package{
					activerecordPkg,
				},
				context: pkg.Context{},
			},
			wantMatches: match.NewMatches(
				match.Match{
					Vulnerability: vulnerability.Vulnerability{
						PackageName: "activerecord",
						Constraint:  version.MustGetConstraint("< 3.7.6", version.UnknownFormat),
						Reference: vulnerability.Reference{
							ID:        "CVE-2014-fake-3",
							Namespace: "nvd:cpe",
						},
						CPEs: []cpe.CPE{
							mustCPE("cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*"),
						},
						PackageQualifiers: []qualifier.Qualifier{},
						Advisories:        []vulnerability.Advisory{},
					},
					Package: activerecordPkg,
					Details: match.Details{
						{
							Type: match.CPEMatch,
							SearchedBy: match.CPEParameters{
								Namespace: "nvd:cpe",
								CPEs: []string{
									"cpe:2.3:*:activerecord:activerecord:3.7.5:*:*:*:*:rails:*:*",
								},
								Package: match.PackageParameter{
									Name:    "activerecord",
									Version: "3.7.5",
								},
							},
							Found: match.CPEResult{
								VulnerabilityID:   "CVE-2014-fake-3",
								VersionConstraint: "< 3.7.6 (unknown)",
								CPEs: []string{
									"cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*",
								},
							},
							Matcher:    "ruby-gem-matcher",
							Confidence: 0.9,
						},
					},
				},
			),
			wantErr: nil,
			wantIgnoredMatches: []match.IgnoredMatch{
				{
					Match: match.Match{
						Vulnerability: vulnerability.Vulnerability{
							PackageName: "activerecord",
							Constraint:  version.MustGetConstraint("< 3.7.6", version.UnknownFormat),
							Reference: vulnerability.Reference{
								ID:        "CVE-2014-fake-3",
								Namespace: "nvd:cpe",
							},
							CPEs:              []cpe.CPE{},
							PackageQualifiers: []qualifier.Qualifier{},
							Advisories:        []vulnerability.Advisory{},
							RelatedVulnerabilities: []vulnerability.Reference{
								{
									ID:        "GHSA-2014-fake-3",
									Namespace: "github:language:ruby",
								},
							},
						},
						Package: activerecordPkg,
						Details: match.Details{
							{
								Type: match.ExactDirectMatch,
								SearchedBy: match.EcosystemParameters{
									Language:  "ruby",
									Namespace: "github:language:ruby",
									Package:   match.PackageParameter{Name: "activerecord", Version: "3.7.5"},
								},
								Found: match.EcosystemResult{
									VulnerabilityID:   "GHSA-2014-fake-3",
									VersionConstraint: "< 3.7.6 (unknown)",
								},
								Matcher:    "ruby-gem-matcher",
								Confidence: 1,
							},
						},
					},
					AppliedIgnoreRules: []match.IgnoreRule{
						{
							Vulnerability: "GHSA-2014-fake-3",
						},
					},
				},
			},
		},
		{
			name: "normalize by cve -- ignore CVE",
			fields: fields{
				Matchers: matcher.NewDefaultMatchers(
					matcher.Config{
						Ruby: ruby.MatcherConfig{
							UseCPEs: true,
						},
					},
				),
				IgnoreRules: []match.IgnoreRule{
					{
						Vulnerability: "CVE-2014-fake-3",
					},
				},
				NormalizeByCVE: true, // IMPORTANT!
			},
			args: args{
				pkgs: []pkg.Package{
					activerecordPkg,
				},
				context: pkg.Context{},
			},
			wantMatches: match.NewMatches(),
			wantIgnoredMatches: []match.IgnoredMatch{
				{
					Match: match.Match{
						Vulnerability: vulnerability.Vulnerability{
							PackageName: "activerecord",
							Constraint:  version.MustGetConstraint("< 3.7.6", version.UnknownFormat),
							Reference: vulnerability.Reference{
								ID:        "CVE-2014-fake-3",
								Namespace: "nvd:cpe",
							},
							CPEs: []cpe.CPE{
								mustCPE("cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*"),
							},
							PackageQualifiers:      []qualifier.Qualifier{},
							Advisories:             []vulnerability.Advisory{},
							RelatedVulnerabilities: nil,
						},
						Package: activerecordPkg,
						Details: match.Details{
							{
								Type: match.CPEMatch,
								SearchedBy: match.CPEParameters{
									Namespace: "nvd:cpe",
									CPEs: []string{
										"cpe:2.3:*:activerecord:activerecord:3.7.5:*:*:*:*:rails:*:*",
									},
									Package: match.PackageParameter{
										Name:    "activerecord",
										Version: "3.7.5",
									},
								},
								Found: match.CPEResult{
									VulnerabilityID:   "CVE-2014-fake-3",
									VersionConstraint: "< 3.7.6 (unknown)",
									CPEs: []string{
										"cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*",
									},
								},
								Matcher:    "ruby-gem-matcher",
								Confidence: 0.9,
							},
						},
					},
					AppliedIgnoreRules: []match.IgnoreRule{
						{
							Vulnerability: "CVE-2014-fake-3",
						},
					},
				},
				{
					AppliedIgnoreRules: []match.IgnoreRule{
						{
							Vulnerability: "CVE-2014-fake-3",
						},
					},
					Match: match.Match{
						Vulnerability: vulnerability.Vulnerability{
							PackageName: "activerecord",
							Constraint:  version.MustGetConstraint("< 3.7.6", version.UnknownFormat),
							Reference: vulnerability.Reference{
								ID:        "CVE-2014-fake-3",
								Namespace: "nvd:cpe",
							},
							CPEs:              []cpe.CPE{},
							PackageQualifiers: []qualifier.Qualifier{},
							Advisories:        []vulnerability.Advisory{},
							RelatedVulnerabilities: []vulnerability.Reference{
								{
									ID:        "GHSA-2014-fake-3",
									Namespace: "github:language:ruby",
								},
							},
						},
						Package: activerecordPkg,
						Details: match.Details{
							{
								Type: match.ExactDirectMatch,
								SearchedBy: match.EcosystemParameters{
									Language:  "ruby",
									Namespace: "github:language:ruby",
									Package:   match.PackageParameter{Name: "activerecord", Version: "3.7.5"},
								},
								Found: match.EcosystemResult{
									VulnerabilityID:   "GHSA-2014-fake-3",
									VersionConstraint: "< 3.7.6 (unknown)",
								},
								Matcher:    "ruby-gem-matcher",
								Confidence: 1,
							},
						},
					},
				},
			},
			wantErr: nil,
		},
		{
			name: "ignore CVE (not normalized by CVE)",
			fields: fields{
				Matchers: matcher.NewDefaultMatchers(matcher.Config{
					Ruby: ruby.MatcherConfig{
						UseCPEs: true,
					},
				}),
				IgnoreRules: []match.IgnoreRule{
					{
						Vulnerability: "CVE-2014-fake-3",
					},
				},
			},
			args: args{
				pkgs: []pkg.Package{
					activerecordPkg,
				},
			},
			wantMatches: match.NewMatches(
				match.Match{
					Vulnerability: vulnerability.Vulnerability{
						PackageName: "activerecord",
						Constraint:  version.MustGetConstraint("< 3.7.6", version.UnknownFormat),
						Reference: vulnerability.Reference{
							ID:        "GHSA-2014-fake-3",
							Namespace: "github:language:ruby",
						},
						RelatedVulnerabilities: []vulnerability.Reference{
							{
								ID:        "CVE-2014-fake-3",
								Namespace: "nvd:cpe",
							},
						},
						PackageQualifiers: []qualifier.Qualifier{},
						Advisories:        []vulnerability.Advisory{},
						CPEs:              []cpe.CPE{},
					},
					Package: activerecordPkg,
					Details: match.Details{
						{
							Type: match.ExactDirectMatch,
							SearchedBy: match.EcosystemParameters{
								Language:  "ruby",
								Namespace: "github:language:ruby",
								Package:   match.PackageParameter{Name: "activerecord", Version: "3.7.5"},
							},
							Found: match.EcosystemResult{
								VulnerabilityID:   "GHSA-2014-fake-3",
								VersionConstraint: "< 3.7.6 (unknown)",
							},
							Matcher:    "ruby-gem-matcher",
							Confidence: 1,
						},
					},
				},
			),
			wantIgnoredMatches: []match.IgnoredMatch{
				{
					AppliedIgnoreRules: []match.IgnoreRule{
						{
							Vulnerability: "CVE-2014-fake-3",
						},
					},
					Match: match.Match{
						Vulnerability: vulnerability.Vulnerability{
							PackageName: "activerecord",
							Constraint:  version.MustGetConstraint("< 3.7.6", version.UnknownFormat),
							Reference: vulnerability.Reference{
								ID:        "CVE-2014-fake-3",
								Namespace: "nvd:cpe",
							},
							CPEs: []cpe.CPE{
								mustCPE("cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*"),
							},
							PackageQualifiers: []qualifier.Qualifier{},
							Advisories:        []vulnerability.Advisory{},
						},
						Package: activerecordPkg,
						Details: match.Details{
							{
								Type: match.CPEMatch,
								SearchedBy: match.CPEParameters{
									Namespace: "nvd:cpe",
									CPEs: []string{
										"cpe:2.3:*:activerecord:activerecord:3.7.5:*:*:*:*:rails:*:*",
									},
									Package: match.PackageParameter{
										Name:    "activerecord",
										Version: "3.7.5",
									},
								},
								Found: match.CPEResult{
									VulnerabilityID:   "CVE-2014-fake-3",
									VersionConstraint: "< 3.7.6 (unknown)",
									CPEs: []string{
										"cpe:2.3:*:activerecord:activerecord:*:*:*:*:*:rails:*:*",
									},
								},
								Matcher:    "ruby-gem-matcher",
								Confidence: 0.9,
							},
						},
					},
				},
			},
			wantErr: nil,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			m := &VulnerabilityMatcher{
				VulnerabilityProvider: vp,
				Matchers:              tt.fields.Matchers,
				IgnoreRules:           tt.fields.IgnoreRules,
				FailSeverity:          tt.fields.FailSeverity,
				NormalizeByCVE:        tt.fields.NormalizeByCVE,
				VexProcessor:          tt.fields.VexProcessor,
			}

			listener := &busListener{}
			bus.Set(listener)
			defer bus.Set(nil)

			actualMatches, actualIgnoreMatches, err := m.FindMatches(tt.args.pkgs, tt.args.context)
			if tt.wantErr != nil {
				require.ErrorIs(t, err, tt.wantErr)
				return
			} else if err != nil {
				t.Errorf("FindMatches() error = %v, wantErr %v", err, tt.wantErr)
				return
			}

			var opts = []cmp.Option{
				cmpopts.EquateEmpty(),
				cmpopts.IgnoreUnexported(match.Match{}),
				cmpopts.IgnoreFields(vulnerability.Vulnerability{}, "Constraint"),
				cmpopts.IgnoreFields(vulnerability.Reference{}, "Internal"),
				cmpopts.IgnoreFields(pkg.Package{}, "Locations", "Distro"),
				cmpopts.IgnoreUnexported(match.IgnoredMatch{}),
			}

			if d := cmp.Diff(tt.wantMatches.Sorted(), actualMatches.Sorted(), opts...); d != "" {
				t.Errorf("FindMatches() matches mismatch [ha!] (-want +got):\n%s", d)
			}

			if d := cmp.Diff(tt.wantIgnoredMatches, actualIgnoreMatches, opts...); d != "" {
				t.Errorf("FindMatches() ignored matches mismatch [ha!] (-want +got):\n%s", d)
			}

			// validate the bus-reported ignored counts are accurate
			require.Equal(t, int64(len(tt.wantIgnoredMatches)), listener.matching.Ignored.Current())
		})
	}
}

func TestVulnerabilityMatcher_APKNakByOwnership(t *testing.T) {
	// This test exercises the full matching pipeline where an APK package has a NAK (< 0) for a vulnerability,
	// and a related python package (owned by the APK package via file overlap) has a real vulnerability match.
	// The NAK should cause the python package's match to be filtered out via the IgnoreRelatedPackage mechanism.
	//
	// Based on GHSA-xjjg-vmw6-c2p9 (httpie):
	// - chainguard/wolfi APK has a NAK (< 0) for httpie
	// - github:language:python has a real vulnerability (< 1.0.3) for httpie
	// - The python httpie@1.0.2 package is owned by the APK httpie package via file overlap

	apkPkgID := pkg.ID("apk-httpie-pkg")
	pythonPkgID := pkg.ID("python-httpie-pkg")

	// The NAK vulnerability in the APK distro namespace
	nakVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "GHSA-xjjg-vmw6-c2p9",
			Namespace: "wolfi:distro:wolfi:rolling",
		},
		PackageName: "httpie",
		Constraint:  version.MustGetConstraint("< 0", version.ApkFormat),
	}

	// The real python vulnerability
	pythonVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "GHSA-xjjg-vmw6-c2p9",
			Namespace: "github:language:python",
		},
		PackageName: "httpie",
		Constraint:  version.MustGetConstraint("< 1.0.3", version.UnknownFormat),
	}

	tests := []struct {
		name           string
		apkPkg         pkg.Package
		pythonPkg      pkg.Package
		wantMatchCount int
	}{
		{
			name: "with files - python match dropped via location and relationship NAK filters",
			apkPkg: pkg.Package{
				ID:      apkPkgID,
				Name:    "httpie",
				Version: "1.0.2-r1",
				Type:    syftPkg.ApkPkg,
				Distro:  distro.New(distro.Wolfi, "", ""),
				Metadata: pkg.ApkMetadata{Files: []pkg.ApkFileRecord{
					{Path: "/usr/lib/python3.14/site-packages/httpie-1.0.2.dist-info/METADATA"},
				}},
			},
			pythonPkg: pkg.Package{
				ID:       pythonPkgID,
				Name:     "httpie",
				Version:  "1.0.2",
				Type:     syftPkg.PythonPkg,
				Language: syftPkg.Python,
				Locations: file.NewLocationSet(
					file.NewLocation("/usr/lib/python3.14/site-packages/httpie-1.0.2.dist-info/METADATA"),
				),
				RelatedPackages: map[artifact.RelationshipType][]*pkg.Package{
					artifact.OwnershipByFileOverlapRelationship: {
						{ID: apkPkgID, Name: "httpie"},
					},
				},
			},
			wantMatchCount: 0,
		},
		{
			name: "without files - python match dropped via relationship NAK filter only",
			apkPkg: pkg.Package{
				ID:       apkPkgID,
				Name:     "httpie",
				Version:  "1.0.2-r1",
				Type:     syftPkg.ApkPkg,
				Distro:   distro.New(distro.Wolfi, "", ""),
				Metadata: pkg.ApkMetadata{Files: nil},
			},
			pythonPkg: pkg.Package{
				ID:       pythonPkgID,
				Name:     "httpie",
				Version:  "1.0.2",
				Type:     syftPkg.PythonPkg,
				Language: syftPkg.Python,
				Locations: file.NewLocationSet(
					file.NewLocation("/usr/lib/python3.14/site-packages/httpie-1.0.2.dist-info/METADATA"),
				),
				RelatedPackages: map[artifact.RelationshipType][]*pkg.Package{
					artifact.OwnershipByFileOverlapRelationship: {
						{ID: apkPkgID, Name: "httpie"},
					},
				},
			},
			wantMatchCount: 0,
		},
		{
			name: "no relationship - python match NOT dropped",
			apkPkg: pkg.Package{
				ID:       apkPkgID,
				Name:     "httpie",
				Version:  "1.0.2-r1",
				Type:     syftPkg.ApkPkg,
				Distro:   distro.New(distro.Wolfi, "", ""),
				Metadata: pkg.ApkMetadata{Files: nil},
			},
			pythonPkg: pkg.Package{
				ID:       pythonPkgID,
				Name:     "httpie",
				Version:  "1.0.2",
				Type:     syftPkg.PythonPkg,
				Language: syftPkg.Python,
				Locations: file.NewLocationSet(
					file.NewLocation("/usr/lib/python3.14/site-packages/httpie-1.0.2.dist-info/METADATA"),
				),
				// no RelatedPackages - this simulates no ownership relationship
			},
			wantMatchCount: 1,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			vp := mock.VulnerabilityProvider(nakVuln, pythonVuln)

			// APK matcher: returns no matches (NAK means not vulnerable) but returns ignore filters
			apkMatcher := matcherMock.New(syftPkg.ApkPkg, func(provider vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) {
				if p.Type != syftPkg.ApkPkg {
					return nil, nil, nil
				}
				// simulate what the real APK matcher does for NAK entries
				meta, ok := p.Metadata.(pkg.ApkMetadata)
				if !ok {
					return nil, nil, nil
				}
				var ignores []match.IgnoreFilter
				for _, f := range meta.Files {
					ignores = append(ignores, match.IgnoreRule{
						Vulnerability:  nakVuln.ID,
						IncludeAliases: true,
						Reason:         "Explicit APK NAK",
						Package: match.IgnoreRulePackage{
							Location: f.Path,
						},
					})
				}
				ignores = append(ignores, match.IgnoreRelatedPackage{
					Reason:           "Explicit APK NAK by Ownership",
					RelationshipType: artifact.OwnershipByFileOverlapRelationship,
					VulnerabilityID:  nakVuln.ID,
					RelatedPackageID: p.ID,
				})
				return nil, ignores, nil
			})

			// Python matcher: returns a match for the python vulnerability
			pythonMatcher := matcherMock.New(syftPkg.PythonPkg, func(provider vulnerability.Provider, p pkg.Package) ([]match.Match, []match.IgnoreFilter, error) {
				if p.Type != syftPkg.PythonPkg {
					return nil, nil, nil
				}
				return []match.Match{
					{
						Vulnerability: pythonVuln,
						Package:       p,
						Details: match.Details{
							{
								Type:       match.ExactDirectMatch,
								Confidence: 1.0,
								Matcher:    "python-matcher",
							},
						},
					},
				}, nil, nil
			})

			m := &VulnerabilityMatcher{
				VulnerabilityProvider: vp,
				Matchers:              []match.Matcher{apkMatcher, pythonMatcher},
			}

			actualMatches, _, err := m.FindMatches(
				[]pkg.Package{tt.apkPkg, tt.pythonPkg},
				pkg.Context{},
			)
			require.NoError(t, err)

			assert.Equal(t, tt.wantMatchCount, actualMatches.Count(), "remaining match count")
		})
	}
}

func Test_fatalErrors(t *testing.T) {
	tests := []struct {
		name        string
		matcherFunc matcherMock.MatchFunc
		assertErr   assert.ErrorAssertionFunc
	}{
		{
			name: "no error",
			matcherFunc: func(_ vulnerability.Provider, _ pkg.Package) ([]match.Match, []match.IgnoreFilter, error) {
				return nil, nil, nil
			},
			assertErr: assert.NoError,
		},
		{
			name: "non-fatal error",
			matcherFunc: func(_ vulnerability.Provider, _ pkg.Package) ([]match.Match, []match.IgnoreFilter, error) {
				return nil, nil, errors.New("some error")
			},
			assertErr: assert.NoError,
		},
		{
			name: "fatal error",
			matcherFunc: func(_ vulnerability.Provider, _ pkg.Package) ([]match.Match, []match.IgnoreFilter, error) {
				return nil, nil, match.NewFatalError(match.UnknownMatcherType, errors.New("some error"))
			},
			assertErr: assert.Error,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			m := &VulnerabilityMatcher{
				Matchers: []match.Matcher{matcherMock.New(syftPkg.JavaPkg, tt.matcherFunc)},
			}

			_, _, err := m.FindMatches([]pkg.Package{
				{
					Name:    "foo",
					Version: "1.2.3",
					Type:    syftPkg.JavaPkg,
				},
			},
				pkg.Context{},
			)

			tt.assertErr(t, err)
		})
	}
}

func Test_matchIgnoreFiltering(t *testing.T) {
	// one commonly used filter uses APK NAK data to exclude false positives on language packages at the same locations
	// based on packages in the APK DB. for example:
	//   APK package pkg1 is not vulnerable, but another python package is found with a slightly different name
	//   by the python cataloger when it scans the same files at the same locations that were associated with
	//   the APK package. the python package is checked by a separate matcher, so we surface ignore rules
	//   based on location and vuln id to exclude these false positives
	ignoreByLocationAndVuln := func(locationToVulnIDs map[string][]string) []match.IgnoreFilter {
		var out []match.IgnoreFilter
		for path, vulnIDs := range locationToVulnIDs {
			for _, vulnID := range vulnIDs {
				out = append(out, match.IgnoreRule{
					Vulnerability:  vulnID,
					IncludeAliases: true,
					Package: match.IgnoreRulePackage{
						Location: path,
					},
				})
			}
		}
		return out
	}

	// with the addition of unaffected packages, ignore rules are returned by package language searches to account for
	// searches for subsequent CPE searches based on the specific package
	ignoreByPackageNameAndVuln := func(pkgNameToVulnIDs map[string][]string) []match.IgnoreFilter {
		var out []match.IgnoreFilter
		for packageName, vulnIDs := range pkgNameToVulnIDs {
			for _, vulnID := range vulnIDs {
				out = append(out, match.IgnoreRule{
					Vulnerability:  vulnID,
					IncludeAliases: true,
					Package: match.IgnoreRulePackage{
						Name: packageName,
					},
				})
			}
		}
		return out
	}

	// construct matches here so we can make test cases more readable

	loc1 := "/usr/bin/pkg1"
	loc2 := "/other/pkg1"

	vuln1 := "vuln1"
	vuln2 := "vuln2"

	pkg1_vuln1_loc1 := match.Match{
		Package: pkg.Package{
			Type:      syftPkg.PythonPkg,
			Name:      "pkg1",
			Locations: file.NewLocationSet(file.NewLocation(loc1)),
		},
		Vulnerability: vulnerability.Vulnerability{Reference: vulnerability.Reference{ID: vuln1}},
	}
	pkg1_vuln2_loc1 := match.Match{
		Package: pkg.Package{
			Name:      "pkg1",
			Locations: file.NewLocationSet(file.NewLocation(loc1)),
		},
		Vulnerability: vulnerability.Vulnerability{Reference: vulnerability.Reference{ID: vuln2}},
	}
	pkg1_vuln1_loc2 := match.Match{
		Package: pkg.Package{
			Name:      "pkg1",
			Locations: file.NewLocationSet(file.NewLocation(loc2)),
		},
		Vulnerability: vulnerability.Vulnerability{Reference: vulnerability.Reference{ID: vuln1}},
	}
	pkg2_vuln1_loc1 := match.Match{
		Package: pkg.Package{
			Type:      syftPkg.PythonPkg,
			Name:      "pkg2",
			Locations: file.NewLocationSet(file.NewLocation(loc1)),
		},
		Vulnerability: vulnerability.Vulnerability{Reference: vulnerability.Reference{ID: vuln1}},
	}
	pkg2_vuln2_loc1 := match.Match{
		Package: pkg.Package{
			Name:      "pkg2",
			Locations: file.NewLocationSet(file.NewLocation(loc1)),
		},
		Vulnerability: vulnerability.Vulnerability{Reference: vulnerability.Reference{ID: vuln2}},
	}

	cases := []struct {
		name          string
		inputMatches  []match.Match
		ignoreFilters []match.IgnoreFilter
		expected      []match.Match
	}{
		{
			name:         "no input matches",
			inputMatches: nil,
			ignoreFilters: ignoreByLocationAndVuln(map[string][]string{
				loc1: {vuln1},
			}),
			expected: nil,
		},
		{
			name: "no ignore rules",
			inputMatches: []match.Match{
				pkg1_vuln1_loc1,
				pkg1_vuln1_loc2,
			},
			ignoreFilters: nil,
			expected: []match.Match{
				pkg1_vuln1_loc1,
				pkg1_vuln1_loc2,
			},
		},
		{
			name: "happy path filtering",
			inputMatches: []match.Match{
				pkg1_vuln1_loc1,
			},
			ignoreFilters: ignoreByLocationAndVuln(map[string][]string{
				loc1: {vuln1},
			}),
			expected: nil,
		},
		{
			name: "location match different vuln",
			inputMatches: []match.Match{
				pkg1_vuln1_loc1,
			},
			ignoreFilters: ignoreByLocationAndVuln(map[string][]string{
				loc1: {vuln2},
			}),
			expected: []match.Match{
				pkg1_vuln1_loc1,
			},
		},
		{
			name: "location across packages",
			inputMatches: []match.Match{
				pkg1_vuln1_loc1,
				pkg1_vuln2_loc1,
				pkg2_vuln1_loc1,
				pkg2_vuln2_loc1,
			},
			ignoreFilters: ignoreByLocationAndVuln(map[string][]string{
				loc1: {vuln1},
			}),
			expected: []match.Match{
				pkg1_vuln2_loc1,
				pkg2_vuln2_loc1,
			},
		},
		{
			name: "package name",
			inputMatches: []match.Match{
				pkg1_vuln1_loc1,
				pkg1_vuln2_loc1,
				pkg2_vuln1_loc1,
			},
			ignoreFilters: ignoreByPackageNameAndVuln(map[string][]string{
				pkg1_vuln1_loc1.Package.Name: {vuln1},
			}),
			expected: []match.Match{
				pkg1_vuln2_loc1,
				pkg2_vuln1_loc1,
			},
		},
		{
			name: "not indexed rule",
			inputMatches: []match.Match{
				pkg1_vuln1_loc1,
				pkg1_vuln2_loc1, // not python package
				pkg2_vuln1_loc1,
			},
			ignoreFilters: []match.IgnoreFilter{
				match.IgnoreRule{
					Package: match.IgnoreRulePackage{
						Type: string(syftPkg.PythonPkg), // no indexed properties
					},
				},
			},
			expected: []match.Match{
				pkg1_vuln2_loc1,
			},
		},
		{
			name: "not indexed filter",
			inputMatches: []match.Match{
				pkg1_vuln1_loc1,
				pkg1_vuln1_loc2,
				pkg2_vuln1_loc1,
				pkg1_vuln2_loc1,
			},
			ignoreFilters: []match.IgnoreFilter{
				testIgnoreFilter{func(m match.Match) bool {
					return m.Vulnerability.ID == vuln1
				}},
			},
			expected: []match.Match{
				pkg1_vuln2_loc1,
			},
		},
		{
			name: "multiple rules mixed",
			inputMatches: []match.Match{
				pkg1_vuln1_loc1, // removed by custom filter
				pkg1_vuln1_loc2,
				pkg2_vuln1_loc1, // removed by name + vuln
				pkg1_vuln2_loc1, // removed by location + vuln
			},
			ignoreFilters: append(append([]match.IgnoreFilter{
				testIgnoreFilter{func(m match.Match) bool {
					return m.Vulnerability.ID == vuln1
				}},
			}, ignoreByLocationAndVuln(map[string][]string{
				loc2: {vuln2},
			})...), ignoreByPackageNameAndVuln(map[string][]string{
				pkg2_vuln1_loc1.Package.Name: {vuln1},
			})...),
			expected: []match.Match{
				pkg1_vuln2_loc1,
			},
		},
	}

	for _, tt := range cases {
		t.Run(tt.name, func(t *testing.T) {
			filter := ignoredMatchFilter(tt.ignoreFilters)

			actual, _ := match.ApplyIgnoreFilters(tt.inputMatches, filter)

			assert.Equal(t, tt.expected, actual)
		})
	}
}

func Test_ignoredMatchFilterReasons(t *testing.T) {
	matches := []match.Match{
		{
			Vulnerability: vulnerability.Vulnerability{
				Reference: vulnerability.Reference{
					ID: "CVE-123",
				},
			},
			Package: pkg.Package{
				Locations: file.NewLocationSet(file.NewLocation("/usr/bin/thing")),
			},
		},
		{
			Vulnerability: vulnerability.Vulnerability{
				Reference: vulnerability.Reference{
					ID: "CVE-456",
				},
			},
		},
		{
			Vulnerability: vulnerability.Vulnerability{
				Reference: vulnerability.Reference{
					ID: "CVE-789",
				},
				Status: "filter-me",
			},
		},
	}

	ignores := []match.IgnoreFilter{
		match.IgnoreRule{
			Reason: "test-location-ignore-rule",
			Package: match.IgnoreRulePackage{
				Location: "/usr/bin/thing",
			},
		},
		testIgnoreFilter{
			f: func(m match.Match) bool {
				return m.Vulnerability.Status == "filter-me"
			},
		},
	}

	f := ignoredMatchFilter(ignores)

	var ignoredReasons []string
	for _, m := range matches {
		got := f.IgnoreMatch(m)
		for _, r := range got {
			ignoredReasons = append(ignoredReasons, r.Reason)
		}
	}

	require.ElementsMatch(t, []string{"test-location-ignore-rule", "test-filtered"}, ignoredReasons)
}

type testIgnoreFilter struct {
	f func(match.Match) bool
}

func (t testIgnoreFilter) IgnoreMatch(m match.Match) []match.IgnoreRule {
	if t.f(m) {
		return []match.IgnoreRule{
			{
				Reason: "test-filtered",
			},
		}
	}
	return nil
}

type panicyMatcher struct {
	matcherType match.MatcherType
}

func (m *panicyMatcher) PackageTypes() []syftPkg.Type {
	return nil
}

func (m *panicyMatcher) Type() match.MatcherType {
	return m.matcherType
}

func (m *panicyMatcher) Match(_ vulnerability.Provider, _ pkg.Package) ([]match.Match, []match.IgnoreFilter, error) {
	panic("test panic message")
}

func TestCallMatcherSafely_RecoverFromPanic(t *testing.T) {
	matcher := &panicyMatcher{
		matcherType: "test-matcher",
	}
	_, _, err := callMatcherSafely(matcher, nil, pkg.Package{})

	require.Error(t, err)
	assert.True(t, match.IsFatalError(err))
	require.Contains(t, err.Error(), "test panic message", "missing message")
	require.Contains(t, err.Error(), "test-matcher", "missing matcher name")
}

type busListener struct {
	matching monitor.Matching
}

func (b *busListener) Publish(e partybus.Event) {
	if e.Type == event.VulnerabilityScanningStarted {
		if m, ok := e.Value.(monitor.Matching); ok {
			b.matching = m
		}
	}
}

var _ partybus.Publisher = (*busListener)(nil)

// mockEOLProvider is a mock vulnerability provider that also implements EOLChecker
type mockEOLProvider struct {
	vulnerability.Provider
	eolDates map[string]*time.Time // distro key -> EOL date
}

func (m *mockEOLProvider) GetOperatingSystemEOL(d *distro.Distro) (*time.Time, *time.Time, error) {
	if m.eolDates == nil {
		return nil, nil, nil
	}
	eolDate := m.eolDates[d.String()]
	return eolDate, nil, nil
}

var _ vulnerability.EOLChecker = (*mockEOLProvider)(nil)

func TestEOLTracker(t *testing.T) {
	pastDate := time.Now().Add(-24 * time.Hour)
	futureDate := time.Now().Add(24 * time.Hour * 365)

	debian8 := &distro.Distro{
		Type:    "debian",
		Version: "8",
	}
	ubuntu2204 := &distro.Distro{
		Type:    "ubuntu",
		Version: "22.04",
	}

	tests := []struct {
		name          string
		enabled       bool
		provider      vulnerability.Provider
		pkg           pkg.Package
		expectedIsEOL bool
	}{
		{
			name:          "tracking disabled - returns false",
			enabled:       false,
			provider:      mock.VulnerabilityProvider(),
			pkg:           pkg.Package{Distro: debian8},
			expectedIsEOL: false,
		},
		{
			name:          "provider does not implement EOLChecker - returns false",
			enabled:       true,
			provider:      mock.VulnerabilityProvider(), // does not implement EOLChecker
			pkg:           pkg.Package{Distro: debian8},
			expectedIsEOL: false,
		},
		{
			name:    "package has no distro - returns false",
			enabled: true,
			provider: &mockEOLProvider{
				Provider: mock.VulnerabilityProvider(),
				eolDates: map[string]*time.Time{
					debian8.String(): &pastDate,
				},
			},
			pkg:           pkg.Package{Distro: nil},
			expectedIsEOL: false,
		},
		{
			name:    "distro is EOL - returns true",
			enabled: true,
			provider: &mockEOLProvider{
				Provider: mock.VulnerabilityProvider(),
				eolDates: map[string]*time.Time{
					debian8.String(): &pastDate,
				},
			},
			pkg:           pkg.Package{Distro: debian8},
			expectedIsEOL: true,
		},
		{
			name:    "distro is not EOL - returns false",
			enabled: true,
			provider: &mockEOLProvider{
				Provider: mock.VulnerabilityProvider(),
				eolDates: map[string]*time.Time{
					ubuntu2204.String(): &futureDate,
				},
			},
			pkg:           pkg.Package{Distro: ubuntu2204},
			expectedIsEOL: false,
		},
		{
			name:    "distro has no EOL data - returns false",
			enabled: true,
			provider: &mockEOLProvider{
				Provider: mock.VulnerabilityProvider(),
				eolDates: map[string]*time.Time{}, // no data for any distro
			},
			pkg:           pkg.Package{Distro: debian8},
			expectedIsEOL: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			tracker := newEOLTracker(tt.enabled, tt.provider)
			result := tracker.checkAndTrack(tt.pkg)
			assert.Equal(t, tt.expectedIsEOL, result)
		})
	}
}

func TestEOLTrackerCaching(t *testing.T) {
	pastDate := time.Now().Add(-24 * time.Hour)

	debian8 := &distro.Distro{
		Type:    "debian",
		Version: "8",
	}

	callCount := 0
	provider := &countingEOLProvider{
		Provider: mock.VulnerabilityProvider(),
		eolDates: map[string]*time.Time{
			debian8.String(): &pastDate,
		},
		callCount: &callCount,
	}

	tracker := newEOLTracker(true, provider)

	// First call should query the provider
	result1 := tracker.checkAndTrack(pkg.Package{Distro: debian8, Name: "pkg1"})
	assert.True(t, result1)
	assert.Equal(t, 1, callCount, "expected one call to GetOperatingSystemEOL")

	// Second call with same distro should use cache
	result2 := tracker.checkAndTrack(pkg.Package{Distro: debian8, Name: "pkg2"})
	assert.True(t, result2)
	assert.Equal(t, 1, callCount, "expected no additional call - should use cache")

	// Third call with same distro should still use cache
	result3 := tracker.checkAndTrack(pkg.Package{Distro: debian8, Name: "pkg3"})
	assert.True(t, result3)
	assert.Equal(t, 1, callCount, "expected no additional call - should use cache")
}

type countingEOLProvider struct {
	vulnerability.Provider
	eolDates  map[string]*time.Time
	callCount *int
}

func (c *countingEOLProvider) GetOperatingSystemEOL(d *distro.Distro) (*time.Time, *time.Time, error) {
	*c.callCount++
	if c.eolDates == nil {
		return nil, nil, nil
	}
	eolDate := c.eolDates[d.String()]
	return eolDate, nil, nil
}

var _ vulnerability.EOLChecker = (*countingEOLProvider)(nil)

func TestVulnerabilityMatcher_EOLDistroPackages(t *testing.T) {
	pastDate := time.Now().Add(-24 * time.Hour)
	futureDate := time.Now().Add(24 * time.Hour * 365)

	debian8 := &distro.Distro{
		Type:    "debian",
		Version: "8",
	}
	ubuntu2204 := &distro.Distro{
		Type:    "ubuntu",
		Version: "22.04",
	}

	eolPkg := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "eol-pkg",
		Version: "1.0.0",
		Type:    syftPkg.DebPkg,
		Distro:  debian8,
	}

	nonEOLPkg := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "non-eol-pkg",
		Version: "1.0.0",
		Type:    syftPkg.DebPkg,
		Distro:  ubuntu2204,
	}

	noDistroPkg := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "no-distro-pkg",
		Version: "1.0.0",
		Type:    syftPkg.NpmPkg,
	}

	provider := &mockEOLProvider{
		Provider: mock.VulnerabilityProvider(),
		eolDates: map[string]*time.Time{
			debian8.String():    &pastDate,
			ubuntu2204.String(): &futureDate,
		},
	}

	tests := []struct {
		name                string
		alertsConfig        AlertsConfig
		packages            []pkg.Package
		expectedEOLPackages []string // package names expected to be tracked as EOL
	}{
		{
			name: "EOL tracking enabled - tracks EOL packages",
			alertsConfig: AlertsConfig{
				EnableEOLDistroWarnings: true,
			},
			packages:            []pkg.Package{eolPkg, nonEOLPkg, noDistroPkg},
			expectedEOLPackages: []string{"eol-pkg"},
		},
		{
			name: "EOL tracking disabled - no packages tracked",
			alertsConfig: AlertsConfig{
				EnableEOLDistroWarnings: false,
			},
			packages:            []pkg.Package{eolPkg, nonEOLPkg, noDistroPkg},
			expectedEOLPackages: nil,
		},
		{
			name: "all packages from EOL distro",
			alertsConfig: AlertsConfig{
				EnableEOLDistroWarnings: true,
			},
			packages:            []pkg.Package{eolPkg},
			expectedEOLPackages: []string{"eol-pkg"},
		},
		{
			name: "no packages from EOL distro",
			alertsConfig: AlertsConfig{
				EnableEOLDistroWarnings: true,
			},
			packages:            []pkg.Package{nonEOLPkg, noDistroPkg},
			expectedEOLPackages: nil,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			m := &VulnerabilityMatcher{
				VulnerabilityProvider: provider,
				Matchers:              matcher.NewDefaultMatchers(matcher.Config{}),
				Alerts:                tt.alertsConfig,
			}

			listener := &busListener{}
			bus.Set(listener)
			defer bus.Set(nil)

			_, _, err := m.FindMatches(tt.packages, pkg.Context{})
			require.NoError(t, err)

			eolPackages := m.EOLDistroPackages()

			var actualNames []string
			for _, p := range eolPackages {
				actualNames = append(actualNames, p.Name)
			}

			if tt.expectedEOLPackages == nil {
				assert.Empty(t, actualNames)
			} else {
				assert.ElementsMatch(t, tt.expectedEOLPackages, actualNames)
			}
		})
	}
}
