// Package pkgmeta inspects npm package manifests (package.json) for
// supply-chain risk patterns that combine static metadata signals into
// chains: install-time lifecycle hooks reachable through a git dependency,
// optional dependencies that resolve to executable git refs, and publish
// surfaces that share a job with install-time code paths.
//
// The analyzer is fully offline. It parses JSON into a small struct, runs
// a handful of string checks against the parsed fields, and never executes
// scripts or resolves the dependency tree. Findings stay chain-aware:
// single weak signals (a prepare script alone, a git dependency alone,
// publishConfig alone) never fire.
package pkgmeta

import (
	"bytes"
	"context"
	"encoding/json"
	"path/filepath"
	"regexp"
	"sort"
	"strings"

	"github.com/garagon/aguara/internal/scanner"
	"github.com/garagon/aguara/internal/types"
)

// AnalyzerName is the value reported in Finding.Analyzer for this engine.
const AnalyzerName = "pkgmeta"

// Rule IDs emitted by this analyzer.
const (
	RuleLifecycleGit       = "NPM_LIFECYCLE_GIT_001"
	RuleOptionalGit        = "NPM_OPTIONAL_GIT_001"
	RulePublishSurface     = "NPM_PUBLISH_SURFACE_001"
	RuleLocalJSLifecycle   = "SUPPLY_026"
	RuleGitInstallTrust    = "NPM_GIT_INSTALL_TRUST_001"
	RuleRemoteInstallTrust = "NPM_REMOTE_INSTALL_TRUST_001"
)

// localJSLifecycleKeys are the lifecycle hooks where a package running its
// own JavaScript is the supply-chain risk. preinstall/install/postinstall
// and the prepare family run automatically during `npm install`;
// prepublish runs on install for git deps; prepack/postpack are pack/
// publish-time but kept here because running local JS at pack time is the
// same execute-on-the-maintainer risk. The Red Hat/Miasma dropper shipped
// a preinstall hook running `node index.js`.
var localJSLifecycleKeys = []string{
	"preinstall", "install", "postinstall",
	"preprepare", "prepare", "postprepare",
	"prepublish", "prepack", "postpack",
}

// Script-value classifiers for SUPPLY_026. They run against the PARSED
// script string (not the raw JSON), so a brace-bearing shell expansion in a
// sibling script and a same-named key outside the scripts object can never
// cause a miss or a false positive — the failure modes a flat regex over
// package.json has.
var (
	// node running a local JS file: `node … x.js|.cjs|.mjs`. `[^"']*`
	// (not `[^\s'"]*`) lets flags sit between node and the entrypoint, so
	// `node --require dotenv/config ./index.js` still matches; it stays
	// quote-bounded so it does not run past the script value. `node
	// --version` has no .js and does not match.
	reNodeLocalJS = regexp.MustCompile(`(?i)\bnode\s+[^"']*\.[cm]?js\b`)
	// node running an extensionless local path entrypoint: `node
	// ./scripts/setup`, `node dist/main`, `node /tmp/x` (node executes
	// extensionless files as JS). Requires a path separator in a non-flag
	// argument so `node --version` and bare subcommand-style words do not
	// match. Flags before the entrypoint are allowed.
	reNodeLocalPath = regexp.MustCompile(`(?i)\bnode\s+(?:--?\S+\s+)*[^\s"'-][^\s"']*/`)
	// node inline eval.
	reNodeEval = regexp.MustCompile(`(?i)\bnode\s+(?:--eval|-e)\b`)
	// bun second stage: run, a local JS file (flags allowed before it),
	// or inline eval.
	reBunExec = regexp.MustCompile(`(?i)\bbun\s+(?:run\b|--eval\b|-e\b|[^"']*\.[cm]?js\b)`)
)

// Analyzer implements scanner.Analyzer for npm package metadata.
type Analyzer struct{}

// New returns a fresh npm package metadata analyzer.
func New() *Analyzer { return &Analyzer{} }

// Name returns the analyzer identifier.
func (a *Analyzer) Name() string { return AnalyzerName }

// Analyze parses the target if it is a package.json file and returns
// supply-chain findings. Non-manifest files and malformed JSON return nil.
func (a *Analyzer) Analyze(_ context.Context, target *scanner.Target) ([]types.Finding, error) {
	if !isManifestTarget(target) {
		return nil, nil
	}
	if len(target.Content) == 0 {
		return nil, nil
	}
	pkg, err := parseManifest(target.Content)
	if err != nil || pkg == nil {
		// Malformed JSON: leave pattern rules to flag what they can; pkgmeta
		// only reports on shapes it can confidently reason about.
		return nil, nil
	}
	pkg.path = target.RelPath
	pkg.raw = target.Content
	return detect(pkg), nil
}

// --- target gating ---

// isManifestTarget returns true for files named package.json. Checks both
// Path (when scanning a real repo) and RelPath (when scanning in-memory
// content with a hinted name).
func isManifestTarget(t *scanner.Target) bool {
	if t == nil {
		return false
	}
	for _, p := range []string{t.Path, t.RelPath} {
		if p == "" {
			continue
		}
		if filepath.Base(filepath.ToSlash(p)) == "package.json" {
			return true
		}
	}
	return false
}

// --- model ---

type manifest struct {
	Name                 string            `json:"name"`
	Version              string            `json:"version"`
	Dependencies         map[string]string `json:"dependencies"`
	DevDependencies      map[string]string `json:"devDependencies"`
	OptionalDependencies map[string]string `json:"optionalDependencies"`
	PeerDependencies     map[string]string `json:"peerDependencies"`
	Scripts              map[string]string `json:"scripts"`
	PublishConfig        json.RawMessage   `json:"publishConfig"`

	// Populated by Analyzer.Analyze before detection. Not part of the JSON
	// schema; lowercase to keep them out of struct-tag-based marshaling.
	path string
	raw  []byte
}

// --- parsing ---

func parseManifest(content []byte) (*manifest, error) {
	var m manifest
	if err := json.Unmarshal(content, &m); err != nil {
		return nil, err
	}
	return &m, nil
}

// sanitizeGitURL strips an embedded `user:password@` (or token `@`) from
// a dependency version string so a credentialed git spec like
// `git+https://user:token@github.com/org/repo.git` does not show up
// verbatim in scan output. Aguara's credential redaction only scrubs the
// credential-leak category, so supply-chain findings that emit a raw
// version must self-sanitize.
func sanitizeGitURL(version string) string {
	// Trim before scheme matching: isGitDep already trims, so a value
	// like " git+https://user:token@github.com/org/repo.git " is treated
	// as a git dep upstream. Without trimming here, the leading space
	// breaks the HasPrefix check and the raw credential survives into
	// Description / MatchedText.
	v := strings.TrimSpace(version)
	// Only URL forms can carry credentials. Match the scheme then look
	// for an `@` that separates `userinfo` from `host`. Drop the
	// userinfo block.
	for _, scheme := range []string{"git+ssh://", "git+https://", "git+http://", "git+git://", "git://", "https://", "http://", "ssh://"} {
		if !strings.HasPrefix(strings.ToLower(v), scheme) {
			continue
		}
		rest := v[len(scheme):]
		at := strings.Index(rest, "@")
		slash := strings.Index(rest, "/")
		// `@` must appear before the first `/` to belong to userinfo;
		// otherwise it is part of a path (e.g. `@scope/...`).
		if at > 0 && (slash < 0 || at < slash) {
			return v[:len(scheme)] + rest[at+1:]
		}
		break
	}
	return v
}

// keyTokenRegex compiles a regex that matches `"key"` followed by any
// JSON-allowed whitespace and then `:`. This tolerates valid manifests
// that format keys with extra space (e.g. `"key" : value`) which a plain
// `"key":` substring search would miss.
func keyTokenRegex(key string) *regexp.Regexp {
	return regexp.MustCompile(`"` + regexp.QuoteMeta(key) + `"\s*:`)
}

// findKeyLine returns the 1-based line number of the first `"key":`
// token in raw (whitespace-tolerant), or 0 if it is not present. Used
// to anchor findings on top-level keys such as publishConfig or scripts.
func findKeyLine(raw []byte, key string) int {
	if len(raw) == 0 || key == "" {
		return 0
	}
	loc := keyTokenRegex(key).FindIndex(raw)
	if loc == nil {
		return 0
	}
	return bytes.Count(raw[:loc[0]], []byte{'\n'}) + 1
}

// findDepLine returns the 1-based line of the dependency entry named
// `name` inside the JSON section `section`. Searching is scoped to the
// bytes after the section's `"section":` token so a key that happens to
// match an unrelated field (the package "name", a script key, etc.)
// does not anchor the finding to the wrong line. Both the section and
// the dep key are matched with optional whitespace between `"key"` and
// `:` so valid JSON formatting variants do not regress to line 0.
func findDepLine(raw []byte, section, name string) int {
	if len(raw) == 0 || section == "" || name == "" {
		return 0
	}
	secLoc := keyTokenRegex(section).FindIndex(raw)
	if secLoc == nil {
		return 0
	}
	rel := keyTokenRegex(name).FindIndex(raw[secLoc[1]:])
	if rel == nil {
		return 0
	}
	abs := secLoc[1] + rel[0]
	return bytes.Count(raw[:abs], []byte{'\n'}) + 1
}

// --- classifiers ---

// isRemoteTarballDep reports whether a dependency value points at a
// remote URL tarball: an http(s) URL that is not a git source. npm v12
// defaults allow-remote to "none", so resolving one requires an
// explicit install-trust exception.
func isRemoteTarballDep(version string) bool {
	v := strings.ToLower(strings.TrimSpace(version))
	if !strings.HasPrefix(v, "http://") && !strings.HasPrefix(v, "https://") {
		return false
	}
	return !isGitDep(version)
}

// detectInstallTrustReadiness emits the npm v12 readiness findings:
// INFO-severity, per dependency, for sources npm v12 will no longer
// resolve without an explicit trust exception (allow-git /
// allow-remote). This is posture information, not an attack signal: it
// tells a team which exceptions the project will need before the v12
// upgrade, and it never fails a build on default thresholds.
//
// Suppression mirrors this file's existing philosophy of one finding
// per concern:
//   - git readiness is suppressed entirely when a lifecycle script is
//     present (NPM_LIFECYCLE_GIT_001 already covers every git dep with
//     higher severity) and skips optionalDependencies (covered by
//     NPM_OPTIONAL_GIT_001);
//   - remote-tarball readiness has no stronger sibling and always
//     fires. file:, link: and workspace: sources are NOT findings:
//     npm v12 keeps allow-file / allow-directory at their current
//     defaults.
func detectInstallTrustReadiness(pkg *manifest) []types.Finding {
	sections := []struct {
		name string
		deps map[string]string
	}{
		{"dependencies", pkg.Dependencies},
		{"devDependencies", pkg.DevDependencies},
		{"optionalDependencies", pkg.OptionalDependencies},
		{"peerDependencies", pkg.PeerDependencies},
	}
	gitCovered := hasLifecycleScript(pkg.Scripts)
	var findings []types.Finding
	for _, sec := range sections {
		names := make([]string, 0, len(sec.deps))
		for n := range sec.deps {
			names = append(names, n)
		}
		sort.Strings(names)
		for _, n := range names {
			v := sec.deps[n]
			ref := sec.name + "." + n
			switch {
			case isGitDep(v):
				if gitCovered || sec.name == "optionalDependencies" {
					continue // a stronger git rule already covers this dep
				}
				safe := sanitizeTarballURL(v)
				findings = append(findings, types.Finding{
					RuleID:   RuleGitInstallTrust,
					RuleName: ruleInfo[RuleGitInstallTrust].Name,
					Severity: types.SeverityInfo,
					Category: ruleInfo[RuleGitInstallTrust].Category,
					Description: ref + " resolves from a git source (" + safe + "). npm v12 " +
						"readiness: npm v12 defaults allow-git to \"none\", so installing this " +
						"project will require an explicit install-trust exception for git " +
						"dependencies.",
					FilePath:    pkg.path,
					Line:        findDepLine(pkg.raw, sec.name, n),
					MatchedText: ref + " = " + safe,
					Analyzer:    AnalyzerName,
					Confidence:  0.9,
					Remediation: "Replace the git source with a registry version where possible. " +
						"If it is genuinely required, grant a reviewed exception (prefer " +
						"allow-git=root over all) before upgrading to npm v12.",
				})
			case isRemoteTarballDep(v):
				safe := sanitizeTarballURL(v)
				findings = append(findings, types.Finding{
					RuleID:   RuleRemoteInstallTrust,
					RuleName: ruleInfo[RuleRemoteInstallTrust].Name,
					Severity: types.SeverityInfo,
					Category: ruleInfo[RuleRemoteInstallTrust].Category,
					Description: ref + " resolves from a remote URL tarball (" + safe + "). npm " +
						"v12 readiness: npm v12 defaults allow-remote to \"none\", so installing " +
						"this project will require an explicit install-trust exception for " +
						"remote tarballs.",
					FilePath:    pkg.path,
					Line:        findDepLine(pkg.raw, sec.name, n),
					MatchedText: ref + " = " + safe,
					Analyzer:    AnalyzerName,
					Confidence:  0.9,
					Remediation: "Replace the URL tarball with a registry version where " +
						"possible. If it is genuinely required, pin the exact tarball and " +
						"grant a reviewed exception (prefer allow-remote=root over all) before " +
						"upgrading to npm v12.",
				})
			}
		}
	}
	return findings
}

// isHostedGitDomainURL reports whether an http(s) URL matches a shape
// npm's hosted-git-info resolves as a git spec. The host comparison
// strips userinfo and port (ground truth: "https://token@github.com/
// org/repo.git" and "https://github.com:443/org/repo.git" both gate as
// EALLOWGIT). Path handling approximates hosted-git-info's templates,
// bounded to the shapes ground-truthed against npm 11.16.0:
//
//	github.com/owner/repo[.git][/tree/...]  -> git
//	github.com/owner/repo/tarball|archive|releases/... -> remote
//	codeload.github.com/...                  -> remote (not in the set)
//	gist.github.com/<id>                     -> git
//	gitlab.com/group[/subgroup...]/repo      -> git (unless /-/ archive paths)
//	bitbucket.org/owner/repo                 -> git
//
// The approximation is deliberately bounded rather than a full
// hosted-git-info port; an exotic hosted endpoint that escapes it
// classifies as remote, and the cost is INFO-level readiness advice
// naming allow-remote instead of allow-git.
func isHostedGitDomainURL(u string) bool {
	rest := u[strings.Index(u, "://")+3:]
	host := rest
	path := ""
	if i := strings.IndexByte(rest, '/'); i >= 0 {
		host = rest[:i]
		path = strings.Trim(rest[i+1:], "/")
	}
	if i := strings.LastIndexByte(host, '@'); i >= 0 {
		host = host[i+1:] // strip userinfo
	}
	if i := strings.IndexByte(host, ':'); i >= 0 {
		host = host[:i] // strip port
	}
	host = strings.TrimPrefix(host, "www.")
	segs := 0
	if path != "" {
		segs = strings.Count(path, "/") + 1
	}
	switch host {
	case "github.com":
		path = strings.TrimSuffix(path, ".git")
		if segs == 2 {
			return true
		}
		// owner/repo/tree/<committish...> is a git ref; tarball/,
		// archive/, releases/ and anything else are download endpoints.
		if segs > 2 {
			parts := strings.SplitN(path, "/", 4)
			return parts[2] == "tree"
		}
		return false
	case "gist.github.com":
		return segs >= 1
	case "gitlab.com":
		// Subgroups give variable depth; gitlab's download endpoints
		// live under "/-/" (e.g. /-/archive/...).
		return segs >= 2 && !strings.Contains("/"+path+"/", "/-/")
	case "bitbucket.org":
		return segs == 2
	}
	return false
}

// hasTarballExt reports whether a URL path ends in an archive extension
// npm fetches as a remote tarball even on hosted-git domains (ground
// truth: /archive/x.tar.gz, /releases/download/.../pkg.tgz and .zip all
// gate as EALLOWREMOTE).
func hasTarballExt(u string) bool {
	for _, ext := range []string{".tgz", ".tar.gz", ".tar", ".zip"} {
		if strings.HasSuffix(u, ext) {
			return true
		}
	}
	return false
}

// sanitizeTarballURL prepares a remote source spec (tarball or git URL) for inclusion in
// finding output: userinfo is stripped (via sanitizeGitURL) and the
// query string is removed entirely, because signed URLs and
// query-string auth (?token=..., ?X-Amz-Signature=...) carry
// credentials that must not be copied into Description/MatchedText.
func sanitizeTarballURL(version string) string {
	safe := sanitizeGitURL(version)
	if i := strings.Index(safe, "?"); i >= 0 {
		safe = safe[:i] + "?[query removed]"
	}
	return safe
}

// isGitDep reports whether a dependency value points at a git source rather
// than the npm registry. npm accepts several shorthand forms; we cover the
// ones that actually resolve to executable git content during install.
func isGitDep(version string) bool {
	v := strings.TrimSpace(version)
	if v == "" {
		return false
	}
	lower := strings.ToLower(v)
	// Strip an optional #ref fragment before suffix-matching so
	// "https://github.com/owner/repo.git#abc1234" still classifies as git.
	noFrag := lower
	if i := strings.Index(noFrag, "#"); i >= 0 {
		noFrag = noFrag[:i]
	}
	// Strip a query string as well: "repo.git?token=x" is still a git
	// URL to npm.
	if i := strings.Index(noFrag, "?"); i >= 0 {
		noFrag = noFrag[:i]
	}
	switch {
	case strings.HasPrefix(lower, "git+"):
		return true
	case strings.HasPrefix(lower, "git://"):
		return true
	case strings.HasPrefix(lower, "github:"):
		return true
	case strings.HasPrefix(lower, "gitlab:"):
		return true
	case strings.HasPrefix(lower, "bitbucket:"):
		return true
	case strings.HasPrefix(lower, "gist:"):
		return true
	case strings.HasPrefix(lower, "ssh://"):
		// Ground truth npm 11.16.0: ssh:// specs resolve as git
		// (EALLOWGIT).
		return true
	case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
		// Ground truth npm 11.16.0: a plain http(s) URL is a git spec
		// ONLY when hosted-git-info recognizes the host (github.com,
		// gist.github.com, gitlab.com, bitbucket.org - any path depth,
		// including /tree/<branch> and gitlab subgroups) and the path is
		// not an archive download (.tgz/.tar.gz/.tar/.zip, which gate as
		// EALLOWREMOTE even on those hosts). A .git suffix on an unknown
		// host is still a REMOTE dependency (EALLOWREMOTE) unless the
		// spec uses an explicit git+ scheme.
		return isHostedGitDomainURL(noFrag) && !hasTarballExt(noFrag)
	}
	// owner/repo or owner/repo#ref shorthand. Strip the #fragment first so
	// a valid semver-fragment shorthand like "owner/repo#semver:^1.2.3"
	// still classifies as a git dep (the ^ is inside the fragment).
	core := v
	if idx := strings.Index(core, "#"); idx >= 0 {
		core = core[:idx]
	}
	// Reject local-path specs and non-git prefixes before the slash count.
	// npm accepts "./runner", "../setup", "/abs/path", and "~/home/path"
	// as filesystem dependencies; they happen to contain exactly one
	// slash and would otherwise be misread as owner/repo shorthand.
	if strings.HasPrefix(core, "./") || strings.HasPrefix(core, "../") ||
		strings.HasPrefix(core, "/") || strings.HasPrefix(core, "~/") {
		return false
	}
	if strings.HasPrefix(core, "file:") || strings.HasPrefix(core, "link:") ||
		strings.HasPrefix(core, "workspace:") || strings.HasPrefix(core, "npm:") {
		return false
	}
	// Whitespace and version-range characters disqualify the core (the
	// #fragment was stripped above, so semver-style fragments do not
	// trip this guard).
	if strings.ContainsAny(core, " \t\n") {
		return false
	}
	if strings.ContainsAny(core, "^~<>=*|") {
		return false
	}
	if strings.HasPrefix(core, "@") {
		// Scoped names use the registry, not a git shorthand.
		return false
	}
	// Require exactly one slash separating owner/repo.
	if strings.Count(core, "/") != 1 {
		return false
	}
	return true
}

// lifecycleScripts are the npm script names that run automatically as
// part of `npm install`. A package that defines any of these AND pulls a
// dependency from a mutable git source can execute attacker code on the
// install user's machine.
//
// Strictly install-time per npm docs: preinstall, install, postinstall.
// prepare and its pre/post hooks also run during install (on git
// dependencies and when packing a local install). prepublish is
// deprecated but npm still executes it during `npm install` / `npm ci`,
// so we keep it; prepublishOnly is the explicitly publish-only variant
// and stays excluded. prepack and postpack run only on publish/pack and
// are also excluded so publish-only manifests do not falsely trigger.
var lifecycleScripts = []string{
	"preinstall",
	"install",
	"postinstall",
	"prepublish",
	"preprepare",
	"prepare",
	"postprepare",
}

// hasLifecycleScript reports whether scripts defines any install-time hook
// with a non-empty body. Empty-body lifecycle entries (e.g. placeholder
// `"postinstall": ""`) do not execute project code and would otherwise
// false-positive on harmless manifests under --fail-on high.
func hasLifecycleScript(scripts map[string]string) bool {
	for _, k := range lifecycleScripts {
		if body, ok := scripts[k]; ok && strings.TrimSpace(body) != "" {
			return true
		}
	}
	return false
}

// suspiciousPackageRoots matches dependency names that historically front
// install-time payloads in real supply-chain incidents. The list is
// intentionally short; broadening it costs false positives on legitimate
// setup/runtime libraries.
var suspiciousPackageRoots = []string{
	"setup",
	"install",
	"init",
	"runner",
	"runtime",
	"bootstrap",
	"loader",
}

// isSuspiciousPackageName reports whether a dependency name's last path
// segment matches a known incident fingerprint root, with or without a
// suffix (e.g. "setup", "setup-utils", "node-setup").
func isSuspiciousPackageName(name string) bool {
	if name == "" {
		return false
	}
	// Scoped names: @scope/leaf — only the leaf segment is the package's
	// own identifier; the scope is the publisher's namespace.
	tail := name
	if strings.HasPrefix(tail, "@") {
		if idx := strings.Index(tail, "/"); idx >= 0 {
			tail = tail[idx+1:]
		}
	}
	lower := strings.ToLower(tail)
	for _, root := range suspiciousPackageRoots {
		if lower == root {
			return true
		}
		if strings.HasPrefix(lower, root+"-") || strings.HasSuffix(lower, "-"+root) {
			return true
		}
		if strings.HasPrefix(lower, root) && len(lower) > len(root) && !isAlpha(lower[len(root)]) {
			return true
		}
	}
	return false
}

func isAlpha(b byte) bool {
	return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
}

// scriptMentionsPublish reports whether any script runs an npm-flavored
// publish command. The publish ID is the sink that turns install-time
// execution into a registry-write capability.
func scriptMentionsPublish(scripts map[string]string) bool {
	needles := []string{
		"npm publish", "pnpm publish", "yarn publish", "yarn npm publish",
		"bun publish",
	}
	for _, body := range scripts {
		lower := strings.ToLower(body)
		for _, n := range needles {
			if strings.Contains(lower, n) {
				return true
			}
		}
	}
	return false
}

// execScriptKeys are conventional npm script names whose declared intent
// is to install, build, lint, or test the package — regardless of the
// command they shell out to. `"build": "tsc"`, `"test": "vitest"`, and
// `"lint": "eslint ."` are all install-time code paths even though the
// body strings do not contain a package-manager verb. Install-lifecycle
// hooks are intentionally excluded; those are covered by
// NPM_LIFECYCLE_GIT_001 and would otherwise double-cover an unrelated
// chain in this rule.
var execScriptKeys = map[string]bool{
	"build":     true,
	"prebuild":  true,
	"postbuild": true,
	"test":      true,
	"pretest":   true,
	"posttest":  true,
	"lint":      true,
	"prelint":   true,
	"postlint":  true,
	"typecheck": true,
	"compile":   true,
	"bundle":    true,
}

// scriptMentionsInstallOrBuild reports whether any script invokes a package
// manager install/build/test verb, or carries a conventional script key
// (build / test / lint / ...) whose body runs project code regardless of
// the command. This is the install-time-code half of the publish-surface
// chain.
func scriptMentionsInstallOrBuild(scripts map[string]string) bool {
	// Conventional script keys count as execution paths even when the
	// body is `tsc`, `vitest`, etc. that no package-manager-verb match
	// would catch.
	for key, body := range scripts {
		if execScriptKeys[strings.ToLower(strings.TrimSpace(key))] {
			if strings.TrimSpace(body) != "" {
				return true
			}
		}
	}
	// Substring needles cover the common multi-word forms.
	needles := []string{
		"npm install", "npm i ", "npm ci", "npm run", "npm test", "npm exec",
		"pnpm install", "pnpm i ", "pnpm run", "pnpm test", "pnpm exec",
		"yarn install", "yarn run", "yarn test",
		"bun install", "bun i ", "bun run", "bun test",
	}
	// Exact short forms: when a script body is just `npm i`, `pnpm i`,
	// `yarn`, `bun i`, etc., the trailing-space needles above miss them.
	exact := map[string]bool{
		"npm i": true, "npm install": true, "npm ci": true,
		"pnpm i": true, "pnpm install": true,
		"yarn": true, "yarn install": true,
		"bun i": true, "bun install": true,
	}
	for _, body := range scripts {
		lower := strings.ToLower(body)
		for _, n := range needles {
			if strings.Contains(lower, n) {
				return true
			}
		}
		// Match the script body as a whole word after trimming, and also
		// each newline-separated line for multi-line shell scripts.
		for _, line := range strings.Split(lower, "\n") {
			if exact[strings.TrimSpace(line)] {
				return true
			}
		}
	}
	return false
}

// manifestReferencesProvenance reports whether the manifest enables a
// trusted-publishing / provenance / OIDC surface. The check is value-aware
// for the provenance flag (a literal `"provenance": false` does not count)
// and substring-based for the other trust signals, which only appear as
// references rather than as booleans.
func manifestReferencesProvenance(pkg *manifest) bool {
	if pkg == nil {
		return false
	}
	// Honor an explicit publishConfig.provenance boolean. Only `true`
	// counts; missing or `false` falls through to the substring checks
	// below, which look for other ways a trust surface might be wired.
	if len(pkg.PublishConfig) > 0 {
		var pc struct {
			Provenance *bool `json:"provenance"`
		}
		if err := json.Unmarshal(pkg.PublishConfig, &pc); err == nil {
			if pc.Provenance != nil && *pc.Provenance {
				return true
			}
		}
	}
	if len(pkg.raw) == 0 {
		return false
	}
	lower := strings.ToLower(string(pkg.raw))
	// `--provenance` is value-aware: a script that explicitly opts out
	// with `--provenance=false` or `--provenance=0` does not enable the
	// trust surface, so the substring search alone would false-positive.
	if hasEnabledProvenanceFlag(lower) {
		return true
	}
	// The remaining needles are either a documented phrase or a full
	// GitHub Actions env var prefix; none occur as dependency-name
	// substrings in practice. Bare "oidc", "id-token", and "id_token"
	// are dropped because legitimate libraries (oidc-client-ts,
	// jwt-id-token-handler) would otherwise false-positive.
	needles := []string{
		"trusted-publishing",
		"trustedpublishing",
		"actions_id_token_request",
	}
	for _, n := range needles {
		if strings.Contains(lower, n) {
			return true
		}
	}
	return false
}

// hasEnabledProvenanceFlag reports whether the lower-cased input mentions
// the npm `--provenance` CLI flag in a way that enables provenance. A
// bare `--provenance` defaults to true; `--provenance=true` is explicit
// true; `--provenance=false` and `--provenance=0` are opt-outs that must
// not be read as a trust signal.
func hasEnabledProvenanceFlag(s string) bool {
	for off := 0; off < len(s); {
		i := strings.Index(s[off:], "--provenance")
		if i < 0 {
			return false
		}
		abs := off + i + len("--provenance")
		if abs >= len(s) || s[abs] != '=' {
			return true
		}
		// `=` follows; inspect the value.
		tail := s[abs+1:]
		switch {
		case strings.HasPrefix(tail, "false"):
			// opt-out; keep scanning in case another instance enables it.
		case strings.HasPrefix(tail, "0"):
			// opt-out variant; keep scanning.
		default:
			// =true or any other value treats as enabled.
			return true
		}
		off = abs + 1
	}
	return false
}

// --- detection ---

// depEntry pairs a dependency name with its declared version and the
// dependency section it came from (so findings can name the chain
// precisely). Order is deterministic.
type depEntry struct {
	Name    string
	Version string
	Section string
}

func collectDeps(pkg *manifest) []depEntry {
	var out []depEntry
	for _, kv := range []struct {
		name string
		m    map[string]string
	}{
		{"dependencies", pkg.Dependencies},
		{"devDependencies", pkg.DevDependencies},
		{"optionalDependencies", pkg.OptionalDependencies},
		{"peerDependencies", pkg.PeerDependencies},
	} {
		names := make([]string, 0, len(kv.m))
		for k := range kv.m {
			names = append(names, k)
		}
		sort.Strings(names)
		for _, n := range names {
			out = append(out, depEntry{Name: n, Version: kv.m[n], Section: kv.name})
		}
	}
	return out
}

func detect(pkg *manifest) []types.Finding {
	var out []types.Finding
	out = append(out, detectLifecycleGit(pkg)...)
	out = append(out, detectOptionalGit(pkg)...)
	out = append(out, detectInstallTrustReadiness(pkg)...)
	if f := detectPublishSurface(pkg); f != nil {
		out = append(out, *f)
	}
	out = append(out, detectLocalJSLifecycle(pkg)...)
	return out
}

// detectLocalJSLifecycle emits SUPPLY_026 for each lifecycle script whose
// body runs Node or Bun on local JavaScript (a .js/.cjs/.mjs file or an
// -e/--eval inline payload). This is the analyzer home for the rule that
// previously lived as a flat YAML regex: walking the parsed scripts map
// is structurally correct where a regex over the raw manifest is not.
func detectLocalJSLifecycle(pkg *manifest) []types.Finding {
	if len(pkg.Scripts) == 0 {
		return nil
	}
	var findings []types.Finding
	for _, key := range localJSLifecycleKeys {
		body, ok := pkg.Scripts[key]
		if !ok {
			continue
		}
		body = strings.TrimSpace(body)
		runtime := localJSRuntime(body)
		if runtime == "" {
			continue
		}
		findings = append(findings, types.Finding{
			RuleID:   RuleLocalJSLifecycle,
			RuleName: ruleInfo[RuleLocalJSLifecycle].Name,
			Severity: ruleInfo[RuleLocalJSLifecycle].SeverityLevel(),
			Category: ruleInfo[RuleLocalJSLifecycle].Category,
			Description: "package.json runs " + runtime + " on local JavaScript from the `" + key +
				"` lifecycle script, an install-time execution path. In npm versions or " +
				"configurations where dependency scripts are allowed, this code runs during " +
				"install. " +
				"Install-time execution of a package's own JS is the first hop of supply-chain " +
				"droppers; the Red Hat/Miasma worm shipped a preinstall hook running `node index.js`.",
			FilePath: pkg.path,
			// Scope the line lookup to the scripts object so a same-named
			// key elsewhere (a dependency or config key called "install")
			// does not anchor the finding to an unrelated earlier line.
			Line:        findDepLine(pkg.raw, "scripts", key),
			MatchedText: key + " = " + body,
			Analyzer:    AnalyzerName,
			Confidence:  0.9,
			Remediation: "Install-time code execution should be unnecessary for most packages. " +
				"Read the referenced script in a clean clone before installing, prefer packages " +
				"with no install/preinstall/postinstall hooks, pin the dependency to a reviewed " +
				"version, and install with --ignore-scripts where possible.",
		})
	}
	return findings
}

// localJSRuntime returns "node" or "bun" when body runs that runtime on
// local JS (a file or an inline -e/--eval payload), or "" otherwise. node
// --version, husky install, npx-only, and build/test commands return "".
func localJSRuntime(body string) string {
	switch {
	case reNodeLocalJS.MatchString(body), reNodeEval.MatchString(body), reNodeLocalPath.MatchString(body):
		return "node"
	case reBunExec.MatchString(body):
		return "bun"
	default:
		return ""
	}
}

// detectLifecycleGit emits NPM_LIFECYCLE_GIT_001 for each git-sourced
// dependency in a manifest that also defines an install-time lifecycle
// script. The chain is "mutable code source" + "auto-run hook" — either
// half on its own is fine.
func detectLifecycleGit(pkg *manifest) []types.Finding {
	if !hasLifecycleScript(pkg.Scripts) {
		return nil
	}
	var findings []types.Finding
	for _, d := range collectDeps(pkg) {
		if !isGitDep(d.Version) {
			continue
		}
		sev := types.SeverityHigh
		if d.Section == "optionalDependencies" && isSuspiciousPackageName(d.Name) {
			sev = types.SeverityCritical
		}
		safeVersion := sanitizeTarballURL(d.Version)
		findings = append(findings, types.Finding{
			RuleID:   RuleLifecycleGit,
			RuleName: ruleInfo[RuleLifecycleGit].Name,
			Severity: sev,
			Category: ruleInfo[RuleLifecycleGit].Category,
			Description: "package.json pulls " + d.Name + " from a git source (" + safeVersion +
				") and defines an npm install-time lifecycle script. On `npm install`, " +
				"the git ref can change between resolutions and the lifecycle script will " +
				"execute whatever code is at the resolved ref, with the install user's " +
				"environment.",
			FilePath:    pkg.path,
			Line:        findDepLine(pkg.raw, d.Section, d.Name),
			MatchedText: d.Section + "." + d.Name + " = " + safeVersion + " + install-time script",
			Analyzer:    AnalyzerName,
			Confidence:  0.9,
			Remediation: "Pin npm dependencies to registry versions with lockfile integrity. " +
				"If a git source is required, audit the exact commit and use --ignore-scripts " +
				"during install. Remove install-time lifecycle hooks unless they are essential.",
		})
	}
	return findings
}

// detectOptionalGit emits NPM_OPTIONAL_GIT_001 for each optionalDependency
// resolving to a git source. Optional deps are special: they install
// silently on platforms where the resolution succeeds, so a git-sourced
// optional dep is a quieter compromise vector than the same shape in
// dependencies.
func detectOptionalGit(pkg *manifest) []types.Finding {
	if len(pkg.OptionalDependencies) == 0 {
		return nil
	}
	// When a lifecycle script is present, NPM_LIFECYCLE_GIT_001 already
	// covers every optional git dep with higher severity and a strictly
	// stronger description (install-time execution). Suppress the
	// optional-git rule in that case to avoid two findings that the
	// scanner's cross-rule dedup would silently collapse.
	if hasLifecycleScript(pkg.Scripts) {
		return nil
	}
	names := make([]string, 0, len(pkg.OptionalDependencies))
	for k := range pkg.OptionalDependencies {
		names = append(names, k)
	}
	sort.Strings(names)
	var findings []types.Finding
	for _, n := range names {
		v := pkg.OptionalDependencies[n]
		if !isGitDep(v) {
			continue
		}
		sev := types.SeverityMedium
		if isSuspiciousPackageName(n) {
			sev = types.SeverityHigh
		}
		safeVersion := sanitizeTarballURL(v)
		findings = append(findings, types.Finding{
			RuleID:   RuleOptionalGit,
			RuleName: ruleInfo[RuleOptionalGit].Name,
			Severity: sev,
			Category: ruleInfo[RuleOptionalGit].Category,
			Description: "optionalDependencies." + n + " resolves to a git source (" + safeVersion +
				"). Optional dependencies install silently when resolution succeeds, so a " +
				"mutable git ref here is a quieter supply-chain entry point than the same " +
				"shape in dependencies.",
			FilePath: pkg.path,
			// Per-dep line anchor, scoped to optionalDependencies so a
			// dep name that also appears as the package "name" or as a
			// script key does not anchor to the wrong line.
			Line:        findDepLine(pkg.raw, "optionalDependencies", n),
			MatchedText: "optionalDependencies." + n + " = " + safeVersion,
			Analyzer:    AnalyzerName,
			Confidence:  0.8,
			Remediation: "Avoid git sources in optionalDependencies. If you must use one, pin " +
				"it to a specific commit and review the contents before each version bump.",
		})
	}
	return findings
}

// detectPublishSurface emits NPM_PUBLISH_SURFACE_001 when the package
// exposes a publish surface (publishConfig present, or scripts run a
// publish command) AND the same scripts also run install/build/test code
// AND the manifest references provenance / trusted-publishing / OIDC
// strings (i.e. the publish path is being given elevated trust).
func detectPublishSurface(pkg *manifest) *types.Finding {
	hasPublishConfig := len(pkg.PublishConfig) > 0 && strings.TrimSpace(string(pkg.PublishConfig)) != "null"
	hasPublishScript := scriptMentionsPublish(pkg.Scripts)
	if !hasPublishConfig && !hasPublishScript {
		return nil
	}
	if !scriptMentionsInstallOrBuild(pkg.Scripts) {
		return nil
	}
	if !manifestReferencesProvenance(pkg) {
		return nil
	}
	// Anchor at publishConfig if declared, otherwise at the scripts block
	// where the publish command lives.
	line := findKeyLine(pkg.raw, "publishConfig")
	if line == 0 {
		line = findKeyLine(pkg.raw, "scripts")
	}
	return &types.Finding{
		RuleID:   RulePublishSurface,
		RuleName: ruleInfo[RulePublishSurface].Name,
		Severity: ruleInfo[RulePublishSurface].SeverityLevel(),
		Category: ruleInfo[RulePublishSurface].Category,
		Description: "package.json defines a publish surface (publishConfig or a publish " +
			"script) alongside install/build/test scripts and references provenance / " +
			"trusted-publishing / OIDC. Install-time scripts running in the same context " +
			"as the publish surface can mint or relay a trusted publishing token before " +
			"the publish step runs.",
		FilePath:    pkg.path,
		Line:        line,
		MatchedText: "publishConfig/publish script + install-or-build script + provenance/OIDC reference",
		Analyzer:    AnalyzerName,
		Confidence:  0.75,
		Remediation: "Split publishing into its own CI job that consumes pre-built, verified " +
			"artifacts. Grant trusted-publishing / OIDC scope only to the publish job and " +
			"keep install/build/test out of that job's execution graph.",
	}
}
