// Package modules parses dependency-manifest files and emits
// KindModule nodes plus EdgeDependsOnModule edges so agents can
// answer "what external packages does this repo depend on" or
// "which files import lodash@4" with a single graph query.
//
// Scope (v1): Go's go.mod. Other ecosystems (package.json, pnpm-
// lock, requirements.txt, Cargo.toml, etc.) are tracked as future
// follow-ups; the scanner's API is shaped so they can land
// alongside without changing the call sites.
package modules

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

	"github.com/pelletier/go-toml/v2"

	"github.com/zzet/gortex/internal/graph"
)

// Spec is one parsed dependency entry from a manifest file.
// Indirect is true for entries marked `// indirect` in go.mod —
// agents may want to scope queries to direct deps only, so the
// flag rides along on the graph node's meta.
type Spec struct {
	Ecosystem string // "go", "npm", "pypi", … — for v1 always "go"
	Path      string // module path / package name
	Version   string // version string, "" for unpinned
	Indirect  bool
	Replace   string // replacement path when go.mod has a `replace` directive, "" otherwise
	Line      int    // 1-based line in the manifest where the spec was found
	// Alias holds the real package name when an npm dependency is
	// declared as an alias — `"shared": "npm:@acme/shared-lib@1.4.0"`
	// makes Path="shared", Version the verbatim `npm:` string, and
	// Alias="@acme/shared-lib". Empty for every non-aliased entry.
	Alias string
}

// ParseGoMod walks go.mod source and returns one Spec per
// dependency. Handles three shapes:
//
//	require github.com/foo/bar v1.0.0          // single-line
//	require ( ... )                            // grouped block
//	replace github.com/foo/bar => ./local/bar  // local replacements
//
// `// indirect` markers attach to the relevant Spec. Comments and
// blank lines are skipped. Errors silently produce a partial Spec
// list — the indexer treats malformed go.mod as best-effort, not
// fatal.
func ParseGoMod(source []byte) []Spec {
	if len(source) == 0 {
		return nil
	}
	var specs []Spec
	scanner := bufio.NewScanner(bytes.NewReader(source))
	scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
	inRequire := false
	inReplace := false
	lineNum := 0
	replaces := map[string]string{}
	for scanner.Scan() {
		lineNum++
		raw := scanner.Text()
		line := strings.TrimSpace(raw)
		if line == "" || strings.HasPrefix(line, "//") {
			continue
		}
		// Block markers.
		switch {
		case strings.HasPrefix(line, "require ("):
			inRequire = true
			continue
		case strings.HasPrefix(line, "replace ("):
			inReplace = true
			continue
		case line == ")":
			inRequire = false
			inReplace = false
			continue
		}
		// Replace directives — collect first so we can stamp the
		// replacement onto the require Spec produced from the same
		// module path. Single-line and block forms both supported.
		if strings.HasPrefix(line, "replace ") || inReplace {
			from, to := parseReplace(line)
			if from != "" {
				replaces[from] = to
			}
			continue
		}
		// Require directives.
		var modulePath, version string
		var directiveLine = lineNum
		switch {
		case strings.HasPrefix(line, "require ") && !strings.Contains(line, "("):
			parts := strings.Fields(line)
			if len(parts) >= 3 {
				modulePath = parts[1]
				version = parts[2]
			}
		case inRequire:
			parts := strings.Fields(line)
			if len(parts) >= 2 && strings.Contains(parts[0], "/") {
				modulePath = parts[0]
				version = parts[1]
			}
		}
		if modulePath == "" {
			continue
		}
		spec := Spec{
			Ecosystem: "go",
			Path:      modulePath,
			Version:   version,
			Indirect:  strings.Contains(raw, "// indirect"),
			Line:      directiveLine,
		}
		specs = append(specs, spec)
	}
	// Stamp replace targets onto the matching require Spec — done
	// after the full pass so block-form replaces declared after
	// requires still attach correctly.
	for i := range specs {
		if to, ok := replaces[specs[i].Path]; ok {
			specs[i].Replace = to
		}
	}
	return specs
}

// ParsePackageJSON walks an npm-style package.json file's
// dependency blocks (`dependencies`, `devDependencies`,
// `peerDependencies`, `optionalDependencies`) and returns one
// Spec per declared package. The Indirect flag is repurposed for
// devDependencies/peerDependencies/optionalDependencies — they
// aren't "indirect" in the same Go-module sense, but the flag
// lets agents scope to "production-only" deps without a separate
// axis. The exact source block lives on Replace as a tag string
// (not the most semantically clean home, but it avoids growing
// the Spec struct for an ecosystem-specific field) — production
// deps get an empty Replace.
//
// Version strings are kept verbatim — npm semver ranges
// (`^1.2.0`, `~3.4.1`, `>=2 <3`) are not normalised, since
// resolved-versions belong in the lockfile, not package.json.
// A future package-lock.json / pnpm-lock.yaml extractor will
// supersede the version string with the resolved one.
func ParsePackageJSON(source []byte) []Spec {
	if len(source) == 0 {
		return nil
	}
	var manifest struct {
		Dependencies         map[string]string `json:"dependencies"`
		DevDependencies      map[string]string `json:"devDependencies"`
		PeerDependencies     map[string]string `json:"peerDependencies"`
		OptionalDependencies map[string]string `json:"optionalDependencies"`
	}
	if err := json.Unmarshal(source, &manifest); err != nil {
		return nil
	}
	var specs []Spec
	specs = append(specs, packageJSONBlock(manifest.Dependencies, "")...)
	specs = append(specs, packageJSONBlock(manifest.DevDependencies, "dev")...)
	specs = append(specs, packageJSONBlock(manifest.PeerDependencies, "peer")...)
	specs = append(specs, packageJSONBlock(manifest.OptionalDependencies, "optional")...)
	return specs
}

// ParseNpmAlias extracts the real package name from an npm-alias
// dependency value. npm lets a dependency be re-pointed at a
// different package by giving its version range the `npm:` prefix:
//
//	"shared":  "npm:@acme/shared-lib@1.4.0" → "@acme/shared-lib"
//	"lodash4": "npm:lodash@4.17.21"         → "lodash"
//	"left":    "npm:left-pad"               → "left-pad"  (no version)
//
// The real name may itself be `@scope/name` (one slash) or a plain
// `name`. ok is false for any value without the `npm:` prefix or
// with an empty real name.
func ParseNpmAlias(value string) (realName string, ok bool) {
	const prefix = "npm:"
	if !strings.HasPrefix(value, prefix) {
		return "", false
	}
	rest := value[len(prefix):]
	if rest == "" {
		return "", false
	}
	// Strip the trailing `@version`. A leading `@` belongs to the
	// scope (`@acme/shared-lib`), so search for the version `@` from
	// the second byte onward — index 0 is never a version separator.
	name := rest
	if at := strings.Index(rest[1:], "@"); at >= 0 {
		name = rest[:at+1]
	}
	if name == "" {
		return "", false
	}
	return name, true
}

func packageJSONBlock(deps map[string]string, kind string) []Spec {
	if len(deps) == 0 {
		return nil
	}
	out := make([]Spec, 0, len(deps))
	for name, version := range deps {
		spec := Spec{
			Ecosystem: "npm",
			Path:      name,
			Version:   version,
			Indirect:  kind != "",
			Replace:   kind, // dev/peer/optional, "" for production
		}
		if alias, ok := ParseNpmAlias(version); ok {
			spec.Alias = alias
		}
		out = append(out, spec)
	}
	// Stable order per block — JSON map iteration is randomised, and
	// downstream consumers (BuildGraphArtifacts dedup, prefix-sort
	// in LinkImports) tolerate any order, but tests want
	// determinism.
	sort.Slice(out, func(i, j int) bool {
		return out[i].Path < out[j].Path
	})
	return out
}

// ParsePackageLockJSON walks an npm v2/v3 package-lock.json
// manifest's `packages` map and returns one Spec per declared
// package with its resolved version. The lockfile carries the
// concrete versions npm picked at install time, which is far
// more useful for "is lodash@4 still in use" queries than the
// semver ranges in package.json.
//
// v1 lockfiles (with a top-level `dependencies` map and no
// `packages` entry) are not handled — they're rare in the
// modern npm ecosystem and a parallel parser would mostly
// duplicate this logic. The lockfileVersion field on the
// manifest tells the caller which shape they have; the v1
// shape can land as another switch case alongside this when
// prioritised.
//
// The empty-string key in `packages` represents the root
// project itself — skipped, the same way ParsePackageJSON
// doesn't emit a self-reference. Production / dev classification
// follows the same Replace-as-tag convention as the rest:
// dev=true entries get Replace="dev"; everything else lands as
// production.
func ParsePackageLockJSON(source []byte) []Spec {
	if len(source) == 0 {
		return nil
	}
	var manifest struct {
		LockfileVersion int `json:"lockfileVersion"`
		Packages        map[string]struct {
			Version  string `json:"version"`
			Dev      bool   `json:"dev"`
			Optional bool   `json:"optional"`
		} `json:"packages"`
	}
	if err := json.Unmarshal(source, &manifest); err != nil {
		return nil
	}
	if manifest.LockfileVersion < 2 {
		// v1 lockfiles use a different shape we don't handle yet.
		return nil
	}
	specs := make([]Spec, 0, len(manifest.Packages))
	for key, entry := range manifest.Packages {
		if key == "" {
			// The root project's own manifest entry — skip.
			continue
		}
		// Keys are `node_modules/<name>` (or
		// `node_modules/<scope>/<name>` for scoped, or nested
		// `node_modules/foo/node_modules/bar` for transitive).
		// We strip the leading `node_modules/` prefix and use the
		// remainder verbatim as the package name. Nested paths
		// preserve their full chain, which keeps duplicate-name
		// entries at different versions distinguishable.
		name := strings.TrimPrefix(key, "node_modules/")
		if name == "" || entry.Version == "" {
			continue
		}
		spec := Spec{
			Ecosystem: "npm",
			Path:      name,
			Version:   entry.Version,
		}
		switch {
		case entry.Dev:
			spec.Indirect = true
			spec.Replace = "dev"
		case entry.Optional:
			spec.Indirect = true
			spec.Replace = "optional"
		}
		specs = append(specs, spec)
	}
	sort.Slice(specs, func(i, j int) bool {
		return specs[i].Path < specs[j].Path
	})
	return specs
}

// ParseYarnLock walks a yarn classic (v1) lockfile and returns
// one Spec per resolved dependency. Format is yarn's bespoke
// pseudo-yaml (key blocks separated by blank lines, each starting
// with one or more `<name>@<range>` keys followed by `version
// "X.Y.Z"`). The parser is line-oriented and intentionally
// minimal — it picks up the resolved version per dependency
// block and ignores the rest (resolved/integrity/dependencies
// fields aren't needed for graph identity).
//
// Berry (yarn 2+) lockfiles use real YAML and a different shape;
// they're recognised as a separate dispatch row when needed.
func ParseYarnLock(source []byte) []Spec {
	if len(source) == 0 {
		return nil
	}
	scanner := bufio.NewScanner(bytes.NewReader(source))
	scanner.Buffer(make([]byte, 64*1024), 16*1024*1024)
	var (
		specs        []Spec
		pendingNames []string
		seen         = make(map[string]struct{})
	)
	for scanner.Scan() {
		raw := scanner.Text()
		line := strings.TrimSpace(raw)
		if line == "" || strings.HasPrefix(line, "#") {
			pendingNames = nil
			continue
		}
		// Block header: a line that ends with `:` and contains
		// one or more `<name>@<range>` keys joined by `, `.
		if strings.HasSuffix(line, ":") && !strings.HasPrefix(raw, " ") && !strings.HasPrefix(raw, "\t") {
			pendingNames = parseYarnHeaderNames(strings.TrimSuffix(line, ":"))
			continue
		}
		// Inside a block — pick up the version line.
		if strings.HasPrefix(line, "version ") {
			version := strings.Trim(strings.TrimPrefix(line, "version "), `"`)
			if version == "" || len(pendingNames) == 0 {
				continue
			}
			for _, name := range pendingNames {
				key := name + "@" + version
				if _, ok := seen[key]; ok {
					continue
				}
				seen[key] = struct{}{}
				specs = append(specs, Spec{
					Ecosystem: "npm",
					Path:      name,
					Version:   version,
				})
			}
			pendingNames = nil
		}
	}
	sort.Slice(specs, func(i, j int) bool {
		if specs[i].Path != specs[j].Path {
			return specs[i].Path < specs[j].Path
		}
		return specs[i].Version < specs[j].Version
	})
	return specs
}

// parseYarnHeaderNames extracts the package names from a yarn-
// classic lockfile block header. Each header consists of one or
// more "<name>@<range>" entries separated by `, `. The names can
// be quoted ("@scope/pkg@^1.0.0") or bare. We strip the @range
// suffix and de-dup so a header like `lodash@^4.0.0, lodash@^4.17.0`
// yields just one "lodash" name.
func parseYarnHeaderNames(header string) []string {
	parts := strings.Split(header, ", ")
	out := make([]string, 0, len(parts))
	seen := make(map[string]struct{}, len(parts))
	for _, p := range parts {
		p = strings.TrimSpace(p)
		p = strings.Trim(p, `"`)
		if p == "" {
			continue
		}
		// Find the @ that introduces the version range, NOT the
		// scope @ at position 0. Scoped packages start with @ —
		// we want the second @, or the first when the package
		// is unscoped.
		var atIdx int
		if strings.HasPrefix(p, "@") {
			// scoped: skip past the leading @ then find the next
			// one.
			atIdx = strings.Index(p[1:], "@")
			if atIdx >= 0 {
				atIdx++
			}
		} else {
			atIdx = strings.Index(p, "@")
		}
		if atIdx < 0 {
			continue
		}
		name := p[:atIdx]
		if _, ok := seen[name]; ok {
			continue
		}
		seen[name] = struct{}{}
		out = append(out, name)
	}
	return out
}

// ParsePnpmLock walks a pnpm v6+ lockfile (real YAML, top-level
// `packages` map keyed by `/<name>@<version>` paths). Each entry
// resolves to one Spec with the canonical name+version.
func ParsePnpmLock(source []byte) []Spec {
	if len(source) == 0 {
		return nil
	}
	// pnpm-lock.yaml has the shape:
	//   packages:
	//     /react@18.2.0:
	//       resolution: {...}
	//     /lodash@4.17.21:
	//       ...
	// We only need the keys; the per-package metadata is large
	// (integrity hashes, transitive deps) and not needed for
	// graph identity. Parsing keys via line-oriented scan stays
	// independent of the YAML library's behaviour around large
	// nested structures.
	scanner := bufio.NewScanner(bytes.NewReader(source))
	scanner.Buffer(make([]byte, 64*1024), 16*1024*1024)
	var (
		specs      []Spec
		inPackages bool
		seen       = make(map[string]struct{})
	)
	for scanner.Scan() {
		raw := scanner.Text()
		trimmed := strings.TrimSpace(raw)
		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
			continue
		}
		// The top-level `packages:` key marks where the per-pkg
		// blocks live. A subsequent unindented top-level key
		// ends the section.
		if !strings.HasPrefix(raw, " ") && !strings.HasPrefix(raw, "\t") {
			inPackages = trimmed == "packages:"
			continue
		}
		if !inPackages {
			continue
		}
		// Per-pkg block headers look like `  /<name>@<version>:`
		// at exactly two-space indent. Sub-keys are deeper.
		if !strings.HasPrefix(raw, "  /") {
			continue
		}
		key := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(raw), "/"), ":")
		key = strings.TrimSuffix(key, "(") // strip peer-suffix start when present
		// The version is the last `@` segment, accounting for
		// scoped packages (which start with `@scope/`).
		var atIdx int
		if strings.HasPrefix(key, "@") {
			atIdx = strings.LastIndex(key, "@")
		} else {
			atIdx = strings.Index(key, "@")
		}
		if atIdx <= 0 {
			continue
		}
		name := key[:atIdx]
		version := key[atIdx+1:]
		// Trim peer-dep suffix (`@types/node@20.11.5_typescript@5.3.3`).
		if i := strings.Index(version, "_"); i >= 0 {
			version = version[:i]
		}
		if name == "" || version == "" {
			continue
		}
		dedupKey := name + "@" + version
		if _, ok := seen[dedupKey]; ok {
			continue
		}
		seen[dedupKey] = struct{}{}
		specs = append(specs, Spec{
			Ecosystem: "npm",
			Path:      name,
			Version:   version,
		})
	}
	sort.Slice(specs, func(i, j int) bool {
		if specs[i].Path != specs[j].Path {
			return specs[i].Path < specs[j].Path
		}
		return specs[i].Version < specs[j].Version
	})
	return specs
}

// ParsePyProject walks a Python pyproject.toml file's dependency
// declarations and returns one Spec per declared package. Both
// PEP 621 (`[project] dependencies = ["pkg>=1.0", ...]`) and
// Poetry (`[tool.poetry.dependencies] pkg = "^1.0"`) shapes are
// recognised; optional-dependency groups (`[project.optional-
// dependencies]`) are surfaced with the group name in Replace
// (analogous to how npm dev/peer/optional reuse the field).
//
// Version constraints are kept verbatim — PEP 440 specifiers and
// Poetry's caret/tilde ranges aren't normalised; resolved
// versions live in the lockfile (poetry.lock / uv.lock /
// requirements.txt) which a future extractor will supersede.
func ParsePyProject(source []byte) []Spec {
	if len(source) == 0 {
		return nil
	}
	var manifest struct {
		Project struct {
			Dependencies         []string            `toml:"dependencies"`
			OptionalDependencies map[string][]string `toml:"optional-dependencies"`
		} `toml:"project"`
		Tool struct {
			Poetry struct {
				Dependencies    map[string]any `toml:"dependencies"`
				DevDependencies map[string]any `toml:"dev-dependencies"`
			} `toml:"poetry"`
		} `toml:"tool"`
	}
	if err := toml.Unmarshal(source, &manifest); err != nil {
		return nil
	}

	var specs []Spec
	// PEP 621 dependencies: list of `name<spec>` strings.
	for _, dep := range manifest.Project.Dependencies {
		name, version := splitPEP508(dep)
		if name == "" {
			continue
		}
		specs = append(specs, Spec{
			Ecosystem: "pypi",
			Path:      name,
			Version:   version,
		})
	}
	// PEP 621 optional groups: each group name lands on Replace so
	// agents can scope by group later. Sorted within each group for
	// determinism.
	groupNames := make([]string, 0, len(manifest.Project.OptionalDependencies))
	for g := range manifest.Project.OptionalDependencies {
		groupNames = append(groupNames, g)
	}
	sort.Strings(groupNames)
	for _, group := range groupNames {
		entries := manifest.Project.OptionalDependencies[group]
		groupSpecs := make([]Spec, 0, len(entries))
		for _, dep := range entries {
			name, version := splitPEP508(dep)
			if name == "" {
				continue
			}
			groupSpecs = append(groupSpecs, Spec{
				Ecosystem: "pypi",
				Path:      name,
				Version:   version,
				Indirect:  true,
				Replace:   group,
			})
		}
		sort.Slice(groupSpecs, func(i, j int) bool {
			return groupSpecs[i].Path < groupSpecs[j].Path
		})
		specs = append(specs, groupSpecs...)
	}
	// Poetry shape: name → spec (string or table). When a table is
	// given we take its `version` field, when a bare string we keep
	// it verbatim.
	specs = append(specs, poetryBlock(manifest.Tool.Poetry.Dependencies, "")...)
	specs = append(specs, poetryBlock(manifest.Tool.Poetry.DevDependencies, "dev")...)
	return specs
}

// poetryBlock converts a Poetry `[tool.poetry.*-dependencies]`
// table into Spec entries. Values can be either bare version
// strings or tables containing a `version` key (for
// extras-bearing entries like `requests = { version = "^2.0",
// extras = ["socks"] }`).
func poetryBlock(deps map[string]any, kind string) []Spec {
	if len(deps) == 0 {
		return nil
	}
	out := make([]Spec, 0, len(deps))
	for name, raw := range deps {
		if name == "python" {
			// `python = "^3.10"` is a Python interpreter
			// constraint, not a dependency. Skip.
			continue
		}
		var version string
		switch v := raw.(type) {
		case string:
			version = v
		case map[string]any:
			if s, ok := v["version"].(string); ok {
				version = s
			}
		}
		out = append(out, Spec{
			Ecosystem: "pypi",
			Path:      name,
			Version:   version,
			Indirect:  kind != "",
			Replace:   kind,
		})
	}
	sort.Slice(out, func(i, j int) bool {
		return out[i].Path < out[j].Path
	})
	return out
}

// pep508Re strips a PEP 508 dependency string into name + version
// constraint. Examples: `requests>=2.0` → ("requests", ">=2.0"),
// `flask[async]==2.0.0` → ("flask", "==2.0.0"), `numpy` → ("numpy", "").
// Environment markers (`pkg; python_version<'3.9'`) and URL
// installs (`pkg @ https://...`) are dropped — they encode
// install-context, not the dependency identity our graph cares
// about.
var pep508Re = regexp.MustCompile(`^([A-Za-z0-9_.\-]+)`)

func splitPEP508(dep string) (name, version string) {
	dep = strings.TrimSpace(dep)
	// Drop environment markers and URL specs at the first ';' / ' @ '.
	if idx := strings.Index(dep, ";"); idx >= 0 {
		dep = strings.TrimSpace(dep[:idx])
	}
	if idx := strings.Index(dep, " @ "); idx >= 0 {
		dep = strings.TrimSpace(dep[:idx])
	}
	// Drop extras (the `[group]` suffix).
	if idx := strings.Index(dep, "["); idx >= 0 {
		end := strings.Index(dep, "]")
		if end < 0 {
			return "", ""
		}
		dep = strings.TrimSpace(dep[:idx]) + strings.TrimSpace(dep[end+1:])
	}
	m := pep508Re.FindString(dep)
	if m == "" {
		return "", ""
	}
	name = m
	version = strings.TrimSpace(dep[len(m):])
	return name, version
}

// ParseRequirementsTxt walks a pip requirements file and returns
// one Spec per non-comment, non-blank entry. Each line is parsed
// like a PEP 508 dependency; `-r other.txt` and `-e .` lines are
// ignored — they're install-mode directives, not dependency
// declarations.
//
// A future enhancement could chase `-r` includes recursively, but
// the first-order coverage from a single file is the
// 90th-percentile use case.
func ParseRequirementsTxt(source []byte) []Spec {
	if len(source) == 0 {
		return nil
	}
	var specs []Spec
	scanner := bufio.NewScanner(bytes.NewReader(source))
	scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		// Drop trailing inline comment.
		if i := strings.Index(line, "#"); i > 0 {
			line = strings.TrimSpace(line[:i])
		}
		if strings.HasPrefix(line, "-") {
			// `-r`, `-e`, `--index-url`, etc. — skip.
			continue
		}
		name, version := splitPEP508(line)
		if name == "" {
			continue
		}
		specs = append(specs, Spec{
			Ecosystem: "pypi",
			Path:      name,
			Version:   version,
		})
	}
	return specs
}

// ParseCargoToml walks a Rust Cargo.toml manifest's dependency
// tables and returns one Spec per declared crate. Three blocks
// are recognised: [dependencies] for production deps,
// [dev-dependencies] (and [dev_dependencies]) for test-only
// deps, and [build-dependencies] for build-script deps. The
// latter two stamp Replace = "dev" / "build" and Indirect = true,
// matching the npm/pyproject convention.
//
// Each entry's value can be either a bare version string ("1.0",
// "^1.0", "~1.0", "=1.0") or a table such as
// `serde = { version = "1.0", features = [...] }`. Git/path/
// registry-only deps without a version field are skipped — they
// resolve to a workspace-internal source rather than a versioned
// crates.io release, and the graph cares about external versioned
// identity.
func ParseCargoToml(source []byte) []Spec {
	if len(source) == 0 {
		return nil
	}
	var manifest struct {
		Dependencies      map[string]any `toml:"dependencies"`
		DevDependencies   map[string]any `toml:"dev-dependencies"`
		DevDependenciesU  map[string]any `toml:"dev_dependencies"`
		BuildDependencies map[string]any `toml:"build-dependencies"`
	}
	if err := toml.Unmarshal(source, &manifest); err != nil {
		return nil
	}
	var specs []Spec
	specs = append(specs, cargoBlock(manifest.Dependencies, "")...)
	// Cargo's canonical key is `dev-dependencies`, but the
	// underscore form is occasionally seen in older manifests.
	// Both fold into a single Replace="dev" set.
	specs = append(specs, cargoBlock(manifest.DevDependencies, "dev")...)
	specs = append(specs, cargoBlock(manifest.DevDependenciesU, "dev")...)
	specs = append(specs, cargoBlock(manifest.BuildDependencies, "build")...)
	return specs
}

func cargoBlock(deps map[string]any, kind string) []Spec {
	if len(deps) == 0 {
		return nil
	}
	out := make([]Spec, 0, len(deps))
	for name, raw := range deps {
		var version string
		switch v := raw.(type) {
		case string:
			version = v
		case map[string]any:
			if s, ok := v["version"].(string); ok {
				version = s
			}
			// Git/path deps without a version field resolve to
			// repo-local sources; skip them.
			if version == "" {
				continue
			}
		default:
			continue
		}
		out = append(out, Spec{
			Ecosystem: "cargo",
			Path:      name,
			Version:   version,
			Indirect:  kind != "",
			Replace:   kind,
		})
	}
	sort.Slice(out, func(i, j int) bool {
		return out[i].Path < out[j].Path
	})
	return out
}

// ParsePomXML walks a Maven pom.xml manifest and returns one Spec
// per <dependency> entry. Maven coordinates take the form
// `<groupId>:<artifactId>` for the dependency name with the
// version a separate child element. The scope attribute
// (compile/test/provided/runtime/system/import) maps to Spec
// fields the same way npm dev/peer/optional do — `compile` is
// the implicit production scope and lands without a Replace
// tag; everything else stamps Replace = "<scope>" and
// Indirect = true so cleanup queries can scope to production-only
// deps.
//
// Maven property substitution (`${project.version}`,
// `${spring.version}` etc.) is left verbatim in the version
// string. A future enhancement could resolve properties from
// <properties> and parent inheritance; the v1 keeps it simple
// since the canonical source-of-truth for resolved versions is
// the lockfile or `mvn dependency:tree` output rather than
// pom.xml itself.
func ParsePomXML(source []byte) []Spec {
	if len(source) == 0 {
		return nil
	}
	var manifest struct {
		Dependencies struct {
			Dependency []struct {
				GroupID    string `xml:"groupId"`
				ArtifactID string `xml:"artifactId"`
				Version    string `xml:"version"`
				Scope      string `xml:"scope"`
			} `xml:"dependency"`
		} `xml:"dependencies"`
	}
	if err := xml.Unmarshal(source, &manifest); err != nil {
		return nil
	}
	specs := make([]Spec, 0, len(manifest.Dependencies.Dependency))
	for _, d := range manifest.Dependencies.Dependency {
		if d.GroupID == "" || d.ArtifactID == "" {
			continue
		}
		scope := strings.TrimSpace(d.Scope)
		isProduction := scope == "" || scope == "compile"
		spec := Spec{
			Ecosystem: "maven",
			Path:      d.GroupID + ":" + d.ArtifactID,
			Version:   strings.TrimSpace(d.Version),
		}
		if !isProduction {
			spec.Indirect = true
			spec.Replace = scope
		}
		specs = append(specs, spec)
	}
	// Stable order — XML parsing preserves source order, but the
	// downstream dedup + edge emission prefers alphabetical for
	// diff-able output across runs.
	sort.Slice(specs, func(i, j int) bool {
		return specs[i].Path < specs[j].Path
	})
	return specs
}

// parseReplace extracts the from/to module paths from a replace
// directive line. Returns ("", "") when the line doesn't have the
// expected `from [version] => to [version]` shape. Replace
// versions are dropped — they don't add graph signal beyond what
// the require directive already carries.
func parseReplace(line string) (from, to string) {
	line = strings.TrimPrefix(line, "replace ")
	idx := strings.Index(line, "=>")
	if idx < 0 {
		return "", ""
	}
	left := strings.TrimSpace(line[:idx])
	right := strings.TrimSpace(line[idx+2:])
	if left == "" || right == "" {
		return "", ""
	}
	// Drop optional version on the from side (`module v1.x => target`).
	if parts := strings.Fields(left); len(parts) > 0 {
		left = parts[0]
	}
	if parts := strings.Fields(right); len(parts) > 0 {
		right = parts[0]
	}
	return left, right
}

// ModuleNodeID returns the canonical ID for a module node. The
// `module::` prefix is reserved for shared external-dependency
// nodes; the version is included so two repos that depend on
// `lodash@3` and `lodash@4` produce two distinct nodes that can be
// joined for "version skew" queries.
func ModuleNodeID(ecosystem, path, version string) string {
	id := "module::" + ecosystem + ":" + path
	if version != "" {
		id += "@" + version
	}
	return id
}

// BuildGraphArtifacts converts the parsed Spec list into
// (modules, edges) pairs. Modules are de-duplicated within the
// returned slice — graph.AddNode is idempotent on ID, so one node
// per (ecosystem, path, version) tuple is guaranteed even when the
// caller appends from multiple manifest files.
//
// filePath is the unprefixed manifest path (typically "go.mod").
// applyRepoPrefix downstream handles multi-repo namespacing for
// the file→module edge, but module IDs themselves do not get
// prefixed — the synthetic `module::` prefix matches the existing
// `external::` / `annotation::` convention the exporter already
// recognises.
func BuildGraphArtifacts(filePath string, specs []Spec) ([]*graph.Node, []*graph.Edge) {
	if len(specs) == 0 {
		return nil, nil
	}
	filePath = filepath.ToSlash(filePath)
	seen := make(map[string]struct{}, len(specs))
	nodes := make([]*graph.Node, 0, len(specs))
	edges := make([]*graph.Edge, 0, len(specs))
	for _, s := range specs {
		id := ModuleNodeID(s.Ecosystem, s.Path, s.Version)
		if _, ok := seen[id]; !ok {
			seen[id] = struct{}{}
			meta := map[string]any{
				"ecosystem": s.Ecosystem,
				"path":      s.Path,
				"version":   s.Version,
				"indirect":  s.Indirect,
			}
			if s.Replace != "" {
				meta["replace"] = s.Replace
			}
			nodes = append(nodes, &graph.Node{
				ID:       id,
				Kind:     graph.KindModule,
				Name:     shortName(s.Path),
				FilePath: filePath,
				Language: "go",
				Meta:     meta,
			})
		}
		edges = append(edges, &graph.Edge{
			From:     filePath,
			To:       id,
			Kind:     graph.EdgeDependsOnModule,
			FilePath: filePath,
			Line:     s.Line,
			Origin:   graph.OriginASTResolved,
		})
	}
	return nodes, edges
}

// LinkImports walks every KindImport node in the graph and emits
// an EdgeDependsOnModule edge to the matching module node from the
// given Spec list. Matching is by longest path prefix — an import
// of `github.com/foo/bar/sub` resolves to the spec for
// `github.com/foo/bar` when no exact match exists. Returns the
// number of edges emitted.
//
// Imports of repo-internal packages (the indexed module's own
// path) are deliberately skipped — they aren't external
// dependencies. Multi-version imports (Go's `module/v2` shape)
// match the longest spec; a manifest declaring both `bar` and
// `bar/v2` will resolve `import bar/v2/sub` to the v2 spec.
func LinkImports(g *graph.Graph, specs []Spec, ownModulePath string) int {
	if g == nil {
		return 0
	}
	return LinkImportsIn(g, g.AllNodes(), specs, ownModulePath)
}

// LinkImportsIn is LinkImports scoped to a caller-supplied import-node
// slice. Walking g.AllNodes() inside a warmup loop is O(R · N) — every
// repo's manifest pass touches every node in the whole graph. Callers
// in multi-repo mode should pass the repo's own KindImport nodes (e.g.
// from g.GetRepoNodes(repoPrefix) filtered by Kind) so each pass stays
// O(repo size).
func LinkImportsIn(g *graph.Graph, importNodes []*graph.Node, specs []Spec, ownModulePath string) int {
	if g == nil || len(specs) == 0 || len(importNodes) == 0 {
		return 0
	}
	// Index specs by path for quick longest-prefix lookup. When two
	// specs share a path (shouldn't happen in a well-formed go.mod,
	// but guard against duplicates) the first wins — graph node
	// dedup later handles any concrete conflict.
	specByPath := make(map[string]Spec, len(specs))
	paths := make([]string, 0, len(specs))
	for _, s := range specs {
		if _, ok := specByPath[s.Path]; ok {
			continue
		}
		specByPath[s.Path] = s
		paths = append(paths, s.Path)
	}
	// Sort longest first so the prefix scan picks the most specific
	// match without an O(n²) probe.
	sort.Slice(paths, func(i, j int) bool {
		return len(paths[i]) > len(paths[j])
	})

	emitted := 0
	for _, n := range importNodes {
		if n.Kind != graph.KindImport {
			continue
		}
		importPath, _ := n.Meta["path"].(string)
		if importPath == "" {
			continue
		}
		if ownModulePath != "" && (importPath == ownModulePath ||
			strings.HasPrefix(importPath, ownModulePath+"/")) {
			continue
		}
		matched := matchLongestPrefix(importPath, paths)
		if matched == "" {
			continue
		}
		spec := specByPath[matched]
		moduleID := ModuleNodeID(spec.Ecosystem, spec.Path, spec.Version)
		g.AddEdge(&graph.Edge{
			From:     n.ID,
			To:       moduleID,
			Kind:     graph.EdgeDependsOnModule,
			FilePath: n.FilePath,
			Line:     n.StartLine,
			Origin:   graph.OriginASTResolved,
		})
		emitted++
	}
	return emitted
}

// matchLongestPrefix returns the longest path from candidates that
// matches importPath as either an exact match or a directory
// prefix. Candidates must already be sorted by descending length;
// the first hit wins.
func matchLongestPrefix(importPath string, candidates []string) string {
	for _, p := range candidates {
		if importPath == p {
			return p
		}
		if strings.HasPrefix(importPath, p+"/") {
			return p
		}
	}
	return ""
}

// shortName returns the last meaningful segment of a module path —
// useful for the Name field surfaced by Brief listings. Strips the
// `vN` major-version suffix when present (`github.com/foo/bar/v2`
// → `bar`, not `v2`).
func shortName(path string) string {
	if path == "" {
		return ""
	}
	parts := strings.Split(path, "/")
	last := parts[len(parts)-1]
	if isMajorVersionSegment(last) && len(parts) >= 2 {
		return parts[len(parts)-2]
	}
	return last
}

func isMajorVersionSegment(s string) bool {
	if len(s) < 2 || s[0] != 'v' {
		return false
	}
	for i := 1; i < len(s); i++ {
		if s[i] < '0' || s[i] > '9' {
			return false
		}
	}
	return true
}
