package python

import (
	"context"
	"fmt"
	"os"
	"path"
	"testing"

	"github.com/stretchr/testify/require"

	"github.com/anchore/syft/syft/file"
	"github.com/anchore/syft/syft/pkg"
	"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
)

func Test_InstalledPackageCataloger(t *testing.T) {
	ctx := context.TODO()
	tests := []struct {
		name             string
		fixture          string
		expectedPackages []pkg.Package
	}{
		{
			name:    "egg-file-no-version",
			fixture: "testdata/site-packages/no-version",
			expectedPackages: []pkg.Package{
				{
					Name:      "no-version",
					Locations: file.NewLocationSet(file.NewLocation("no-version-py3.8.egg-info")),
					PURL:      "pkg:pypi/no-version",
					Type:      pkg.PythonPkg,
					Language:  pkg.Python,
					FoundBy:   "python-installed-package-cataloger",
					Metadata: pkg.PythonPackage{
						Name:                 "no-version",
						SitePackagesRootPath: ".", // requires scanning the grandparent directory to get a valid path
					},
				},
			},
		},
		{
			name:    "dist-info+egg-info site-packages directory",
			fixture: "testdata/site-packages/nested",
			expectedPackages: []pkg.Package{
				{
					Name:     "pygments",
					Version:  "2.6.1",
					PURL:     "pkg:pypi/pygments@2.6.1?vcs_url=git%2Bhttps%3A%2F%2Fgithub.com%2Fpython-test%2Ftest.git%40aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
					Type:     pkg.PythonPkg,
					Language: pkg.Python,
					Locations: file.NewLocationSet(
						file.NewLocation("dist-name/dist-info/METADATA"),
						file.NewLocation("dist-name/dist-info/RECORD"),
						file.NewLocation("dist-name/dist-info/direct_url.json"),
						file.NewLocation("dist-name/dist-info/top_level.txt"),
					),
					Licenses: pkg.NewLicenseSet(
						// here we only used the license that was declared in the METADATA file, we did not go searching for other licenses
						// this is the better source of truth when there is no explicit LicenseFile given
						pkg.NewLicenseFromLocationsWithContext(ctx, "BSD License", file.NewLocation("dist-name/dist-info/METADATA")),
					),
					FoundBy: "python-installed-package-cataloger",
					Metadata: pkg.PythonPackage{
						Name:                 "Pygments",
						Version:              "2.6.1",
						Platform:             "any",
						Author:               "Georg Brandl",
						AuthorEmail:          "georg@python.org",
						SitePackagesRootPath: "dist-name",
						Files: []pkg.PythonFileRecord{
							{Path: "../../../bin/pygmentize", Digest: &pkg.PythonFileDigest{"sha256", "dDhv_U2jiCpmFQwIRHpFRLAHUO4R1jIJPEvT_QYTFp8"}, Size: "220"},
							{Path: "Pygments-2.6.1.dist-info/AUTHORS", Digest: &pkg.PythonFileDigest{"sha256", "PVpa2_Oku6BGuiUvutvuPnWGpzxqFy2I8-NIrqCvqUY"}, Size: "8449"},
							{Path: "Pygments-2.6.1.dist-info/LICENSE.txt", Digest: &pkg.PythonFileDigest{Algorithm: "sha256", Value: "utiUvpzxqFPVpvuPnWG2_Oku6BGuay2I8-NIrqCvqUY"}, Size: "8449"},
							{Path: "Pygments-2.6.1.dist-info/RECORD"},
							{Path: "pygments/__pycache__/__init__.cpython-38.pyc"},
							{Path: "pygments/util.py", Digest: &pkg.PythonFileDigest{"sha256", "586xXHiJGGZxqk5PMBu3vBhE68DLuAe5MBARWrSPGxA"}, Size: "10778"},

							{Path: "pygments/x_util.py", Digest: &pkg.PythonFileDigest{"sha256", "qpzzsOW31KT955agi-7NS--90I0iNiJCyLJQnRCHgKI="}, Size: "10778"},
						},
						TopLevelPackages: []string{"pygments", "something_else"},
						DirectURLOrigin:  &pkg.PythonDirectURLOriginInfo{URL: "https://github.com/python-test/test.git", VCS: "git", CommitID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
						RequiresPython:   ">=3.5",
						RequiresDist:     []string{"soupsieve (>1.2)", "html5lib ; extra == 'html5lib'", "lxml ; extra == 'lxml'"},
						ProvidesExtra:    []string{"html5lib", "lxml"},
					},
				},
				{
					Name:     "requests",
					Version:  "2.22.0",
					PURL:     "pkg:pypi/requests@2.22.0",
					Type:     pkg.PythonPkg,
					Language: pkg.Python,
					Locations: file.NewLocationSet(
						file.NewLocation("egg-name/egg-info/PKG-INFO"),
						file.NewLocation("egg-name/egg-info/RECORD"),
						file.NewLocation("egg-name/egg-info/top_level.txt"),
					),
					Licenses: pkg.NewLicenseSet(
						pkg.NewLicenseFromLocationsWithContext(ctx, "Apache 2.0", file.NewLocation("egg-name/egg-info/PKG-INFO")),
					),
					FoundBy: "python-installed-package-cataloger",
					Metadata: pkg.PythonPackage{
						Name:                 "requests",
						Version:              "2.22.0",
						Platform:             "UNKNOWN",
						Author:               "Kenneth Reitz",
						AuthorEmail:          "me@kennethreitz.org",
						SitePackagesRootPath: "egg-name",
						Files: []pkg.PythonFileRecord{
							{Path: "requests-2.22.0.dist-info/INSTALLER", Digest: &pkg.PythonFileDigest{"sha256", "zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg"}, Size: "4"},
							{Path: "requests/__init__.py", Digest: &pkg.PythonFileDigest{"sha256", "PnKCgjcTq44LaAMzB-7--B2FdewRrE8F_vjZeaG9NhA"}, Size: "3921"},
							{Path: "requests/__pycache__/__version__.cpython-38.pyc"},
							{Path: "requests/__pycache__/utils.cpython-38.pyc"},
							{Path: "requests/__version__.py", Digest: &pkg.PythonFileDigest{"sha256", "Bm-GFstQaFezsFlnmEMrJDe8JNROz9n2XXYtODdvjjc"}, Size: "436"},
							{Path: "requests/utils.py", Digest: &pkg.PythonFileDigest{"sha256", "LtPJ1db6mJff2TJSJWKi7rBpzjPS3mSOrjC9zRhoD3A"}, Size: "30049"},
						},
						TopLevelPackages: []string{"requests"},
						RequiresPython:   ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*",
						ProvidesExtra:    []string{"security", "socks"},
					},
				},
			},
		},
		{
			name:    "DIST-INFO+EGG-INFO site-packages directory (case insensitive)",
			fixture: "testdata/site-packages/uppercase",
			expectedPackages: []pkg.Package{
				{
					Name:     "pygments",
					Version:  "2.6.1",
					PURL:     "pkg:pypi/pygments@2.6.1?vcs_url=git%2Bhttps%3A%2F%2Fgithub.com%2Fpython-test%2Ftest.git%40aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
					Type:     pkg.PythonPkg,
					Language: pkg.Python,
					Locations: file.NewLocationSet(
						file.NewLocation("dist-name/DIST-INFO/METADATA"),
						file.NewLocation("dist-name/DIST-INFO/RECORD"),
						file.NewLocation("dist-name/DIST-INFO/direct_url.json"),
						file.NewLocation("dist-name/DIST-INFO/top_level.txt"),
					),
					Licenses: pkg.NewLicenseSet(
						// here we only used the license that was declared in the METADATA file, we did not go searching for other licenses
						// this is the better source of truth when there is no explicit LicenseFile given
						pkg.NewLicenseFromLocationsWithContext(ctx, "BSD License", file.NewLocation("dist-name/DIST-INFO/METADATA")),
					),
					FoundBy: "python-installed-package-cataloger",
					Metadata: pkg.PythonPackage{
						Name:                 "Pygments",
						Version:              "2.6.1",
						Platform:             "any",
						Author:               "Georg Brandl",
						AuthorEmail:          "georg@python.org",
						SitePackagesRootPath: "dist-name",
						Files: []pkg.PythonFileRecord{
							{Path: "../../../bin/pygmentize", Digest: &pkg.PythonFileDigest{"sha256", "dDhv_U2jiCpmFQwIRHpFRLAHUO4R1jIJPEvT_QYTFp8"}, Size: "220"},
							{Path: "Pygments-2.6.1.dist-info/AUTHORS", Digest: &pkg.PythonFileDigest{"sha256", "PVpa2_Oku6BGuiUvutvuPnWGpzxqFy2I8-NIrqCvqUY"}, Size: "8449"},
							{Path: "Pygments-2.6.1.dist-info/RECORD"},
							{Path: "pygments/__pycache__/__init__.cpython-38.pyc"},
							{Path: "pygments/util.py", Digest: &pkg.PythonFileDigest{"sha256", "586xXHiJGGZxqk5PMBu3vBhE68DLuAe5MBARWrSPGxA"}, Size: "10778"},

							{Path: "pygments/x_util.py", Digest: &pkg.PythonFileDigest{"sha256", "qpzzsOW31KT955agi-7NS--90I0iNiJCyLJQnRCHgKI="}, Size: "10778"},
						},
						TopLevelPackages: []string{"pygments", "something_else"},
						DirectURLOrigin:  &pkg.PythonDirectURLOriginInfo{URL: "https://github.com/python-test/test.git", VCS: "git", CommitID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
						RequiresPython:   ">=3.5",
					},
				},
				{
					Name:     "requests",
					Version:  "2.22.0",
					PURL:     "pkg:pypi/requests@2.22.0",
					Type:     pkg.PythonPkg,
					Language: pkg.Python,
					Locations: file.NewLocationSet(
						file.NewLocation("egg-name/EGG-INFO/PKG-INFO"),
						file.NewLocation("egg-name/EGG-INFO/RECORD"),
						file.NewLocation("egg-name/EGG-INFO/top_level.txt"),
					),
					Licenses: pkg.NewLicenseSet(
						pkg.NewLicenseFromLocationsWithContext(ctx, "Apache 2.0", file.NewLocation("egg-name/EGG-INFO/PKG-INFO")),
					),
					FoundBy: "python-installed-package-cataloger",
					Metadata: pkg.PythonPackage{
						Name:                 "requests",
						Version:              "2.22.0",
						Platform:             "UNKNOWN",
						Author:               "Kenneth Reitz",
						AuthorEmail:          "me@kennethreitz.org",
						SitePackagesRootPath: "egg-name",
						Files: []pkg.PythonFileRecord{
							{Path: "requests-2.22.0.dist-info/INSTALLER", Digest: &pkg.PythonFileDigest{"sha256", "zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg"}, Size: "4"},
							{Path: "requests/__init__.py", Digest: &pkg.PythonFileDigest{"sha256", "PnKCgjcTq44LaAMzB-7--B2FdewRrE8F_vjZeaG9NhA"}, Size: "3921"},
							{Path: "requests/__pycache__/__version__.cpython-38.pyc"},
							{Path: "requests/__pycache__/utils.cpython-38.pyc"},
							{Path: "requests/__version__.py", Digest: &pkg.PythonFileDigest{"sha256", "Bm-GFstQaFezsFlnmEMrJDe8JNROz9n2XXYtODdvjjc"}, Size: "436"},
							{Path: "requests/utils.py", Digest: &pkg.PythonFileDigest{"sha256", "LtPJ1db6mJff2TJSJWKi7rBpzjPS3mSOrjC9zRhoD3A"}, Size: "30049"},
						},
						TopLevelPackages: []string{"requests"},
						RequiresPython:   ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*",
						ProvidesExtra:    []string{"security", "socks"},
					},
				},
			},
		},
		{
			name:    "detect licenses",
			fixture: "testdata/site-packages/license",
			expectedPackages: []pkg.Package{
				{
					Name:     "pygments",
					Version:  "2.6.1",
					PURL:     "pkg:pypi/pygments@2.6.1?vcs_url=git%2Bhttps%3A%2F%2Fgithub.com%2Fpython-test%2Ftest.git%40aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
					Type:     pkg.PythonPkg,
					Language: pkg.Python,
					Locations: file.NewLocationSet(
						file.NewLocation("with-license-file-declared.dist-info/METADATA"), // the LicenseFile is declared in the METADATA file
						file.NewLocation("with-license-file-declared.dist-info/RECORD"),
						file.NewLocation("with-license-file-declared.dist-info/top_level.txt"),
						file.NewLocation("with-license-file-declared.dist-info/direct_url.json"),
					),
					Licenses: pkg.NewLicenseSet(
						pkg.License{
							Value:          "BSD-3-Clause",
							SPDXExpression: "BSD-3-Clause",
							Type:           "concluded",
							Contents:       mustContentsFromLocation(t, "testdata/site-packages/license/with-license-file-declared.dist-info/LICENSE.txt", 0, 1475),
							// we read the path from the LicenseFile field in the METADATA file, then read the license file directly
							Locations: file.NewLocationSet(file.NewLocation("with-license-file-declared.dist-info/LICENSE.txt")),
						},
					),
					FoundBy: "python-installed-package-cataloger",
					Metadata: pkg.PythonPackage{
						Name:                 "Pygments",
						Version:              "2.6.1",
						Platform:             "any",
						Author:               "Georg Brandl",
						AuthorEmail:          "georg@python.org",
						SitePackagesRootPath: ".",
						Files: []pkg.PythonFileRecord{
							{Path: "../../../bin/pygmentize", Digest: &pkg.PythonFileDigest{"sha256", "dDhv_U2jiCpmFQwIRHpFRLAHUO4R1jIJPEvT_QYTFp8"}, Size: "220"},
							{Path: "with-license-file-declared.dist-info/AUTHORS", Digest: &pkg.PythonFileDigest{"sha256", "PVpa2_Oku6BGuiUvutvuPnWGpzxqFy2I8-NIrqCvqUY"}, Size: "8449"},
							{Path: "with-license-file-declared.dist-info/LICENSE.txt", Digest: &pkg.PythonFileDigest{Algorithm: "sha256", Value: "utiUvpzxqFPVpvuPnWG2_Oku6BGuay2I8-NIrqCvqUY"}, Size: "8449"},
							{Path: "with-license-file-declared.dist-info/RECORD"},
							{Path: "pygments/__pycache__/__init__.cpython-38.pyc"},
							{Path: "pygments/util.py", Digest: &pkg.PythonFileDigest{"sha256", "586xXHiJGGZxqk5PMBu3vBhE68DLuAe5MBARWrSPGxA"}, Size: "10778"},

							{Path: "pygments/x_util.py", Digest: &pkg.PythonFileDigest{"sha256", "qpzzsOW31KT955agi-7NS--90I0iNiJCyLJQnRCHgKI="}, Size: "10778"},
						},
						TopLevelPackages: []string{"pygments", "something_else"},
						DirectURLOrigin:  &pkg.PythonDirectURLOriginInfo{URL: "https://github.com/python-test/test.git", VCS: "git", CommitID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
						RequiresPython:   ">=3.5",
						RequiresDist:     []string{"soupsieve (>1.2)", "html5lib ; extra == 'html5lib'", "lxml ; extra == 'lxml'"},
						ProvidesExtra:    []string{"html5lib", "lxml"},
					},
				},
				{
					Name:     "pygments",
					Version:  "2.6.1",
					PURL:     "pkg:pypi/pygments@2.6.1?vcs_url=git%2Bhttps%3A%2F%2Fgithub.com%2Fpython-test%2Ftest.git%40aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
					Type:     pkg.PythonPkg,
					Language: pkg.Python,
					Locations: file.NewLocationSet(
						file.NewLocation("without-license-file-declared.dist-info/METADATA"), // the LicenseFile is declared in the METADATA file
						file.NewLocation("without-license-file-declared.dist-info/RECORD"),
						file.NewLocation("without-license-file-declared.dist-info/top_level.txt"),
						file.NewLocation("without-license-file-declared.dist-info/direct_url.json"),
					),
					Licenses: pkg.NewLicenseSet(
						pkg.License{
							Value:          "BSD-3-Clause",
							SPDXExpression: "BSD-3-Clause",
							Type:           "concluded",
							Contents:       mustContentsFromLocation(t, "testdata/site-packages/license/with-license-file-declared.dist-info/LICENSE.txt", 0, 1475),
							Locations:      file.NewLocationSet(file.NewLocation("without-license-file-declared.dist-info/LICENSE.txt")),
						},
					),
					FoundBy: "python-installed-package-cataloger",
					Metadata: pkg.PythonPackage{
						Name:                 "Pygments",
						Version:              "2.6.1",
						Platform:             "any",
						Author:               "Georg Brandl",
						AuthorEmail:          "georg@python.org",
						SitePackagesRootPath: ".",
						Files: []pkg.PythonFileRecord{
							{Path: "../../../bin/pygmentize", Digest: &pkg.PythonFileDigest{"sha256", "dDhv_U2jiCpmFQwIRHpFRLAHUO4R1jIJPEvT_QYTFp8"}, Size: "220"},
							{Path: "without-license-file-declared.dist-info/AUTHORS", Digest: &pkg.PythonFileDigest{"sha256", "PVpa2_Oku6BGuiUvutvuPnWGpzxqFy2I8-NIrqCvqUY"}, Size: "8449"},
							{Path: "without-license-file-declared.dist-info/LICENSE.txt", Digest: &pkg.PythonFileDigest{Algorithm: "sha256", Value: "utiUvpzxqFPVpvuPnWG2_Oku6BGuay2I8-NIrqCvqUY"}, Size: "8449"},
							{Path: "without-license-file-declared.dist-info/RECORD"},
							{Path: "pygments/__pycache__/__init__.cpython-38.pyc"},
							{Path: "pygments/util.py", Digest: &pkg.PythonFileDigest{"sha256", "586xXHiJGGZxqk5PMBu3vBhE68DLuAe5MBARWrSPGxA"}, Size: "10778"},

							{Path: "pygments/x_util.py", Digest: &pkg.PythonFileDigest{"sha256", "qpzzsOW31KT955agi-7NS--90I0iNiJCyLJQnRCHgKI="}, Size: "10778"},
						},
						TopLevelPackages: []string{"pygments", "something_else"},
						DirectURLOrigin:  &pkg.PythonDirectURLOriginInfo{URL: "https://github.com/python-test/test.git", VCS: "git", CommitID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
						RequiresPython:   ">=3.5",
						RequiresDist:     []string{"soupsieve (>1.2)", "html5lib ; extra == 'html5lib'", "lxml ; extra == 'lxml'"},
						ProvidesExtra:    []string{"html5lib", "lxml"},
					},
				},
			},
		},
		{
			name:    "malformed-record",
			fixture: "testdata/site-packages/malformed-record",
			expectedPackages: []pkg.Package{
				{
					Name:     "pygments",
					Version:  "2.6.1",
					PURL:     "pkg:pypi/pygments@2.6.1",
					Type:     pkg.PythonPkg,
					Language: pkg.Python,
					Locations: file.NewLocationSet(
						file.NewLocation("dist-info/METADATA"),
						file.NewLocation("dist-info/RECORD"),
					),
					Licenses: pkg.NewLicenseSet(
						pkg.NewLicenseFromLocationsWithContext(ctx, "BSD License", file.NewLocation("dist-info/METADATA")),
					),
					FoundBy: "python-installed-package-cataloger",
					Metadata: pkg.PythonPackage{
						Name:                 "Pygments",
						Version:              "2.6.1",
						Platform:             "any",
						Author:               "Georg Brandl",
						AuthorEmail:          "georg@python.org",
						SitePackagesRootPath: ".",
						Files: []pkg.PythonFileRecord{
							{Path: "flask/json/tag.py", Digest: &pkg.PythonFileDigest{"sha256", "9ehzrmt5k7hxf7ZEK0NOs3swvQyU9fWNe-pnYe69N60"}, Size: "8223"},
							{Path: "../../Scripts/flask.exe", Digest: &pkg.PythonFileDigest{"sha256", "mPrbVeZCDX20himZ_bRai1nCs_tgr7jHIOGZlcgn-T4"}, Size: "93063"},
							{Path: "../../Scripts/flask.exe", Size: "89470", Digest: &pkg.PythonFileDigest{"sha256", "jvqh4N3qOqXLlq40i6ZOLCY9tAOwfwdzIpLDYhRjoqQ"}},
							{Path: "Flask-1.0.2.dist-info/INSTALLER", Size: "4", Digest: &pkg.PythonFileDigest{"sha256", "zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg"}},
						},
						RequiresPython: ">=3.5",
					},
				},
			},
		},
		{
			// in cases where the metadata file is available and the record is not we should still record there is a package
			// additionally empty top_level.txt files should not result in an error
			name:    "partial dist-info directory",
			fixture: "testdata/site-packages/partial.dist-info",
			expectedPackages: []pkg.Package{
				{
					Name:     "pygments",
					Version:  "2.6.1",
					PURL:     "pkg:pypi/pygments@2.6.1",
					Type:     pkg.PythonPkg,
					Language: pkg.Python,
					Locations: file.NewLocationSet(
						file.NewLocation("METADATA"),
					),
					Licenses: pkg.NewLicenseSet(
						pkg.NewLicenseFromLocationsWithContext(ctx, "BSD License", file.NewLocation("METADATA")),
					),
					FoundBy: "python-installed-package-cataloger",
					Metadata: pkg.PythonPackage{
						Name:                 "Pygments",
						Version:              "2.6.1",
						Platform:             "any",
						Author:               "Georg Brandl",
						AuthorEmail:          "georg@python.org",
						SitePackagesRootPath: ".",
						RequiresPython:       ">=3.5",
					},
				},
			},
		},
		{
			name:    "egg-info regular file",
			fixture: "testdata/site-packages/test",
			expectedPackages: []pkg.Package{
				{
					Name:     "requests",
					Version:  "2.22.0",
					PURL:     "pkg:pypi/requests@2.22.0",
					Type:     pkg.PythonPkg,
					Language: pkg.Python,
					Locations: file.NewLocationSet(
						file.NewLocation("test.egg-info"),
					),
					Licenses: pkg.NewLicenseSet(
						pkg.NewLicenseFromLocationsWithContext(ctx, "Apache 2.0", file.NewLocation("test.egg-info")),
					),
					FoundBy: "python-installed-package-cataloger",
					Metadata: pkg.PythonPackage{
						Name:                 "requests",
						Version:              "2.22.0",
						Platform:             "UNKNOWN",
						Author:               "Kenneth Reitz",
						AuthorEmail:          "me@kennethreitz.org",
						SitePackagesRootPath: ".",
						RequiresPython:       ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*",
						ProvidesExtra:        []string{"security", "socks"},
					},
				},
			},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			pkgtest.NewCatalogTester().
				FromDirectory(t, test.fixture).
				Expects(test.expectedPackages, nil).
				TestCataloger(t, NewInstalledPackageCataloger())
		})
	}
}

func Test_PackageCataloger(t *testing.T) {

	tests := []struct {
		name                  string
		fixture               string
		expectedPackages      []string
		expectedRelationships []string
	}{
		{
			name:    "pdm",
			fixture: "testdata/pdm-lock",
			expectedPackages: []string{
				"certifi @ 2025.1.31 (pdm.lock)",
				"chardet @ 3.0.4 (pdm.lock)",
				"charset-normalizer @ 2.0.12 (pdm.lock)",
				"colorama @ 0.3.9 (pdm.lock)",
				"idna @ 2.7 (pdm.lock)",
				"py @ 1.4.34 (pdm.lock)",
				"pytest @ 3.2.5 (pdm.lock)",
				"requests @ 2.27.1 (pdm.lock)",
				"setuptools @ 39.2.0 (pdm.lock)",
				"urllib3 @ 1.26.20 (pdm.lock)",
			},
			expectedRelationships: []string{
				"certifi @ 2025.1.31 (pdm.lock) [dependency-of] requests @ 2.27.1 (pdm.lock)",
				"chardet @ 3.0.4 (pdm.lock) [dependency-of] requests @ 2.27.1 (pdm.lock)",
				"charset-normalizer @ 2.0.12 (pdm.lock) [dependency-of] requests @ 2.27.1 (pdm.lock)",
				"colorama @ 0.3.9 (pdm.lock) [dependency-of] pytest @ 3.2.5 (pdm.lock)",
				"idna @ 2.7 (pdm.lock) [dependency-of] requests @ 2.27.1 (pdm.lock)",
				"py @ 1.4.34 (pdm.lock) [dependency-of] pytest @ 3.2.5 (pdm.lock)",
				"setuptools @ 39.2.0 (pdm.lock) [dependency-of] pytest @ 3.2.5 (pdm.lock)",
				"urllib3 @ 1.26.20 (pdm.lock) [dependency-of] requests @ 2.27.1 (pdm.lock)",
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			pkgtest.NewCatalogTester().
				FromDirectory(t, tt.fixture).
				ExpectsPackageStrings(tt.expectedPackages).
				ExpectsRelationshipStrings(tt.expectedRelationships).
				TestCataloger(t, NewPackageCataloger(DefaultCatalogerConfig()))
		})
	}
}

func Test_PackageCataloger_IgnorePackage(t *testing.T) {
	tests := []struct {
		MetadataFixture string
	}{
		{
			MetadataFixture: "testdata/Python-2.7.egg-info",
		},
		{
			MetadataFixture: "testdata/empty-1.0.0-py3.8.egg-info",
		},
	}

	for _, test := range tests {
		t.Run(test.MetadataFixture, func(t *testing.T) {
			resolver := file.NewMockResolverForPaths(test.MetadataFixture)

			actual, _, err := NewInstalledPackageCataloger().Catalog(pkgtest.Context(t), resolver)
			require.NoError(t, err)

			if len(actual) != 0 {
				t.Fatalf("Expected 0 packages but found: %d", len(actual))
			}
		})
	}
}

func Test_IndexCataloger_Globs(t *testing.T) {
	tests := []struct {
		name     string
		fixture  string
		expected []string
	}{
		{
			name:    "obtain index files",
			fixture: "testdata/glob-paths",
			expected: []string{
				"src/requirements.txt",
				"src/extra-requirements.txt",
				"src/requirements-dev.txt",
				"src/1-requirements-dev.txt",
				"src/setup.py",
				"src/poetry.lock",
				"src/Pipfile.lock",
				"src/uv.lock",
				"src/script.py.lock",
				"src/pdm.lock",
			},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			pkgtest.NewCatalogTester().
				FromDirectory(t, test.fixture).
				ExpectsResolverContentQueries(test.expected).
				TestCataloger(t, NewPackageCataloger(DefaultCatalogerConfig()))
		})
	}
}

func Test_PackageCataloger_Globs(t *testing.T) {
	tests := []struct {
		name     string
		fixture  string
		expected []string
	}{
		{
			name:    "obtain index files",
			fixture: "testdata/glob-paths",
			expected: []string{
				"site-packages/v.DIST-INFO/METADATA",
				"site-packages/w.EGG-INFO/PKG-INFO",
				"site-packages/x.dist-info/METADATA",
				"site-packages/y.egg-info/PKG-INFO",
				"site-packages/z.egg-info",
			},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			pkgtest.NewCatalogTester().
				FromDirectory(t, test.fixture).
				ExpectsResolverContentQueries(test.expected).
				IgnoreUnfulfilledPathResponses("**/pyvenv.cfg").
				TestCataloger(t, NewInstalledPackageCataloger())
		})
	}
}

func Test_PackageCataloger_Relationships(t *testing.T) {
	tests := []struct {
		name                  string
		fixture               string
		expectedRelationships []string
	}{
		{
			name:                  "poetry - no dependencies",
			fixture:               "testdata/poetry/dev-deps",
			expectedRelationships: nil,
		},
		{
			name:    "poetry - simple dependencies",
			fixture: "testdata/poetry/simple-deps",
			expectedRelationships: []string{
				"certifi @ 2024.2.2 (.) [dependency-of] requests @ 2.32.2 (.)",
				"charset-normalizer @ 3.3.2 (.) [dependency-of] requests @ 2.32.2 (.)",
				"idna @ 3.7 (.) [dependency-of] requests @ 2.32.2 (.)",
				"urllib3 @ 2.2.1 (.) [dependency-of] requests @ 2.32.2 (.)",
			},
		},
		{
			name:    "poetry - multiple extras",
			fixture: "testdata/poetry/multiple-extras",
			expectedRelationships: []string{
				"anyio @ 4.3.0 (.) [dependency-of] anyio @ 4.3.0 (.)",
				"anyio @ 4.3.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
				"anyio @ 4.3.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"brotli @ 1.1.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"brotlicffi @ 1.1.0.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"certifi @ 2024.2.2 (.) [dependency-of] httpcore @ 1.0.5 (.)",
				"certifi @ 2024.2.2 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"cffi @ 1.16.0 (.) [dependency-of] brotlicffi @ 1.1.0.0 (.)",
				"h11 @ 0.14.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
				"h2 @ 4.1.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
				"h2 @ 4.1.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"hpack @ 4.0.0 (.) [dependency-of] h2 @ 4.1.0 (.)",
				"httpcore @ 1.0.5 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"hyperframe @ 6.0.1 (.) [dependency-of] h2 @ 4.1.0 (.)",
				"idna @ 3.7 (.) [dependency-of] anyio @ 4.3.0 (.)",
				"idna @ 3.7 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"pycparser @ 2.22 (.) [dependency-of] cffi @ 1.16.0 (.)",
				"sniffio @ 1.3.1 (.) [dependency-of] anyio @ 4.3.0 (.)",
				"sniffio @ 1.3.1 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"socksio @ 1.0.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
				"socksio @ 1.0.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
			},
		},
		{
			name:    "poetry - nested extras",
			fixture: "testdata/poetry/nested-extras",
			expectedRelationships: []string{
				"annotated-types @ 0.7.0 (.) [dependency-of] pydantic @ 2.7.1 (.)",
				"anyio @ 4.3.0 (.) [dependency-of] anyio @ 4.3.0 (.)",
				"anyio @ 4.3.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
				"anyio @ 4.3.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"anyio @ 4.3.0 (.) [dependency-of] starlette @ 0.37.2 (.)",
				"anyio @ 4.3.0 (.) [dependency-of] watchfiles @ 0.21.0 (.)",
				"certifi @ 2024.2.2 (.) [dependency-of] httpcore @ 1.0.5 (.)",
				"certifi @ 2024.2.2 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"click @ 8.1.7 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"click @ 8.1.7 (.) [dependency-of] python-dotenv @ 1.0.1 (.)",
				"click @ 8.1.7 (.) [dependency-of] typer @ 0.12.3 (.)",
				"click @ 8.1.7 (.) [dependency-of] uvicorn @ 0.29.0 (.)",
				"colorama @ 0.4.6 (.) [dependency-of] click @ 8.1.7 (.)",
				"colorama @ 0.4.6 (.) [dependency-of] pygments @ 2.18.0 (.)",
				"colorama @ 0.4.6 (.) [dependency-of] uvicorn @ 0.29.0 (.)", // proof of uvicorn[standard]
				"dnspython @ 2.6.1 (.) [dependency-of] email-validator @ 2.1.1 (.)",
				"email-validator @ 2.1.1 (.) [dependency-of] fastapi @ 0.111.0 (.)",
				"email-validator @ 2.1.1 (.) [dependency-of] pydantic @ 2.7.1 (.)",
				"fastapi @ 0.111.0 (.) [dependency-of] fastapi-cli @ 0.0.4 (.)",
				"fastapi-cli @ 0.0.4 (.) [dependency-of] fastapi @ 0.111.0 (.)",
				"h11 @ 0.14.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
				"h11 @ 0.14.0 (.) [dependency-of] uvicorn @ 0.29.0 (.)",
				"httpcore @ 1.0.5 (.) [dependency-of] dnspython @ 2.6.1 (.)",
				"httpcore @ 1.0.5 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"httptools @ 0.6.1 (.) [dependency-of] uvicorn @ 0.29.0 (.)", // proof of uvicorn[standard]
				"httpx @ 0.27.0 (.) [dependency-of] dnspython @ 2.6.1 (.)",
				"httpx @ 0.27.0 (.) [dependency-of] fastapi @ 0.111.0 (.)",
				"httpx @ 0.27.0 (.) [dependency-of] starlette @ 0.37.2 (.)",
				"idna @ 3.7 (.) [dependency-of] anyio @ 4.3.0 (.)",
				"idna @ 3.7 (.) [dependency-of] dnspython @ 2.6.1 (.)",
				"idna @ 3.7 (.) [dependency-of] email-validator @ 2.1.1 (.)",
				"idna @ 3.7 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"itsdangerous @ 2.2.0 (.) [dependency-of] fastapi @ 0.111.0 (.)",
				"itsdangerous @ 2.2.0 (.) [dependency-of] starlette @ 0.37.2 (.)",
				"jinja2 @ 3.1.4 (.) [dependency-of] fastapi @ 0.111.0 (.)",
				"jinja2 @ 3.1.4 (.) [dependency-of] starlette @ 0.37.2 (.)",
				"markdown-it-py @ 3.0.0 (.) [dependency-of] rich @ 13.7.1 (.)",
				"markupsafe @ 2.1.5 (.) [dependency-of] jinja2 @ 3.1.4 (.)", // MarkupSafe (mixed case) -> markupsafe
				"mdurl @ 0.1.2 (.) [dependency-of] markdown-it-py @ 3.0.0 (.)",
				"orjson @ 3.10.3 (.) [dependency-of] fastapi @ 0.111.0 (.)",
				"pydantic @ 2.7.1 (.) [dependency-of] fastapi @ 0.111.0 (.)",
				"pydantic @ 2.7.1 (.) [dependency-of] pydantic-extra-types @ 2.7.0 (.)",
				"pydantic @ 2.7.1 (.) [dependency-of] pydantic-settings @ 2.2.1 (.)",
				"pydantic-core @ 2.18.2 (.) [dependency-of] pydantic @ 2.7.1 (.)",
				"pydantic-extra-types @ 2.7.0 (.) [dependency-of] fastapi @ 0.111.0 (.)",
				"pydantic-settings @ 2.2.1 (.) [dependency-of] fastapi @ 0.111.0 (.)",
				"pygments @ 2.18.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"pygments @ 2.18.0 (.) [dependency-of] rich @ 13.7.1 (.)",
				"python-dotenv @ 1.0.1 (.) [dependency-of] pydantic-settings @ 2.2.1 (.)",
				"python-dotenv @ 1.0.1 (.) [dependency-of] uvicorn @ 0.29.0 (.)", // proof of uvicorn[standard]
				"python-multipart @ 0.0.9 (.) [dependency-of] fastapi @ 0.111.0 (.)",
				"python-multipart @ 0.0.9 (.) [dependency-of] starlette @ 0.37.2 (.)",
				"pyyaml @ 6.0.1 (.) [dependency-of] fastapi @ 0.111.0 (.)",
				"pyyaml @ 6.0.1 (.) [dependency-of] markdown-it-py @ 3.0.0 (.)",
				"pyyaml @ 6.0.1 (.) [dependency-of] pydantic-settings @ 2.2.1 (.)",
				"pyyaml @ 6.0.1 (.) [dependency-of] python-multipart @ 0.0.9 (.)",
				"pyyaml @ 6.0.1 (.) [dependency-of] starlette @ 0.37.2 (.)",
				"pyyaml @ 6.0.1 (.) [dependency-of] uvicorn @ 0.29.0 (.)", // proof of uvicorn[standard]
				"rich @ 13.7.1 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"rich @ 13.7.1 (.) [dependency-of] typer @ 0.12.3 (.)",
				"shellingham @ 1.5.4 (.) [dependency-of] typer @ 0.12.3 (.)",
				"sniffio @ 1.3.1 (.) [dependency-of] anyio @ 4.3.0 (.)",
				"sniffio @ 1.3.1 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"starlette @ 0.37.2 (.) [dependency-of] fastapi @ 0.111.0 (.)",
				"typer @ 0.12.3 (.) [dependency-of] fastapi-cli @ 0.0.4 (.)",
				"typing-extensions @ 4.12.0 (.) [dependency-of] fastapi @ 0.111.0 (.)",
				"typing-extensions @ 4.12.0 (.) [dependency-of] pydantic @ 2.7.1 (.)",
				"typing-extensions @ 4.12.0 (.) [dependency-of] pydantic-core @ 2.18.2 (.)",
				"typing-extensions @ 4.12.0 (.) [dependency-of] typer @ 0.12.3 (.)",
				"ujson @ 5.10.0 (.) [dependency-of] fastapi @ 0.111.0 (.)",
				"uvicorn @ 0.29.0 (.) [dependency-of] fastapi @ 0.111.0 (.)",
				"uvicorn @ 0.29.0 (.) [dependency-of] fastapi-cli @ 0.0.4 (.)",
				"uvloop @ 0.19.0 (.) [dependency-of] anyio @ 4.3.0 (.)",
				"uvloop @ 0.19.0 (.) [dependency-of] uvicorn @ 0.29.0 (.)",     // proof of uvicorn[standard]
				"watchfiles @ 0.21.0 (.) [dependency-of] uvicorn @ 0.29.0 (.)", // proof of uvicorn[standard]
				"websockets @ 12.0 (.) [dependency-of] uvicorn @ 0.29.0 (.)",   // proof of uvicorn[standard]
			},
		},
		{
			name:    "poetry - conflicting extras",
			fixture: "testdata/poetry/conflicting-with-extras",
			expectedRelationships: []string{
				"anyio @ 4.3.0 (.) [dependency-of] anyio @ 4.3.0 (.)",
				"anyio @ 4.3.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
				"anyio @ 4.3.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"brotli @ 1.1.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"brotlicffi @ 1.1.0.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"certifi @ 2024.2.2 (.) [dependency-of] httpcore @ 1.0.5 (.)",
				"certifi @ 2024.2.2 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"cffi @ 1.16.0 (.) [dependency-of] brotlicffi @ 1.1.0.0 (.)",
				"colorama @ 0.4.6 (.) [dependency-of] rich @ 0.3.3 (.)",
				"h11 @ 0.14.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
				"h2 @ 4.1.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
				"h2 @ 4.1.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"hpack @ 4.0.0 (.) [dependency-of] h2 @ 4.1.0 (.)",
				"httpcore @ 1.0.5 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"hyperframe @ 6.0.1 (.) [dependency-of] h2 @ 4.1.0 (.)",
				"idna @ 3.7 (.) [dependency-of] anyio @ 4.3.0 (.)",
				"idna @ 3.7 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"pprintpp @ 0.4.0 (.) [dependency-of] rich @ 0.3.3 (.)",
				"pycparser @ 2.22 (.) [dependency-of] cffi @ 1.16.0 (.)",
				"sniffio @ 1.3.1 (.) [dependency-of] anyio @ 4.3.0 (.)",
				"sniffio @ 1.3.1 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"socksio @ 1.0.0 (.) [dependency-of] httpcore @ 1.0.5 (.)",
				"socksio @ 1.0.0 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"typing-extensions @ 3.10.0.2 (.) [dependency-of] rich @ 0.3.3 (.)",

				// ideally we should NOT see these dependencies. However, they are technically installed in the environment
				// and an import is present in httpx for each of these, so in theory they are actually dependencies even
				// though our pyproject.toml looks like this:
				//
				//     [tool.poetry.dependencies]
				//     python = "^3.11"
				//     httpx = {extras = ["brotli", "http2", "socks"], version = "^0.27.0"}
				//     pygments = "1.6"
				//     click = "<8"
				//     rich = "<10"
				//
				// note that pygments, click, and rich are installed outside of the allowable ranges for the given
				// httpx package version constraints, per the poetry.lock:
				//
				//     # for package httpx
				//     [package.extras]
				//     cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
				//
				// note: the pyproject.toml and poetry.lock state are consistent with each other (just with
				// "poetry install" and "poetry lock", and nothing was forced!)
				"click @ 7.1.2 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"pygments @ 1.6 (.) [dependency-of] httpx @ 0.27.0 (.)",
				"rich @ 0.3.3 (.) [dependency-of] httpx @ 0.27.0 (.)",
			},
		},
		{
			name:    "uv - simple dependencies",
			fixture: "testdata/uv/simple-deps",
			expectedRelationships: []string{
				"certifi @ 2025.1.31 (.) [dependency-of] requests @ 2.32.3 (.)",
				"charset-normalizer @ 3.4.1 (.) [dependency-of] requests @ 2.32.3 (.)",
				"idna @ 3.10 (.) [dependency-of] requests @ 2.32.3 (.)",
				"requests @ 2.32.3 (.) [dependency-of] testpkg @ 0.1.0 (.)",
				"urllib3 @ 2.3.0 (.) [dependency-of] requests @ 2.32.3 (.)",
			},
		},
		{
			name:    "uv - multiple extras",
			fixture: "testdata/uv/multiple-extras",
			expectedRelationships: []string{
				"anyio @ 4.9.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"brotli @ 1.1.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"brotlicffi @ 1.1.0.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"certifi @ 2025.1.31 (.) [dependency-of] httpcore @ 1.0.7 (.)",
				"certifi @ 2025.1.31 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"cffi @ 1.17.1 (.) [dependency-of] brotlicffi @ 1.1.0.0 (.)",
				"h11 @ 0.14.0 (.) [dependency-of] httpcore @ 1.0.7 (.)",
				"h2 @ 4.2.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"hpack @ 4.1.0 (.) [dependency-of] h2 @ 4.2.0 (.)",
				"httpcore @ 1.0.7 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"httpx @ 0.28.1 (.) [dependency-of] testpkg @ 0.1.0 (.)",
				"hyperframe @ 6.1.0 (.) [dependency-of] h2 @ 4.2.0 (.)",
				"idna @ 3.10 (.) [dependency-of] anyio @ 4.9.0 (.)",
				"idna @ 3.10 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"pycparser @ 2.22 (.) [dependency-of] cffi @ 1.17.1 (.)",
				"sniffio @ 1.3.1 (.) [dependency-of] anyio @ 4.9.0 (.)",
				"socksio @ 1.0.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"typing-extensions @ 4.13.0 (.) [dependency-of] anyio @ 4.9.0 (.)",
			},
		},
		{
			name:    "uv - nested extras",
			fixture: "testdata/uv/nested-extras",
			expectedRelationships: []string{
				"annotated-types @ 0.7.0 (.) [dependency-of] pydantic @ 2.11.0 (.)",
				"anyio @ 4.9.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"anyio @ 4.9.0 (.) [dependency-of] starlette @ 0.37.2 (.)",
				"anyio @ 4.9.0 (.) [dependency-of] watchfiles @ 1.0.4 (.)",
				"certifi @ 2025.1.31 (.) [dependency-of] httpcore @ 1.0.7 (.)",
				"certifi @ 2025.1.31 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"click @ 8.1.8 (.) [dependency-of] rich-toolkit @ 0.14.0 (.)",
				"click @ 8.1.8 (.) [dependency-of] typer @ 0.15.2 (.)",
				"click @ 8.1.8 (.) [dependency-of] uvicorn @ 0.34.0 (.)",
				"colorama @ 0.4.6 (.) [dependency-of] click @ 8.1.8 (.)",
				"colorama @ 0.4.6 (.) [dependency-of] uvicorn @ 0.34.0 (.)", // proof of uvicorn[standard]
				"dnspython @ 2.7.0 (.) [dependency-of] email-validator @ 2.2.0 (.)",
				"email-validator @ 2.2.0 (.) [dependency-of] fastapi @ 0.111.1 (.)",
				"fastapi @ 0.111.1 (.) [dependency-of] testpkg @ 0.1.0 (.)",
				"fastapi-cli @ 0.0.7 (.) [dependency-of] fastapi @ 0.111.1 (.)",
				"h11 @ 0.14.0 (.) [dependency-of] httpcore @ 1.0.7 (.)",
				"h11 @ 0.14.0 (.) [dependency-of] uvicorn @ 0.34.0 (.)",
				"httpcore @ 1.0.7 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"httptools @ 0.6.4 (.) [dependency-of] uvicorn @ 0.34.0 (.)", // proof of uvicorn[standard]
				"httpx @ 0.28.1 (.) [dependency-of] fastapi @ 0.111.1 (.)",
				"idna @ 3.10 (.) [dependency-of] anyio @ 4.9.0 (.)",
				"idna @ 3.10 (.) [dependency-of] email-validator @ 2.2.0 (.)",
				"idna @ 3.10 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"itsdangerous @ 2.2.0 (.) [dependency-of] fastapi @ 0.111.1 (.)",
				"jinja2 @ 3.1.6 (.) [dependency-of] fastapi @ 0.111.1 (.)",
				"markdown-it-py @ 3.0.0 (.) [dependency-of] rich @ 13.9.4 (.)",
				"markupsafe @ 3.0.2 (.) [dependency-of] jinja2 @ 3.1.6 (.)",
				"mdurl @ 0.1.2 (.) [dependency-of] markdown-it-py @ 3.0.0 (.)",
				"orjson @ 3.10.16 (.) [dependency-of] fastapi @ 0.111.1 (.)",
				"pydantic @ 2.11.0 (.) [dependency-of] fastapi @ 0.111.1 (.)",
				"pydantic @ 2.11.0 (.) [dependency-of] pydantic-extra-types @ 2.10.3 (.)",
				"pydantic @ 2.11.0 (.) [dependency-of] pydantic-settings @ 2.8.1 (.)",
				"pydantic-core @ 2.33.0 (.) [dependency-of] pydantic @ 2.11.0 (.)",
				"pydantic-extra-types @ 2.10.3 (.) [dependency-of] fastapi @ 0.111.1 (.)",
				"pydantic-settings @ 2.8.1 (.) [dependency-of] fastapi @ 0.111.1 (.)",
				"pygments @ 2.19.1 (.) [dependency-of] rich @ 13.9.4 (.)",
				"python-dotenv @ 1.1.0 (.) [dependency-of] pydantic-settings @ 2.8.1 (.)",
				"python-dotenv @ 1.1.0 (.) [dependency-of] uvicorn @ 0.34.0 (.)", // proof of uvicorn[standard]
				"python-multipart @ 0.0.20 (.) [dependency-of] fastapi @ 0.111.1 (.)",
				"pyyaml @ 6.0.2 (.) [dependency-of] fastapi @ 0.111.1 (.)",
				"pyyaml @ 6.0.2 (.) [dependency-of] uvicorn @ 0.34.0 (.)", // proof of uvicorn[standard]
				"rich @ 13.9.4 (.) [dependency-of] rich-toolkit @ 0.14.0 (.)",
				"rich @ 13.9.4 (.) [dependency-of] typer @ 0.15.2 (.)",
				"rich-toolkit @ 0.14.0 (.) [dependency-of] fastapi-cli @ 0.0.7 (.)",
				"shellingham @ 1.5.4 (.) [dependency-of] typer @ 0.15.2 (.)",
				"sniffio @ 1.3.1 (.) [dependency-of] anyio @ 4.9.0 (.)",
				"starlette @ 0.37.2 (.) [dependency-of] fastapi @ 0.111.1 (.)",
				"typer @ 0.15.2 (.) [dependency-of] fastapi-cli @ 0.0.7 (.)",
				"typing-extensions @ 4.13.0 (.) [dependency-of] anyio @ 4.9.0 (.)",
				"typing-extensions @ 4.13.0 (.) [dependency-of] fastapi @ 0.111.1 (.)",
				"typing-extensions @ 4.13.0 (.) [dependency-of] pydantic @ 2.11.0 (.)",
				"typing-extensions @ 4.13.0 (.) [dependency-of] pydantic-core @ 2.33.0 (.)",
				"typing-extensions @ 4.13.0 (.) [dependency-of] pydantic-extra-types @ 2.10.3 (.)",
				"typing-extensions @ 4.13.0 (.) [dependency-of] rich-toolkit @ 0.14.0 (.)",
				"typing-extensions @ 4.13.0 (.) [dependency-of] typer @ 0.15.2 (.)",
				"typing-extensions @ 4.13.0 (.) [dependency-of] typing-inspection @ 0.4.0 (.)",
				"typing-inspection @ 0.4.0 (.) [dependency-of] pydantic @ 2.11.0 (.)",
				"ujson @ 5.10.0 (.) [dependency-of] fastapi @ 0.111.1 (.)",
				"uvicorn @ 0.34.0 (.) [dependency-of] fastapi @ 0.111.1 (.)",
				"uvicorn @ 0.34.0 (.) [dependency-of] fastapi-cli @ 0.0.7 (.)",
				"uvloop @ 0.21.0 (.) [dependency-of] uvicorn @ 0.34.0 (.)",     // proof of uvicorn[standard]
				"watchfiles @ 1.0.4 (.) [dependency-of] uvicorn @ 0.34.0 (.)",  // proof of uvicorn[standard]
				"websockets @ 15.0.1 (.) [dependency-of] uvicorn @ 0.34.0 (.)", // proof of uvicorn[standard]
			},
		},
		{
			name:    "uv - conflicting extras",
			fixture: "testdata/uv/conflicting-with-extras",
			expectedRelationships: []string{
				"anyio @ 4.6.2.post1 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"brotli @ 1.1.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"brotlicffi @ 1.1.0.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"certifi @ 2025.1.31 (.) [dependency-of] httpcore @ 1.0.7 (.)",
				"certifi @ 2025.1.31 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"cffi @ 1.17.1 (.) [dependency-of] brotlicffi @ 1.1.0.0 (.)",
				"colorama @ 0.4.6 (.) [dependency-of] rich @ 0.3.3 (.)",
				"h11 @ 0.14.0 (.) [dependency-of] httpcore @ 1.0.7 (.)",
				"h2 @ 4.2.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"hpack @ 4.1.0 (.) [dependency-of] h2 @ 4.2.0 (.)",
				"httpcore @ 1.0.7 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"httpx @ 0.28.1 (.) [dependency-of] testpkg @ 0.1.0 (.)",
				"hyperframe @ 6.1.0 (.) [dependency-of] h2 @ 4.2.0 (.)",
				"idna @ 3.10 (.) [dependency-of] anyio @ 4.6.2.post1 (.)",
				"idna @ 3.10 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"pprintpp @ 0.4.0 (.) [dependency-of] rich @ 0.3.3 (.)",
				"pycparser @ 2.22 (.) [dependency-of] cffi @ 1.17.1 (.)",
				"sniffio @ 1.3.1 (.) [dependency-of] anyio @ 4.6.2.post1 (.)",
				"socksio @ 1.0.0 (.) [dependency-of] httpx @ 0.28.1 (.)",
				"typing-extensions @ 3.10.0.2 (.) [dependency-of] rich @ 0.3.3 (.)",
				// ideally we should NOT see these dependencies. However, they are technically installed in the environment
				// and an import is present in httpx for each of these, so in theory they are actually dependencies even
				// though our pyproject.toml looks like this:
				//
				//     [tool.poetry.dependencies]
				//     python = "^3.11"
				//     httpx = {extras = ["brotli", "http2", "socks"], version = "^0.27.0"}
				//     pygments = "1.6"
				//     click = "<8"
				//     rich = "<10"
				//
				// note that pygments, click, and rich are installed outside of the allowable ranges for the given
				// httpx package version constraints, per the poetry.lock:
				//
				//     # for package httpx
				//     [package.extras]
				//     cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
				//
				// note: the pyproject.toml and uv.lock state are consistent with each other (just with
				// "uv sync" and nothing was forced!)
				"click @ 7.1.2 (.) [dependency-of] testpkg @ 0.1.0 (.)",
				"pygments @ 1.6 (.) [dependency-of] testpkg @ 0.1.0 (.)",
				"rich @ 0.3.3 (.) [dependency-of] testpkg @ 0.1.0 (.)",
			},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			pkgtest.NewCatalogTester().
				FromDirectory(t, test.fixture).
				WithPackageStringer(stringPackage).
				ExpectsRelationshipStrings(test.expectedRelationships).
				TestCataloger(t, NewPackageCataloger(DefaultCatalogerConfig()))
		})
	}
}

func Test_PackageCataloger_SitePackageRelationships(t *testing.T) {
	tests := []struct {
		name                  string
		fixture               string
		expectedRelationships []string
	}{
		{
			name:    "with multiple python installations and virtual envs",
			fixture: "image-multi-site-package",
			expectedRelationships: []string{
				// purely python 3.9 dist-packages
				//
				// in the container, you can get a sense for dependencies with :
				//   $ python3.9 -m pip list | tail -n +3 | awk '{print $1}' | xargs python3.9 -m pip show | grep -e 'Name:' -e 'Requires:' -e '\-\-\-'
				//
				// which approximates to (all in system packages):
				//
				// - beautifulsoup4: soupsieve
				// - requests: certifi, chardet, idna, urllib3
				// - blessed: six, wcwidth
				// - virtualenv: distlib, filelock, platformdirs
				"certifi @ 2020.12.5 (/usr/local/lib/python3.9/dist-packages) [dependency-of] requests @ 2.25.0 (/usr/local/lib/python3.9/dist-packages)",
				"certifi @ 2020.12.5 (/usr/local/lib/python3.9/dist-packages) [dependency-of] urllib3 @ 1.26.18 (/usr/local/lib/python3.9/dist-packages)", // available when extra == "secure", but another dependency is primarily installing it
				"chardet @ 3.0.4 (/usr/local/lib/python3.9/dist-packages) [dependency-of] requests @ 2.25.0 (/usr/local/lib/python3.9/dist-packages)",
				"distlib @ 0.3.9 (/usr/local/lib/python3.9/dist-packages) [dependency-of] virtualenv @ 20.31.2 (/usr/local/lib/python3.9/dist-packages)",
				"filelock @ 3.18.0 (/usr/local/lib/python3.9/dist-packages) [dependency-of] virtualenv @ 20.31.2 (/usr/local/lib/python3.9/dist-packages)",
				"idna @ 2.10 (/usr/local/lib/python3.9/dist-packages) [dependency-of] requests @ 2.25.0 (/usr/local/lib/python3.9/dist-packages)",
				"idna @ 2.10 (/usr/local/lib/python3.9/dist-packages) [dependency-of] urllib3 @ 1.26.18 (/usr/local/lib/python3.9/dist-packages)", // available when extra == "secure", but another dependency is primarily installing it
				"platformdirs @ 4.3.8 (/usr/local/lib/python3.9/dist-packages) [dependency-of] virtualenv @ 20.31.2 (/usr/local/lib/python3.9/dist-packages)",
				"six @ 1.16.0 (/usr/local/lib/python3.9/dist-packages) [dependency-of] blessed @ 1.20.0 (/usr/local/lib/python3.9/dist-packages)",
				"soupsieve @ 2.2.1 (/usr/local/lib/python3.9/dist-packages) [dependency-of] beautifulsoup4 @ 4.9.3 (/usr/local/lib/python3.9/dist-packages)",
				"urllib3 @ 1.26.18 (/usr/local/lib/python3.9/dist-packages) [dependency-of] requests @ 2.25.0 (/usr/local/lib/python3.9/dist-packages)",
				"virtualenv @ 20.31.2 (/usr/local/lib/python3.9/dist-packages) [dependency-of] filelock @ 3.18.0 (/usr/local/lib/python3.9/dist-packages)", // available when extra == "testing", but we are installing it
				"wcwidth @ 0.2.13 (/usr/local/lib/python3.9/dist-packages) [dependency-of] blessed @ 1.20.0 (/usr/local/lib/python3.9/dist-packages)",

				// purely python 3.8 dist-packages
				//
				// in the container, you can get a sense for dependencies with :
				//   $ python3.8 -m pip list | tail -n +3 | awk '{print $1}' | xargs python3.8 -m pip show | grep -e 'Name:' -e 'Requires:' -e '\-\-\-'
				//
				// which approximates to (all in system packages):
				//
				// - beautifulsoup4: soupsieve
				// - requests: certifi, chardet, idna, urllib3
				// - runs: xmod
				// - virtualenv: distlib, filelock, platformdirs
				"certifi @ 2020.12.5 (/usr/local/lib/python3.8/dist-packages) [dependency-of] requests @ 2.25.0 (/usr/local/lib/python3.8/dist-packages)",
				"certifi @ 2020.12.5 (/usr/local/lib/python3.8/dist-packages) [dependency-of] urllib3 @ 1.26.18 (/usr/local/lib/python3.8/dist-packages)", // available when extra == "secure", but another dependency is primarily installing it
				"chardet @ 3.0.4 (/usr/local/lib/python3.8/dist-packages) [dependency-of] requests @ 2.25.0 (/usr/local/lib/python3.8/dist-packages)",
				"distlib @ 0.3.9 (/usr/local/lib/python3.8/dist-packages) [dependency-of] virtualenv @ 20.31.2 (/usr/local/lib/python3.8/dist-packages)",
				"filelock @ 3.16.1 (/usr/local/lib/python3.8/dist-packages) [dependency-of] virtualenv @ 20.31.2 (/usr/local/lib/python3.8/dist-packages)",
				"idna @ 2.10 (/usr/local/lib/python3.8/dist-packages) [dependency-of] requests @ 2.25.0 (/usr/local/lib/python3.8/dist-packages)",
				"idna @ 2.10 (/usr/local/lib/python3.8/dist-packages) [dependency-of] urllib3 @ 1.26.18 (/usr/local/lib/python3.8/dist-packages)", // available when extra == "secure", but another dependency is primarily installing it
				"platformdirs @ 4.3.6 (/usr/local/lib/python3.8/dist-packages) [dependency-of] virtualenv @ 20.31.2 (/usr/local/lib/python3.8/dist-packages)",
				"soupsieve @ 2.2 (/usr/local/lib/python3.8/dist-packages) [dependency-of] beautifulsoup4 @ 4.9.2 (/usr/local/lib/python3.8/dist-packages)",
				"urllib3 @ 1.26.18 (/usr/local/lib/python3.8/dist-packages) [dependency-of] requests @ 2.25.0 (/usr/local/lib/python3.8/dist-packages)",
				"virtualenv @ 20.31.2 (/usr/local/lib/python3.8/dist-packages) [dependency-of] filelock @ 3.16.1 (/usr/local/lib/python3.8/dist-packages)", // available when extra == "testing", but we are installing it
				"xmod @ 1.8.1 (/usr/local/lib/python3.8/dist-packages) [dependency-of] runs @ 1.2.2 (/usr/local/lib/python3.8/dist-packages)",

				// project 1 virtual env
				//
				// in the container, you can get a sense for dependencies with :
				//   $ source /app/project1/venv/bin/activate
				//   $ pip list | tail -n +3 | awk '{print $1}' | xargs pip show | grep -e 'Name:' -e 'Requires:' -e '\-\-\-' -e 'Location:' | grep -A 1 -B 1 '\-packages'
				//
				// which approximates to (some in virtual env, some in system packages):
				//
				// - beautifulsoup4: soupsieve
				// - requests [SYSTEM]: certifi [SYSTEM], chardet [SYSTEM], idna [SYSTEM], urllib3 [SYSTEM]
				// - blessed [SYSTEM]: six [SYSTEM], wcwidth [SYSTEM]
				// - virtualenv [SYSTEM]: distlib [SYSTEM], filelock [SYSTEM], platformdirs [SYSTEM]
				// - inquirer: python-editor [SYSTEM], blessed [SYSTEM], readchar
				//
				// Note: we'll only see new relationships, so any relationship where there is at least one new player (in FROM or TO)
				"blessed @ 1.20.0 (/usr/local/lib/python3.9/dist-packages) [dependency-of] inquirer @ 3.0.0 (/app/project1/venv/lib/python3.9/site-packages)",      // note: depends on global site package!
				"python-editor @ 1.0.4 (/usr/local/lib/python3.9/dist-packages) [dependency-of] inquirer @ 3.0.0 (/app/project1/venv/lib/python3.9/site-packages)", // note: depends on global site package!
				"readchar @ 4.2.1 (/app/project1/venv/lib/python3.9/site-packages) [dependency-of] inquirer @ 3.0.0 (/app/project1/venv/lib/python3.9/site-packages)",
				"setuptools @ 44.0.0 (/app/project1/venv/lib/python3.9/site-packages) [dependency-of] virtualenv @ 20.31.2 (/usr/local/lib/python3.9/dist-packages)", // available when extra == "test", but we are installing it
				"soupsieve @ 2.3 (/app/project1/venv/lib/python3.9/site-packages) [dependency-of] beautifulsoup4 @ 4.10.0 (/app/project1/venv/lib/python3.9/site-packages)",

				// project 2 virtual env
				//
				// in the container, you can get a sense for dependencies with :
				//   $ source /app/project2/venv/bin/activate
				//   $ pip list | tail -n +3 | awk '{print $1}' | xargs pip show | grep -e 'Name:' -e 'Requires:' -e '\-\-\-' -e 'Location:'
				//
				// which approximates to (all in virtual env):
				//
				// - blessed: six, wcwidth
				// - editor: runs, xmod
				// - runs: xmod
				// - inquirer: editor, blessed, readchar
				"blessed @ 1.20.0 (/app/project2/venv/lib/python3.8/site-packages) [dependency-of] inquirer @ 3.2.4 (/app/project2/venv/lib/python3.8/site-packages)",
				"editor @ 1.6.6 (/app/project2/venv/lib/python3.8/site-packages) [dependency-of] inquirer @ 3.2.4 (/app/project2/venv/lib/python3.8/site-packages)",
				"readchar @ 4.1.0 (/app/project2/venv/lib/python3.8/site-packages) [dependency-of] inquirer @ 3.2.4 (/app/project2/venv/lib/python3.8/site-packages)",
				"runs @ 1.2.2 (/app/project2/venv/lib/python3.8/site-packages) [dependency-of] editor @ 1.6.6 (/app/project2/venv/lib/python3.8/site-packages)",
				"six @ 1.16.0 (/app/project2/venv/lib/python3.8/site-packages) [dependency-of] blessed @ 1.20.0 (/app/project2/venv/lib/python3.8/site-packages)",
				"wcwidth @ 0.2.13 (/app/project2/venv/lib/python3.8/site-packages) [dependency-of] blessed @ 1.20.0 (/app/project2/venv/lib/python3.8/site-packages)",
				"xmod @ 1.8.1 (/app/project2/venv/lib/python3.8/site-packages) [dependency-of] editor @ 1.6.6 (/app/project2/venv/lib/python3.8/site-packages)",
				"xmod @ 1.8.1 (/app/project2/venv/lib/python3.8/site-packages) [dependency-of] runs @ 1.2.2 (/app/project2/venv/lib/python3.8/site-packages)",
			},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			pkgtest.NewCatalogTester().
				WithImageResolver(t, test.fixture).
				WithPackageStringer(stringPackage).
				ExpectsRelationshipStrings(test.expectedRelationships).
				TestCataloger(t, NewInstalledPackageCataloger())
		})
	}
}

func stringPackage(p pkg.Package) string {
	locs := p.Locations.ToSlice()
	var loc string
	if len(locs) > 0 {
		// we want the location of the site-packages, not the metadata file
		loc = path.Dir(path.Dir(p.Locations.ToSlice()[0].RealPath))
	}

	return fmt.Sprintf("%s @ %s (%s)", p.Name, p.Version, loc)
}

func mustContentsFromLocation(t *testing.T, contentsPath string, offset ...int) string {
	t.Helper() // Marks this function as a test helper for cleaner error reporting
	contents, err := os.ReadFile(contentsPath)
	if err != nil {
		t.Fatalf("failed to read file %s: %v", contentsPath, err)
	}

	if len(offset) == 0 {
		return string(contents)
	}

	if len(offset) != 2 {
		t.Fatalf("invalid offset provided, expected two integers: start and end")
	}
	start, end := offset[0], offset[1]

	if start < 0 || end > len(contents) || start > end {
		t.Fatalf("invalid offset range: start=%d, end=%d, content length=%d", start, end, len(contents))
	}

	return string(contents[start:end])
}
