package incident_test

import (
	"encoding/json"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"
	"time"

	"github.com/garagon/aguara/internal/incident"
	"github.com/garagon/aguara/internal/intel"
)

func writeNPMPackage(t *testing.T, root, importPath, version string) {
	t.Helper()
	dir := filepath.Join(root, filepath.FromSlash(importPath))
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir %s: %v", dir, err)
	}
	body := `{"name":` + jsonString(importPath) + `,"version":` + jsonString(version) + `}`
	if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(body), 0o644); err != nil {
		t.Fatalf("write package.json: %v", err)
	}
}

func jsonString(s string) string {
	// Minimal JSON string escaping for the few cases the test writes.
	return `"` + s + `"`
}

func TestIsCompromisedIn_NPMHit(t *testing.T) {
	cp := incident.IsCompromisedIn(incident.EcosystemNPM, "event-stream", "3.3.6")
	if cp == nil {
		t.Fatalf("expected event-stream 3.3.6 to be compromised, got nil")
	}
	if cp.Ecosystem != incident.EcosystemNPM {
		t.Errorf("expected ecosystem npm, got %q", cp.Ecosystem)
	}
}

func TestIsCompromisedIn_NPMMiss(t *testing.T) {
	// event-stream is in the npm list; checking pypi for the same name
	// must return nil, otherwise a Python package sharing a name with
	// an npm incident would falsely chain.
	if cp := incident.IsCompromisedIn(incident.EcosystemPyPI, "event-stream", "3.3.6"); cp != nil {
		t.Errorf("expected ecosystem filter to exclude pypi lookup, got: %+v", cp)
	}
}

func TestIsCompromised_LegacyTwoArg_ScopedToPyPI(t *testing.T) {
	// The legacy two-arg signature pre-dates the Ecosystem field
	// and is scoped to PyPI. A Python package whose metadata
	// name+version matches an npm advisory must not be falsely
	// flagged by the Python checker.
	if cp := incident.IsCompromised("event-stream", "3.3.6"); cp != nil {
		t.Errorf("legacy IsCompromised must not match npm event-stream from a Python lookup, got: %+v", cp)
	}
	// PyPI entries still match.
	if cp := incident.IsCompromised("litellm", "1.82.7"); cp == nil {
		t.Errorf("legacy IsCompromised should still find PyPI litellm 1.82.7")
	}
}

func TestCheckNPM_RequiresPath(t *testing.T) {
	if _, err := incident.CheckNPM(incident.CheckOptions{}); err == nil {
		t.Errorf("expected error when path is empty")
	}
}

func TestCheckNPM_DetectsCompromisedPackage(t *testing.T) {
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	writeNPMPackage(t, nm, "event-stream", "3.3.6")
	writeNPMPackage(t, nm, "express", "4.18.2")

	result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
	if err != nil {
		t.Fatalf("CheckNPM returned error: %v", err)
	}
	if result.PackagesRead != 2 {
		t.Errorf("expected 2 packages read, got %d", result.PackagesRead)
	}
	if len(result.Findings) != 1 {
		t.Fatalf("expected 1 finding, got %d: %+v", len(result.Findings), result.Findings)
	}
	if result.Findings[0].Severity != incident.SevCritical {
		t.Errorf("expected CRITICAL severity, got %q", result.Findings[0].Severity)
	}
}

func TestCheckNPM_ScopedPackages(t *testing.T) {
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	writeNPMPackage(t, nm, "@scope/legit", "1.0.0")
	writeNPMPackage(t, nm, "ua-parser-js", "0.7.29")

	result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
	if err != nil {
		t.Fatalf("CheckNPM returned error: %v", err)
	}
	if result.PackagesRead != 2 {
		t.Errorf("expected 2 packages read, got %d", result.PackagesRead)
	}
	if len(result.Findings) != 1 || result.Findings[0].Path == "" {
		t.Fatalf("expected one ua-parser-js finding with a path, got: %+v", result.Findings)
	}
}

func TestCheckNPM_NestedNodeModules(t *testing.T) {
	// A nested copy at node_modules/foo/node_modules/event-stream
	// must still be detected (npm legitimately ships nested deps
	// when a dep's version constraint cannot be hoisted).
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	writeNPMPackage(t, nm, "foo", "1.0.0")
	writeNPMPackage(t, filepath.Join(nm, "foo", "node_modules"), "event-stream", "3.3.6")

	result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
	if err != nil {
		t.Fatalf("CheckNPM returned error: %v", err)
	}
	if len(result.Findings) != 1 {
		t.Fatalf("expected 1 finding for nested compromised dep, got %d: %+v", len(result.Findings), result.Findings)
	}
}

func TestCheckNPM_MiniShaiHulud_AntvWave(t *testing.T) {
	// Mini Shai-Hulud / @antv wave: compromised versions must flag
	// CRITICAL on an installed tree. Picks one scoped (@antv/g2)
	// and one unscoped (echarts-for-react) package to exercise both
	// scoped-path and unscoped-path lookup branches.
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	writeNPMPackage(t, nm, "@antv/g2", "5.6.8")
	writeNPMPackage(t, nm, "echarts-for-react", "3.2.7")
	writeNPMPackage(t, nm, "react", "18.3.1") // unrelated; must NOT flag

	result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
	if err != nil {
		t.Fatalf("CheckNPM returned error: %v", err)
	}
	if got := len(result.Findings); got != 2 {
		t.Fatalf("expected 2 findings (compromised @antv/g2 5.6.8 + echarts-for-react 3.2.7), got %d: %+v", got, result.Findings)
	}
	for _, f := range result.Findings {
		if f.Severity != incident.SevCritical {
			t.Errorf("expected CRITICAL severity, got %q on %q", f.Severity, f.Title)
		}
	}
	// Title should name the specific package+version+advisory so a
	// dashboard reader can correlate without digging into Detail.
	wantTitles := []string{
		"@antv/g2 5.6.8 is a known compromised npm package (SOCKET-2026-05-19-mini-shai-hulud-antv)",
		"echarts-for-react 3.2.7 is a known compromised npm package (SOCKET-2026-05-19-mini-shai-hulud-antv)",
	}
	for _, want := range wantTitles {
		var found bool
		for _, f := range result.Findings {
			if f.Title == want {
				found = true
				break
			}
		}
		if !found {
			t.Errorf("expected finding title %q not present; got %+v", want, result.Findings)
		}
	}
}

func TestCheckNPM_MiniShaiHulud_AntvWaveExpansion(t *testing.T) {
	// Second-round @antv expansion: covers the additional packages
	// added after the first hotfix landed. Same registry-attestation
	// rule: only versions with explicit "published in error" or
	// "compromised key" deprecation messages are included.
	//
	// node_modules holds one resolved version per package; the second
	// version of @antv/infographic (0.3.19) is exercised through the
	// snapshot parity test (TestKnownCompromisedSnapshotParity walks
	// every Versions entry in every record).
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	writeNPMPackage(t, nm, "@antv/g-image-exporter", "1.2.42")
	writeNPMPackage(t, nm, "@antv/infographic", "0.4.19")

	result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
	if err != nil {
		t.Fatalf("CheckNPM returned error: %v", err)
	}
	if got := len(result.Findings); got != 2 {
		t.Fatalf("expected 2 findings (g-image-exporter 1.2.42 + infographic 0.4.19), got %d: %+v", got, result.Findings)
	}
	for _, f := range result.Findings {
		if f.Severity != incident.SevCritical {
			t.Errorf("expected CRITICAL severity, got %q on %q", f.Severity, f.Title)
		}
		if !strings.Contains(f.Title, "SOCKET-2026-05-19-mini-shai-hulud-antv") {
			t.Errorf("expected @antv advisory ID in title, got %q", f.Title)
		}
	}
}

func TestCheckNPM_CollapsesManualAndOSVDuplicate(t *testing.T) {
	// When the matcher returns multiple advisories for the same
	// (ecosystem, name, version, path) tuple - one from manual
	// intel, one from a refreshed OSV snapshot - the check layer
	// must emit exactly ONE finding, not one per advisory. The
	// matcher itself keeps returning every record so correlation
	// consumers see the full set; this is a check-output-layer
	// dedup, not a matcher behaviour change.
	//
	// Manual must win the title because EmbeddedSnapshots() puts
	// the manual snapshot first; advisory tokens stay stable
	// across `aguara check` and `aguara check --fresh`.
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	writeNPMPackage(t, nm, "@antv/g2", "5.6.8")

	manualSnap := intel.Snapshot{
		SchemaVersion: intel.CurrentSchemaVersion,
		GeneratedAt:   time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
		Sources:       []intel.SourceMeta{{Kind: intel.SourceManual}},
		Records: []intel.Record{{
			ID:        "SOCKET-2026-05-19-mini-shai-hulud-antv",
			Ecosystem: intel.EcosystemNPM,
			Name:      "@antv/g2",
			Kind:      intel.KindMalicious,
			Summary:   "manual-side advisory",
			Versions:  []string{"5.6.8"},
		}},
	}
	osvSnap := intel.Snapshot{
		SchemaVersion: intel.CurrentSchemaVersion,
		GeneratedAt:   time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC),
		Sources:       []intel.SourceMeta{{Kind: intel.SourceOSV}},
		Records: []intel.Record{{
			ID:        "MAL-2026-3973",
			Ecosystem: intel.EcosystemNPM,
			Name:      "@antv/g2",
			Kind:      intel.KindMalicious,
			Summary:   "osv-side advisory",
			Versions:  []string{"5.6.8"},
		}},
	}

	result, err := incident.CheckNPM(incident.CheckOptions{
		Path: nm,
		Intel: &incident.IntelOverride{
			Snapshots:     []intel.Snapshot{manualSnap, osvSnap},
			Mode:          "online",
			SnapshotLabel: "remote-fresh",
		},
	})
	if err != nil {
		t.Fatalf("CheckNPM error: %v", err)
	}
	if got := len(result.Findings); got != 1 {
		t.Fatalf("expected exactly 1 finding (manual+OSV collapse to one exposure), got %d: %+v", got, result.Findings)
	}
	got := result.Findings[0]
	if !strings.Contains(got.Title, "SOCKET-2026-05-19-mini-shai-hulud-antv") {
		t.Errorf("manual advisory must win the title; got %q", got.Title)
	}
	if strings.Contains(got.Title, "MAL-2026-3973") {
		t.Errorf("OSV advisory must not appear in title when manual is present; got %q", got.Title)
	}
	if got.Severity != incident.SevCritical {
		t.Errorf("expected CRITICAL, got %q", got.Severity)
	}
	if len(result.Ecosystems) != 1 || result.Ecosystems[0].FindingsCount != 1 {
		t.Errorf("ecosystems[0].findings_count must be 1, got %+v", result.Ecosystems)
	}
}

func TestCheckNPM_MiniShaiHulud_NeighborVersionDoesNotFalsePositive(t *testing.T) {
	// Versions adjacent to a compromised pin must NOT flag. Guards
	// against accidental range expansion when adding manual intel.
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	writeNPMPackage(t, nm, "@antv/g2", "5.4.8")                // current latest, clean
	writeNPMPackage(t, nm, "@antv/g6", "5.1.1")                // current latest, clean
	writeNPMPackage(t, nm, "size-sensor", "1.0.3")             // current latest, clean
	writeNPMPackage(t, nm, "@antv/data-set", "0.11.8")         // current latest, clean
	writeNPMPackage(t, nm, "echarts-for-react", "3.0.6")       // current latest, clean
	writeNPMPackage(t, nm, "@antv/g-image-exporter", "1.0.42") // current latest, clean
	writeNPMPackage(t, nm, "@antv/infographic", "0.2.19")      // current latest, clean

	result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
	if err != nil {
		t.Fatalf("CheckNPM returned error: %v", err)
	}
	if len(result.Findings) != 0 {
		t.Errorf("clean / neighbor versions must not flag, got: %+v", result.Findings)
	}
}

func TestCheckNPM_CleanTree(t *testing.T) {
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	writeNPMPackage(t, nm, "react", "18.3.1")
	writeNPMPackage(t, nm, "@scope/utils", "2.1.0")

	result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
	if err != nil {
		t.Fatalf("CheckNPM returned error: %v", err)
	}
	if len(result.Findings) != 0 {
		t.Errorf("clean tree must produce no findings, got: %+v", result.Findings)
	}
}

func TestCheckNPM_CleanTreeJSONEmitsEmptyArrays(t *testing.T) {
	// JSON consumers expect a stable `findings: []` shape on a clean
	// tree. nil slices would marshal as `null`, breaking pipelines
	// that .length or .map() over the result.
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	writeNPMPackage(t, nm, "express", "4.18.2")

	result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
	if err != nil {
		t.Fatalf("CheckNPM error: %v", err)
	}
	out, err := json.Marshal(result)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	js := string(out)
	if strings.Contains(js, `"findings":null`) || strings.Contains(js, `"credentials":null`) {
		t.Errorf("clean tree must emit empty arrays, got: %s", js)
	}
	if !strings.Contains(js, `"findings":[]`) {
		t.Errorf("expected findings:[] in JSON output, got: %s", js)
	}
}

// TestCheckNPM_AppendsNPMEcosystemEntry locks the per-call ecosystems[]
// contract for the npm incident path. Issue #109: before this, the
// npm path always emitted .Ecosystems = [] regardless of whether
// node_modules was actually consulted, so JSON consumers reading the
// ecosystems[] array concluded "npm not covered" even when packages
// were checked and findings would have fired.
func TestCheckNPM_AppendsNPMEcosystemEntry(t *testing.T) {
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	writeNPMPackage(t, nm, "express", "4.18.2")
	writeNPMPackage(t, nm, "lodash", "4.17.21")

	result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
	if err != nil {
		t.Fatalf("CheckNPM error: %v", err)
	}
	if len(result.Ecosystems) != 1 {
		t.Fatalf("expected exactly one ecosystems[] entry for npm path, got %d (%+v)",
			len(result.Ecosystems), result.Ecosystems)
	}
	got := result.Ecosystems[0]
	if got.Ecosystem != "npm" {
		t.Errorf("ecosystem = %q, want %q", got.Ecosystem, "npm")
	}
	if got.Source != "node_modules" {
		t.Errorf("source = %q, want %q", got.Source, "node_modules")
	}
	if got.Path != nm {
		t.Errorf("path = %q, want %q (the actual scanned node_modules dir)", got.Path, nm)
	}
	if got.PackagesRead != 2 {
		t.Errorf("packages_read = %d, want 2 (express + lodash)", got.PackagesRead)
	}
	if got.FindingsCount != 0 {
		t.Errorf("findings_count = %d, want 0 (clean tree)", got.FindingsCount)
	}
}

// TestCheckNPM_AppendsNPMEcosystemEntry_WithFindings pins that
// FindingsCount counts only package-match findings (not other
// finding sources) and that the count survives serialisation.
func TestCheckNPM_AppendsNPMEcosystemEntry_WithFindings(t *testing.T) {
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	// node-ipc 9.2.3 is in the embedded compromised list. The match
	// makes this test exercise the findings-counting branch.
	writeNPMPackage(t, nm, "node-ipc", "9.2.3")

	result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
	if err != nil {
		t.Fatalf("CheckNPM error: %v", err)
	}
	if len(result.Ecosystems) != 1 {
		t.Fatalf("expected exactly one ecosystems[] entry, got %d", len(result.Ecosystems))
	}
	got := result.Ecosystems[0]
	if got.PackagesRead != 1 {
		t.Errorf("packages_read = %d, want 1", got.PackagesRead)
	}
	if got.FindingsCount < 1 {
		t.Errorf("findings_count = %d, want at least 1 (node-ipc 9.2.3 should match)", got.FindingsCount)
	}

	// Round-trip through JSON to confirm the new entry survives
	// serialisation with the documented field names.
	out, err := json.Marshal(result)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	js := string(out)
	for _, want := range []string{
		`"ecosystem":"npm"`,
		`"source":"node_modules"`,
		`"packages_read":1`,
	} {
		if !strings.Contains(js, want) {
			t.Errorf("JSON output missing %q in ecosystems[] entry; got: %s", want, js)
		}
	}
}

// TestCheckNPM_ReturnsErrorOnUnreadableTree pins the readability-probe
// behaviour. WalkDir swallows traversal errors and returns an empty
// package list, so without an explicit ReadDir probe an unreadable
// node_modules would surface as a clean-looking result with
// ecosystems[npm].PackagesRead=0, indistinguishable from a real empty
// tree to coverage consumers. The probe converts the silent failure
// into an explicit error.
func TestCheckNPM_ReturnsErrorOnUnreadableTree(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("POSIX mode 0o000 does not block ReadDir on Windows; the readability semantics this test covers are Unix-only")
	}
	if os.Getuid() == 0 {
		t.Skip("running as root bypasses POSIX permission checks; skip")
	}
	nm := filepath.Join(t.TempDir(), "node_modules")
	if err := os.MkdirAll(nm, 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	if err := os.Chmod(nm, 0o000); err != nil {
		t.Fatalf("chmod 000: %v", err)
	}
	t.Cleanup(func() { _ = os.Chmod(nm, 0o755) })

	result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
	if err == nil {
		t.Fatalf("unreadable node_modules must surface as a CheckNPM error, not a silent clean scan")
	}
	if result != nil {
		t.Errorf("CheckNPM must not return a result when the directory cannot be read; got %+v", result)
	}
	if !strings.Contains(err.Error(), "cannot read node_modules directory") {
		t.Errorf("error message must mention the readability failure for operators to diagnose; got %v", err)
	}
}

func TestCheckNPM_NonExistentPath(t *testing.T) {
	if _, err := incident.CheckNPM(incident.CheckOptions{Path: "/nonexistent/path"}); err == nil {
		t.Errorf("expected error for nonexistent path")
	}
}

func TestCheckNPM_AcceptsProjectRoot(t *testing.T) {
	// `--path .` (project root with a sibling node_modules) is the
	// most user-friendly invocation. The checker normalizes it to
	// the node_modules child instead of silently reporting zero
	// packages.
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	writeNPMPackage(t, nm, "event-stream", "3.3.6")

	result, err := incident.CheckNPM(incident.CheckOptions{Path: dir})
	if err != nil {
		t.Fatalf("CheckNPM should accept project root, got: %v", err)
	}
	if result.PackagesRead != 1 {
		t.Errorf("expected 1 package, got %d", result.PackagesRead)
	}
	if len(result.Findings) != 1 {
		t.Errorf("project-root scan should still detect compromised dep, got: %+v", result.Findings)
	}
}

func TestCheckNPM_DetectsNodeIPC2026Compromise(t *testing.T) {
	// The May 2026 node-ipc compromise (Socket / StepSecurity):
	// versions 9.1.6, 9.2.3, and 12.0.1 published an obfuscated
	// CommonJS payload. Each must surface as CRITICAL with the
	// SOCKET-2026-05-14 advisory.
	//
	// Since v0.16 the embedded OSV snapshot may carry an additional
	// MAL- record for the same version, in which case BOTH advisories
	// surface (distinct advisory IDs at the same tuple are kept
	// separate by design -- see TestMatcherDistinctIDsAtSameTuple).
	// The contract we lock is: at least one finding, CRITICAL, and
	// the manual SOCKET advisory MUST be among them.
	for _, ver := range []string{"9.1.6", "9.2.3", "12.0.1"} {
		ver := ver
		t.Run(ver, func(t *testing.T) {
			dir := t.TempDir()
			nm := filepath.Join(dir, "node_modules")
			writeNPMPackage(t, nm, "node-ipc", ver)

			result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
			if err != nil {
				t.Fatalf("CheckNPM error: %v", err)
			}
			if len(result.Findings) == 0 {
				t.Fatalf("expected at least one finding for node-ipc %s, got none", ver)
			}
			var sawSocket bool
			for _, f := range result.Findings {
				if f.Severity != incident.SevCritical {
					t.Errorf("expected CRITICAL, got %q", f.Severity)
				}
				if strings.Contains(f.Title+f.Detail, "SOCKET-2026-05-14-node-ipc") {
					sawSocket = true
				}
			}
			if !sawSocket {
				t.Errorf("expected SOCKET-2026-05-14-node-ipc advisory among findings for %s, got: %+v", ver, result.Findings)
			}
		})
	}
}

func TestCheckNPM_DetectsHistoricalNodeIPCCompromises(t *testing.T) {
	// The 2022 "peacenotwar" / RIAEvangelist incident. These
	// versions are listed as historical malicious to surface them
	// on installs that still pin or transitively depend on them.
	for _, ver := range []string{"10.1.1", "10.1.2", "11.0.0", "11.1.0"} {
		ver := ver
		t.Run(ver, func(t *testing.T) {
			dir := t.TempDir()
			nm := filepath.Join(dir, "node_modules")
			writeNPMPackage(t, nm, "node-ipc", ver)

			result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
			if err != nil {
				t.Fatalf("CheckNPM error: %v", err)
			}
			if len(result.Findings) != 1 {
				t.Fatalf("expected 1 finding for historical node-ipc %s, got %d", ver, len(result.Findings))
			}
			if !strings.Contains(result.Findings[0].Title+result.Findings[0].Detail, "SOCKET-node-ipc-historical-malicious") {
				t.Errorf("expected historical advisory reference, got: %+v", result.Findings[0])
			}
		})
	}
}

func TestCheckNPM_NodeIPCCleanVersions(t *testing.T) {
	// Versions adjacent to the compromised tuples must not be
	// flagged. 12.0.0 is the immediately prior version to the
	// compromised 12.0.1 in the same minor series.
	for _, ver := range []string{"9.0.0", "9.1.5", "12.0.0", "9.2.4"} {
		ver := ver
		t.Run(ver, func(t *testing.T) {
			dir := t.TempDir()
			nm := filepath.Join(dir, "node_modules")
			writeNPMPackage(t, nm, "node-ipc", ver)

			result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
			if err != nil {
				t.Fatalf("CheckNPM error: %v", err)
			}
			if len(result.Findings) != 0 {
				t.Errorf("clean node-ipc %s must not produce findings, got: %+v", ver, result.Findings)
			}
		})
	}
}

func TestCheckNPM_RejectsBareDirWithoutNodeModules(t *testing.T) {
	// A directory that is neither node_modules nor a project with
	// one must error rather than report a clean (and misleading)
	// zero-finding result.
	dir := t.TempDir()
	if _, err := incident.CheckNPM(incident.CheckOptions{Path: dir}); err == nil {
		t.Errorf("expected error on directory without node_modules child")
	}
}

func TestCheckNPM_PnpmStoreLayout(t *testing.T) {
	// pnpm exposes top-level packages as symlinks into a virtual
	// store under node_modules/.pnpm. The real manifests live at
	// node_modules/.pnpm/<spec>/node_modules/<name>/package.json
	// and must be parsed as installed dependencies.
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	writeNPMPackage(t, filepath.Join(nm, ".pnpm", "event-stream@3.3.6", "node_modules"), "event-stream", "3.3.6")
	// A scoped variant.
	writeNPMPackage(t, filepath.Join(nm, ".pnpm", "@scope+name@1.0.0", "node_modules"), "@scope/name", "1.0.0")

	result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
	if err != nil {
		t.Fatalf("CheckNPM returned error: %v", err)
	}
	if result.PackagesRead != 2 {
		t.Errorf("expected 2 packages in pnpm store, got %d", result.PackagesRead)
	}
	if len(result.Findings) != 1 || result.Findings[0].Title == "" {
		t.Fatalf("expected one event-stream finding, got: %+v", result.Findings)
	}
}

func TestCheckNPM_IgnoresFixtureNodeModules(t *testing.T) {
	// A build-tool style fixture: an installed package ships a
	// test fixture that itself has a node_modules tree
	// (node_modules/build-tool/examples/some-app/node_modules/x/package.json).
	// The state-machine path check must reject the nested node_modules
	// because of the `examples/some-app` intermediate segments.
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	writeNPMPackage(t, nm, "build-tool", "1.0.0")
	deep := filepath.Join(nm, "build-tool", "examples", "some-app", "node_modules")
	if err := os.MkdirAll(filepath.Join(deep, "event-stream"), 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	if err := os.WriteFile(
		filepath.Join(deep, "event-stream", "package.json"),
		[]byte(`{"name":"event-stream","version":"3.3.6"}`),
		0o644,
	); err != nil {
		t.Fatalf("write fixture: %v", err)
	}

	result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
	if err != nil {
		t.Fatalf("CheckNPM returned error: %v", err)
	}
	if result.PackagesRead != 1 {
		t.Errorf("expected only 1 installed package (build-tool), got %d", result.PackagesRead)
	}
	if len(result.Findings) != 0 {
		t.Errorf("fixture node_modules must not chain, got: %+v", result.Findings)
	}
}

func TestCheckNPM_IgnoresFixtureManifests(t *testing.T) {
	// A package may ship its own examples / test fixtures that contain
	// a `package.json` (e.g. fixture for a transpiler). Those are not
	// installed dependencies and must not be parsed as such.
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	writeNPMPackage(t, nm, "real-pkg", "1.0.0")
	// A fixture manifest with a known-compromised tuple deep inside
	// real-pkg/examples/.
	fixDir := filepath.Join(nm, "real-pkg", "examples", "scenario")
	if err := os.MkdirAll(fixDir, 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	if err := os.WriteFile(
		filepath.Join(fixDir, "package.json"),
		[]byte(`{"name":"event-stream","version":"3.3.6"}`),
		0o644,
	); err != nil {
		t.Fatalf("write fixture: %v", err)
	}

	result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
	if err != nil {
		t.Fatalf("CheckNPM returned error: %v", err)
	}
	if result.PackagesRead != 1 {
		t.Errorf("expected only 1 installed package, got %d", result.PackagesRead)
	}
	if len(result.Findings) != 0 {
		t.Errorf("fixture manifests must not produce findings, got: %+v", result.Findings)
	}
}

func TestCheckNPM_TrapDoor(t *testing.T) {
	// TrapDoor campaign npm package at its confirmed malicious version
	// in an installed tree must flag CRITICAL with the campaign advisory.
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	writeNPMPackage(t, nm, "dev-env-bootstrapper", "1.0.12")
	writeNPMPackage(t, nm, "left-pad", "1.3.0") // unrelated; must NOT flag

	result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
	if err != nil {
		t.Fatalf("CheckNPM returned error: %v", err)
	}
	if len(result.Findings) != 1 {
		t.Fatalf("expected 1 TrapDoor finding, got %d: %+v", len(result.Findings), result.Findings)
	}
	f := result.Findings[0]
	if f.Severity != incident.SevCritical {
		t.Errorf("severity = %q, want CRITICAL", f.Severity)
	}
	if !strings.Contains(f.Title, "dev-env-bootstrapper") || !strings.Contains(f.Title, "SOCKET-2026-05-24-trapdoor") {
		t.Errorf("title = %q, want it to name the package and the campaign advisory", f.Title)
	}
}

func TestCheckNPM_TrapDoor_WholePackageRangeFlagsAnyVersion(t *testing.T) {
	// async-pipeline-builder is a TrapDoor package npm security-held in
	// its entirety (OSV introduced:0). It is now carried as a range-only
	// manual entry, so the range-capable matcher flags it at ANY
	// installed version, not just one pinned version. (At v0.18.4 it was
	// excluded because the matcher could not evaluate ranges.)
	for _, version := range []string{"1.0.12", "2.5.0", "0.0.1"} {
		dir := t.TempDir()
		nm := filepath.Join(dir, "node_modules")
		writeNPMPackage(t, nm, "async-pipeline-builder", version)
		writeNPMPackage(t, nm, "left-pad", "1.3.0") // unrelated; must NOT flag

		result, err := incident.CheckNPM(incident.CheckOptions{Path: nm})
		if err != nil {
			t.Fatalf("CheckNPM returned error: %v", err)
		}
		if len(result.Findings) != 1 {
			t.Fatalf("version %s: expected 1 finding, got %d: %+v", version, len(result.Findings), result.Findings)
		}
		f := result.Findings[0]
		if f.Severity != incident.SevCritical {
			t.Errorf("version %s: severity = %q, want CRITICAL", version, f.Severity)
		}
		if !strings.Contains(f.Title, "async-pipeline-builder") || !strings.Contains(f.Title, "SOCKET-2026-05-24-trapdoor") {
			t.Errorf("version %s: title = %q, want package + advisory", version, f.Title)
		}
	}
}

func TestCheckNPM_TrapDoor_DedupeManualAndOSV(t *testing.T) {
	// Simulate `aguara check --fresh`: an OSV snapshot also carries the
	// same tuple under its own ID (MAL-*). The matcher returns both
	// records for correlation, but the user must see ONE finding, and
	// it must keep the manual SOCKET advisory ID because the manual
	// snapshot loads first (mirrors EmbeddedSnapshots() ordering).
	dir := t.TempDir()
	nm := filepath.Join(dir, "node_modules")
	writeNPMPackage(t, nm, "dev-env-bootstrapper", "1.0.12")

	osv := intel.Snapshot{
		SchemaVersion: intel.CurrentSchemaVersion,
		Records: []intel.Record{{
			ID:        "MAL-2026-4277",
			Ecosystem: intel.EcosystemNPM,
			Name:      "dev-env-bootstrapper",
			Kind:      intel.KindMalicious,
			Versions:  []string{"1.0.12"},
		}},
	}
	override := &incident.IntelOverride{
		Snapshots:     []intel.Snapshot{incident.KnownCompromisedSnapshot(), osv},
		Mode:          "online",
		SnapshotLabel: "remote-fresh",
	}
	result, err := incident.CheckNPM(incident.CheckOptions{Path: nm, Intel: override})
	if err != nil {
		t.Fatalf("CheckNPM returned error: %v", err)
	}
	if len(result.Findings) != 1 {
		t.Fatalf("manual + OSV for the same tuple must collapse to 1 finding, got %d: %+v", len(result.Findings), result.Findings)
	}
	if !strings.Contains(result.Findings[0].Title, "SOCKET-2026-05-24-trapdoor") {
		t.Errorf("finding should keep the manual advisory ID, got title %q", result.Findings[0].Title)
	}
}
