package packagecheck

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/garagon/aguara/internal/intel"
	"github.com/stretchr/testify/require"
)

// writeYarn writes content to a yarn.lock in a fresh temp dir and
// returns a Target pointing at it.
func writeYarn(t *testing.T, content string) Target {
	t.Helper()
	dir := t.TempDir()
	path := filepath.Join(dir, "yarn.lock")
	require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
	return Target{Ecosystem: intel.EcosystemNPM, Path: path, Source: "yarn.lock"}
}

const yarnHeader = "# THIS IS AN AUTOGENERATED FILE.\n# yarn lockfile v1\n\n\n"

func TestParseYarnLock_BasicAndScoped(t *testing.T) {
	refs, err := ParseYarnLock(writeYarn(t, yarnHeader+`lodash@^4.17.0:
  version "4.17.21"
  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#abc"
  integrity sha512-FAKE==

"@scope/pkg@^1.2.0":
  version "1.2.3"
  resolved "https://registry.yarnpkg.com/@scope/pkg/-/pkg-1.2.3.tgz#def"
`))
	require.NoError(t, err)
	require.ElementsMatch(t, []string{"lodash@4.17.21", "@scope/pkg@1.2.3"}, refSet(refs))
	for _, r := range refs {
		require.Equal(t, intel.EcosystemNPM, r.Ecosystem)
		require.Equal(t, "yarn.lock", r.Source)
	}
}

func TestParseYarnLock_CommaSeparatedDescriptors(t *testing.T) {
	// Multiple ranges for the same package share one resolution block
	// and must emit exactly one ref.
	refs, err := ParseYarnLock(writeYarn(t, yarnHeader+`lodash@^4.17.0, lodash@^4.17.11, lodash@^4.17.21:
  version "4.17.21"
  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#abc"
`))
	require.NoError(t, err)
	require.Equal(t, []string{"lodash@4.17.21"}, refSet(refs))
}

func TestParseYarnLock_QuotedScopedMultiDescriptor(t *testing.T) {
	refs, err := ParseYarnLock(writeYarn(t, yarnHeader+`"@babel/core@^7.0.0", "@babel/core@^7.1.0":
  version "7.5.0"
  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.5.0.tgz#abc"
`))
	require.NoError(t, err)
	require.Equal(t, []string{"@babel/core@7.5.0"}, refSet(refs))
}

func TestParseYarnLock_SkipsNonRegistrySources(t *testing.T) {
	// file: / link: / workspace: / portal: / patch: / git / and the
	// npm: alias protocol are all non-registry and must be skipped;
	// only the clean registry entry survives.
	refs, err := ParseYarnLock(writeYarn(t, yarnHeader+`clean@^1.0.0:
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/clean/-/clean-1.0.0.tgz#abc"

"local@file:../local-pkg":
  version "2.0.0"

"linked@link:../linked":
  version "3.0.0"

"ws@workspace:packages/ws":
  version "0.0.0"

"aliased@npm:real-pkg@^1.0.0":
  version "1.5.0"
  resolved "https://registry.yarnpkg.com/real-pkg/-/real-pkg-1.5.0.tgz#abc"

"patched@patch:patched@^1.0.0#./p.patch":
  version "1.0.0"

"from-git@git+https://github.com/u/repo.git":
  version "1.0.0"
`))
	require.NoError(t, err)
	require.Equal(t, []string{"clean@1.0.0"}, refSet(refs))
}

func TestParseYarnLock_DedupAndDistinctVersions(t *testing.T) {
	// Same (name, version) across two blocks dedupes; different
	// versions of the same name both survive.
	refs, err := ParseYarnLock(writeYarn(t, yarnHeader+`foo@^1.0.0:
  version "1.2.3"
  resolved "https://registry.yarnpkg.com/foo/-/foo-1.2.3.tgz#a"

foo@~1.2.0:
  version "1.2.3"
  resolved "https://registry.yarnpkg.com/foo/-/foo-1.2.3.tgz#a"

foo@^2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/foo/-/foo-2.0.0.tgz#b"
`))
	require.NoError(t, err)
	require.ElementsMatch(t, []string{"foo@1.2.3", "foo@2.0.0"}, refSet(refs))
}

func TestParseYarnLock_DependencyNamedVersionNotMiscaptured(t *testing.T) {
	// A nested dependency literally named "version" must NOT be
	// mistaken for the entry's resolved version. yarn writes the
	// entry's version first, so first-match wins.
	refs, err := ParseYarnLock(writeYarn(t, yarnHeader+`pkg@^1.0.0:
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/pkg/-/pkg-1.0.0.tgz#a"
  dependencies:
    version "^9.9.9"
    lodash "^4.17.0"
`))
	require.NoError(t, err)
	require.Equal(t, []string{"pkg@1.0.0"}, refSet(refs))
}

func TestParseYarnLock_DeterministicOrder(t *testing.T) {
	refs, err := ParseYarnLock(writeYarn(t, yarnHeader+`zeta@^1.0.0:
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/zeta/-/zeta-1.0.0.tgz#a"

alpha@^2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/alpha/-/alpha-2.0.0.tgz#b"

"@scope/mid@^1.0.0":
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/@scope/mid/-/mid-1.0.0.tgz#c"
`))
	require.NoError(t, err)
	require.Equal(t, []string{"@scope/mid@1.0.0", "alpha@2.0.0", "zeta@1.0.0"}, refSet(refs))
}

func TestParseYarnLock_BerryParsed(t *testing.T) {
	// A yarn Berry (v2+) lockfile carries __metadata: and is parsed via
	// the resolution: field. The alias descriptor resolves to the REAL
	// package (node-ipc), the workspace entry is skipped, and a scoped
	// package is handled.
	refs, err := ParseYarnLock(writeYarn(t, `# This file is generated by running "yarn install"
__metadata:
  version: 8
  cacheKey: 10c0

"lodash@npm:^4.17.21":
  version: 4.17.21
  resolution: "lodash@npm:4.17.21"
  languageName: node
  linkType: hard

"@babel/core@npm:^7.0.0":
  version: 7.23.0
  resolution: "@babel/core@npm:7.23.0"
  languageName: node
  linkType: hard

"safe-ipc@npm:node-ipc@9.2.3":
  version: 9.2.3
  resolution: "node-ipc@npm:9.2.3"
  languageName: node
  linkType: hard

"app@workspace:.":
  version: 0.0.0-use.local
  resolution: "app@workspace:."
  languageName: unknown
  linkType: soft
`))
	require.NoError(t, err)
	require.ElementsMatch(t,
		[]string{"lodash@4.17.21", "@babel/core@7.23.0", "node-ipc@9.2.3"},
		refSet(refs),
		"alias must resolve to node-ipc; workspace entry must be skipped")
	for _, r := range refs {
		require.Equal(t, intel.EcosystemNPM, r.Ecosystem)
		require.Equal(t, "yarn.lock", r.Source)
	}
}

func TestParseYarnLock_V1WithMetadataCommentStillParsedAsV1(t *testing.T) {
	// A classic v1 lockfile containing `__metadata:` only in a comment
	// must NOT be routed to the Berry parser (which would read zero
	// packages and hide every v1 dependency).
	refs, err := ParseYarnLock(writeYarn(t, `# __metadata: this is just a comment, not a Berry header
lodash@^4.17.21:
  version "4.17.21"
  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#abc"
`))
	require.NoError(t, err)
	require.Equal(t, []string{"lodash@4.17.21"}, refSet(refs), "v1 lockfile must parse as v1 despite a __metadata: comment")
}

func TestParseYarnLock_BerryInternalBlankLine(t *testing.T) {
	// A hand-edited block with a blank line before resolution: must still
	// be read (a blank line does not end the block; only the next header
	// does).
	refs, err := ParseYarnLock(writeYarn(t, `__metadata:
  version: 8

"lodash@npm:4.17.21":
  version: 4.17.21

  resolution: "lodash@npm:4.17.21"
  languageName: node

"left-pad@npm:1.3.0":
  version: 1.3.0
  resolution: "left-pad@npm:1.3.0"
`))
	require.NoError(t, err)
	require.ElementsMatch(t, []string{"lodash@4.17.21", "left-pad@1.3.0"}, refSet(refs),
		"an internal blank line must not hide a package")
}

func TestParseYarnLock_BerryCRLF(t *testing.T) {
	// A Windows/CRLF checkout must parse identically to LF.
	lf := "__metadata:\n  version: 8\n\n\"lodash@npm:4.17.21\":\n  version: 4.17.21\n  resolution: \"lodash@npm:4.17.21\"\n"
	crlf := strings.ReplaceAll(lf, "\n", "\r\n")
	refs, err := ParseYarnLock(writeYarn(t, crlf))
	require.NoError(t, err)
	require.Equal(t, []string{"lodash@4.17.21"}, refSet(refs), "CRLF Berry lockfile must read its packages")
}

func TestParseYarnLock_BerryAliasRetainingResolution(t *testing.T) {
	// Defensive: if a Berry resolution ever retains the alias ident
	// instead of normalizing to the real package, the real package is
	// still extracted, not the alias -- in either the single-@npm:
	// embedded form (alias@npm:real@version) or a double-@npm: form.
	for _, resolution := range []string{
		"safe-ipc@npm:node-ipc@9.2.3",     // alias retained, real embedded as name@version
		"safe-ipc@npm:node-ipc@npm:9.2.3", // alias retained, double @npm:
	} {
		refs, err := ParseYarnLock(writeYarn(t, `__metadata:
  version: 8

"safe-ipc@npm:node-ipc@9.2.3":
  version: 9.2.3
  resolution: "`+resolution+`"
`))
		require.NoError(t, err)
		require.Equal(t, []string{"node-ipc@9.2.3"}, refSet(refs),
			"must resolve to the real package, not the alias, for resolution %q", resolution)
	}
}

func TestParseYarnLock_BerryNestedFieldsNotMiscaptured(t *testing.T) {
	// A nested dependency named "version"/"resolution" (4-space indent)
	// must NOT be mistaken for the block's own resolution. `good` must
	// resolve to 1.0.0 (from its own 2-space resolution), never 9.9.9 or
	// 8.8.8 from the nested deps.
	refs, err := ParseYarnLock(writeYarn(t, `__metadata:
  version: 8

"good@npm:1.0.0":
  version: 1.0.0
  resolution: "good@npm:1.0.0"
  dependencies:
    version: "npm:9.9.9"
    resolution: "npm:8.8.8"
`))
	require.NoError(t, err)
	require.Equal(t, []string{"good@1.0.0"}, refSet(refs),
		"nested version/resolution deps must not be captured as the block's own fields")
}

func TestParseYarnLock_BerrySkipsNonRegistry(t *testing.T) {
	// patch:, git, and exec: resolutions are not public-registry and
	// must be skipped (no @npm: protocol in the resolution).
	refs, err := ParseYarnLock(writeYarn(t, `__metadata:
  version: 8

"left-pad@npm:1.3.0":
  version: 1.3.0
  resolution: "left-pad@npm:1.3.0"

"lodash@patch:lodash@npm%3A4.17.21#~builtin<compat/lodash>":
  version: 4.17.21
  resolution: "lodash@patch:lodash@npm%3A4.17.21#~builtin<compat/lodash>::version=4.17.21&hash=abc"

"thing@git@https://example.com/x.git":
  version: 1.0.0
  resolution: "thing@https://example.com/x.git#commit=abc"
`))
	require.NoError(t, err)
	require.Equal(t, []string{"left-pad@1.3.0"}, refSet(refs))
}

func TestParseYarnLock_MissingTopLevelVersionNoMiscapture(t *testing.T) {
	// A block with NO top-level version line but a nested dependency
	// named "version" with an exact range must emit nothing. The
	// nested entry sits at four-space indent; the version regex is
	// anchored to the two-space body level, so it cannot be captured.
	refs, err := ParseYarnLock(writeYarn(t, yarnHeader+`pkg@^1.0.0:
  resolved "https://registry.yarnpkg.com/pkg/-/pkg-1.0.0.tgz#a"
  dependencies:
    version "1.2.3"
    lodash "^4.17.0"
`))
	require.NoError(t, err)
	require.Empty(t, refSet(refs), "block without its own version must not borrow a nested dependency's exact range")
}

func TestParseYarnLock_EmptyRangeDescriptorSkipped(t *testing.T) {
	// `foo@` is an invalid descriptor (empty range). The conservative
	// parser must skip the block rather than emit foo@<version>.
	refs, err := ParseYarnLock(writeYarn(t, yarnHeader+`foo@:
  version "1.2.3"
  resolved "https://registry.yarnpkg.com/foo/-/foo-1.2.3.tgz#a"
`))
	require.NoError(t, err)
	require.Empty(t, refSet(refs))
}

func TestYarnDescriptorParse(t *testing.T) {
	cases := []struct {
		desc, name, rng string
		ok              bool
	}{
		{"lodash@^4.17.0", "lodash", "^4.17.0", true},
		{`"@scope/pkg@^1.2.0"`, "@scope/pkg", "^1.2.0", true},
		{"alias@npm:real@^1.0.0", "alias", "npm:real@^1.0.0", true},
		{"@scope/pkg@npm:other@^1.0.0", "@scope/pkg", "npm:other@^1.0.0", true},
		{"noatsign", "", "", false},
		{"", "", "", false},
		{"foo@", "", "", false},
		{`"@scope/pkg@"`, "", "", false},
	}
	for _, c := range cases {
		n, r, ok := yarnDescriptorParse(c.desc)
		require.Equalf(t, c.ok, ok, "ok for %q", c.desc)
		if c.ok {
			require.Equalf(t, c.name, n, "name for %q", c.desc)
			require.Equalf(t, c.rng, r, "range for %q", c.desc)
		}
	}
}

func TestIsExactYarnVersion(t *testing.T) {
	require.True(t, isExactYarnVersion("4.17.21"))
	require.True(t, isExactYarnVersion("1.0.0-beta.1+build"))
	require.False(t, isExactYarnVersion(""))
	require.False(t, isExactYarnVersion("^1.0.0"))
	require.False(t, isExactYarnVersion(">=1.0.0 <2.0.0"))
	require.False(t, isExactYarnVersion("npm:real@1.0.0"))
}

func TestRunner_YarnLockHit(t *testing.T) {
	snap := intel.Snapshot{
		SchemaVersion: intel.CurrentSchemaVersion,
		GeneratedAt:   time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
		Records: []intel.Record{{
			ID:        "MAL-YARN",
			Ecosystem: intel.EcosystemNPM,
			Name:      "evil-pkg",
			Kind:      intel.KindMalicious,
			Versions:  []string{"1.2.3"},
		}},
	}
	runner := &Runner{Matcher: intel.NewMatcher(snap)}
	target := writeYarn(t, yarnHeader+`evil-pkg@^1.0.0:
  version "1.2.3"
  resolved "https://registry.yarnpkg.com/evil-pkg/-/evil-pkg-1.2.3.tgz#a"

safe@^9.0.0:
  version "9.9.9"
  resolved "https://registry.yarnpkg.com/safe/-/safe-9.9.9.tgz#b"
`)
	res, err := runner.Run([]Target{target})
	require.NoError(t, err)
	require.Len(t, res.Ecosystems, 1)
	require.Equal(t, "yarn.lock", res.Ecosystems[0].Source)
	require.Equal(t, 2, res.Ecosystems[0].PackagesRead)
	require.Equal(t, 1, res.Ecosystems[0].FindingsCount)
	require.Len(t, res.Hits, 1)
	require.Equal(t, "evil-pkg", res.Hits[0].Ref.Name)
	require.Equal(t, "MAL-YARN", res.Hits[0].Record.ID)
}

func TestDiscover_PicksYarnLock(t *testing.T) {
	dir := t.TempDir()
	require.NoError(t, os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte("# yarn lockfile v1\n"), 0o600))
	targets, err := Discover(dir, []string{intel.EcosystemNPM})
	require.NoError(t, err)
	require.Len(t, targets, 1)
	require.Equal(t, "yarn.lock", targets[0].Source)
	require.Equal(t, intel.EcosystemNPM, targets[0].Ecosystem)
}
