package modules

import (
	"testing"

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

func TestParseGoMod_Variants(t *testing.T) {
	src := []byte(`module github.com/example/x

go 1.22

require github.com/spf13/cobra v1.10.0

require (
	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
	github.com/stretchr/testify v1.11.1
	go.uber.org/zap v1.27.1 // indirect
)

replace github.com/foo/bar => ./local/bar
`)
	specs := ParseGoMod(src)
	if len(specs) != 4 {
		t.Fatalf("expected 4 specs, got %d: %+v", len(specs), specs)
	}

	got := map[string]Spec{}
	for _, s := range specs {
		got[s.Path] = s
	}

	if got["github.com/spf13/cobra"].Version != "v1.10.0" {
		t.Errorf("cobra version = %q", got["github.com/spf13/cobra"].Version)
	}
	if !got["go.uber.org/zap"].Indirect {
		t.Errorf("zap should be indirect")
	}
	if got["go.uber.org/zap"].Indirect != true {
		t.Errorf("zap indirect flag wrong")
	}
	if got["github.com/sabhiram/go-gitignore"].Indirect {
		t.Errorf("go-gitignore should not be indirect")
	}
}

func TestParseGoMod_ReplaceDirective(t *testing.T) {
	src := []byte(`module x

require github.com/foo/bar v1.0.0
replace github.com/foo/bar => ./local/bar
`)
	specs := ParseGoMod(src)
	if len(specs) != 1 {
		t.Fatalf("expected 1 spec, got %d", len(specs))
	}
	if specs[0].Replace != "./local/bar" {
		t.Errorf("replace = %q", specs[0].Replace)
	}
}

func TestParseGoMod_Empty(t *testing.T) {
	if got := ParseGoMod(nil); got != nil {
		t.Errorf("nil input should yield nil specs")
	}
	if got := ParseGoMod([]byte("module x\n")); len(got) != 0 {
		t.Errorf("module-only manifest should have no deps")
	}
}

func TestModuleNodeID(t *testing.T) {
	cases := []struct {
		ecosystem, path, version, want string
	}{
		{"go", "github.com/foo/bar", "v1.0.0", "module::go:github.com/foo/bar@v1.0.0"},
		{"go", "github.com/foo/bar", "", "module::go:github.com/foo/bar"},
		{"npm", "lodash", "4.17.0", "module::npm:lodash@4.17.0"},
	}
	for _, c := range cases {
		if got := ModuleNodeID(c.ecosystem, c.path, c.version); got != c.want {
			t.Errorf("ModuleNodeID(%q,%q,%q) = %q, want %q",
				c.ecosystem, c.path, c.version, got, c.want)
		}
	}
}

func TestBuildGraphArtifacts(t *testing.T) {
	specs := []Spec{
		{Ecosystem: "go", Path: "github.com/foo/bar", Version: "v1.0.0", Line: 5},
		{Ecosystem: "go", Path: "github.com/foo/bar", Version: "v1.0.0", Line: 6}, // dup
		{Ecosystem: "go", Path: "go.uber.org/zap", Version: "v1.27.1", Indirect: true, Line: 7},
	}
	nodes, edges := BuildGraphArtifacts("go.mod", specs)

	if len(nodes) != 2 {
		t.Errorf("expected 2 unique nodes, got %d", len(nodes))
	}
	if len(edges) != 3 {
		t.Errorf("expected 3 edges (one per spec, dups produce dup edges), got %d", len(edges))
	}
	for _, e := range edges {
		if e.From != "go.mod" {
			t.Errorf("edge from = %q", e.From)
		}
		if e.Kind != graph.EdgeDependsOnModule {
			t.Errorf("edge kind = %q", e.Kind)
		}
	}

	for _, n := range nodes {
		if n.Kind != graph.KindModule {
			t.Errorf("node kind = %q", n.Kind)
		}
		if n.Meta["ecosystem"] != "go" {
			t.Errorf("ecosystem meta = %v", n.Meta["ecosystem"])
		}
	}
	// Verify the indirect flag on the zap node.
	for _, n := range nodes {
		if n.Meta["path"] == "go.uber.org/zap" {
			if v, _ := n.Meta["indirect"].(bool); !v {
				t.Errorf("zap indirect flag missing")
			}
		}
	}
}

func TestParsePackageJSON_AllBlocks(t *testing.T) {
	src := []byte(`{
  "name": "my-app",
  "version": "1.0.0",
  "dependencies": {
    "react": "^18.2.0",
    "lodash": "4.17.21"
  },
  "devDependencies": {
    "vitest": "^1.0.0"
  },
  "peerDependencies": {
    "next": ">=13.0.0"
  },
  "optionalDependencies": {
    "fsevents": "^2.3.0"
  }
}`)
	specs := ParsePackageJSON(src)
	if len(specs) != 5 {
		t.Fatalf("expected 5 specs, got %d: %+v", len(specs), specs)
	}

	got := map[string]Spec{}
	for _, s := range specs {
		got[s.Path] = s
		if s.Ecosystem != "npm" {
			t.Errorf("ecosystem = %q for %q", s.Ecosystem, s.Path)
		}
	}
	if got["react"].Version != "^18.2.0" {
		t.Errorf("react version = %q", got["react"].Version)
	}
	if got["react"].Indirect {
		t.Errorf("react should NOT be indirect (production dep)")
	}
	if !got["vitest"].Indirect || got["vitest"].Replace != "dev" {
		t.Errorf("vitest should be dev-indirect: %+v", got["vitest"])
	}
	if got["next"].Replace != "peer" {
		t.Errorf("next.Replace = %q", got["next"].Replace)
	}
	if got["fsevents"].Replace != "optional" {
		t.Errorf("fsevents.Replace = %q", got["fsevents"].Replace)
	}
}

func TestParsePackageJSON_Empty(t *testing.T) {
	if got := ParsePackageJSON(nil); got != nil {
		t.Errorf("nil input → nil specs")
	}
	if got := ParsePackageJSON([]byte("{}")); len(got) != 0 {
		t.Errorf("empty manifest → empty specs")
	}
}

func TestParsePackageJSON_Malformed(t *testing.T) {
	if got := ParsePackageJSON([]byte("not json")); got != nil {
		t.Errorf("malformed input → nil")
	}
}

func TestParseNpmAlias(t *testing.T) {
	cases := []struct {
		in       string
		wantName string
		wantOK   bool
	}{
		{"npm:@acme/shared-lib@1.4.0", "@acme/shared-lib", true},
		{"npm:lodash@4.17.21", "lodash", true},
		{"npm:left-pad", "left-pad", true},     // no version
		{"npm:@scope/pkg", "@scope/pkg", true}, // scoped, no version
		{"npm:@scope/pkg@^2.0.0", "@scope/pkg", true},
		{"^18.2.0", "", false},          // ordinary semver range
		{"4.17.21", "", false},          // bare version
		{"github:user/repo", "", false}, // git shorthand, not npm:
		{"npm:", "", false},             // prefix only
		{"", "", false},
	}
	for _, c := range cases {
		name, ok := ParseNpmAlias(c.in)
		if name != c.wantName || ok != c.wantOK {
			t.Errorf("ParseNpmAlias(%q) = (%q, %t), want (%q, %t)",
				c.in, name, ok, c.wantName, c.wantOK)
		}
	}
}

func TestParsePackageJSON_NpmAlias(t *testing.T) {
	src := []byte(`{
  "name": "my-app",
  "dependencies": {
    "shared": "npm:@acme/shared-lib@1.4.0",
    "lodash4": "npm:lodash@4.17.21",
    "react": "^18.2.0"
  },
  "devDependencies": {
    "test-utils": "npm:@acme/test-utils"
  }
}`)
	specs := ParsePackageJSON(src)
	got := map[string]Spec{}
	for _, s := range specs {
		got[s.Path] = s
	}

	// Aliased production dep: Path is the alias key, Alias the real name.
	if got["shared"].Alias != "@acme/shared-lib" {
		t.Errorf("shared.Alias = %q, want @acme/shared-lib", got["shared"].Alias)
	}
	if got["shared"].Version != "npm:@acme/shared-lib@1.4.0" {
		t.Errorf("shared.Version should keep the verbatim npm: string, got %q", got["shared"].Version)
	}
	if got["lodash4"].Alias != "lodash" {
		t.Errorf("lodash4.Alias = %q, want lodash", got["lodash4"].Alias)
	}
	// Aliased dev dep — alias is captured in devDependencies too.
	if got["test-utils"].Alias != "@acme/test-utils" {
		t.Errorf("test-utils.Alias = %q, want @acme/test-utils", got["test-utils"].Alias)
	}
	if got["test-utils"].Replace != "dev" {
		t.Errorf("test-utils should be a dev dep, got Replace=%q", got["test-utils"].Replace)
	}
	// Ordinary dep — no alias.
	if got["react"].Alias != "" {
		t.Errorf("react.Alias should be empty, got %q", got["react"].Alias)
	}
}

func TestParsePackageJSON_StableOrder(t *testing.T) {
	// JSON map iteration is randomised — our packageJSONBlock
	// helper sorts within each block to keep tests deterministic.
	src := []byte(`{"dependencies": {"zoo": "1.0", "alpha": "2.0", "beta": "3.0"}}`)
	specs := ParsePackageJSON(src)
	if len(specs) != 3 {
		t.Fatalf("expected 3, got %d", len(specs))
	}
	if specs[0].Path != "alpha" || specs[1].Path != "beta" || specs[2].Path != "zoo" {
		t.Errorf("not alphabetically sorted: %+v", specs)
	}
}

func TestParsePackageLockJSON_v3(t *testing.T) {
	src := []byte(`{
  "name": "myapp",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "myapp",
      "version": "1.0.0"
    },
    "node_modules/lodash": {
      "version": "4.17.21",
      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
      "integrity": "sha512-..."
    },
    "node_modules/react": {
      "version": "18.2.0"
    },
    "node_modules/vitest": {
      "version": "1.0.4",
      "dev": true
    },
    "node_modules/@scope/util": {
      "version": "2.5.0"
    },
    "node_modules/foo/node_modules/bar": {
      "version": "1.0.0"
    }
  }
}`)
	specs := ParsePackageLockJSON(src)
	if len(specs) != 5 {
		t.Fatalf("expected 5 specs (root entry skipped), got %d: %+v", len(specs), specs)
	}

	got := map[string]Spec{}
	for _, s := range specs {
		got[s.Path] = s
		if s.Ecosystem != "npm" {
			t.Errorf("ecosystem = %q for %q", s.Ecosystem, s.Path)
		}
	}
	if got["lodash"].Version != "4.17.21" {
		t.Errorf("lodash version = %q (lockfile resolved value, not semver range)", got["lodash"].Version)
	}
	if got["react"].Version != "18.2.0" {
		t.Errorf("react version = %q", got["react"].Version)
	}
	if !got["vitest"].Indirect || got["vitest"].Replace != "dev" {
		t.Errorf("vitest dev classification wrong: %+v", got["vitest"])
	}
	if got["@scope/util"].Version != "2.5.0" {
		t.Errorf("scoped package version = %q", got["@scope/util"].Version)
	}
	if got["foo/node_modules/bar"].Version != "1.0.0" {
		t.Errorf("nested-transitive path %q should preserve full chain (multi-version differentiator)",
			"foo/node_modules/bar")
	}
}

func TestParsePackageLockJSON_RejectsV1(t *testing.T) {
	src := []byte(`{
  "name": "myapp",
  "lockfileVersion": 1,
  "dependencies": {
    "lodash": {"version": "4.17.21"}
  }
}`)
	if got := ParsePackageLockJSON(src); got != nil {
		t.Errorf("v1 lockfile should yield nil specs (unsupported shape), got %+v", got)
	}
}

func TestParsePackageLockJSON_Empty(t *testing.T) {
	if got := ParsePackageLockJSON(nil); got != nil {
		t.Errorf("nil input should yield nil")
	}
	if got := ParsePackageLockJSON([]byte(`{"lockfileVersion": 3, "packages": {}}`)); len(got) != 0 {
		t.Errorf("empty packages map should yield empty specs")
	}
}

func TestParseYarnLock_v1(t *testing.T) {
	src := []byte(`# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


lodash@^4.17.0:
  version "4.17.21"
  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#..."
  integrity sha512-...

react@^18.2.0:
  version "18.2.0"
  resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#..."

"@types/node@*", "@types/node@^20.0.0":
  version "20.10.5"
  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.10.5.tgz#..."
`)
	specs := ParseYarnLock(src)
	if len(specs) != 3 {
		t.Fatalf("expected 3 specs, got %d: %+v", len(specs), specs)
	}

	got := map[string]string{}
	for _, s := range specs {
		got[s.Path] = s.Version
		if s.Ecosystem != "npm" {
			t.Errorf("ecosystem = %q for %q", s.Ecosystem, s.Path)
		}
	}
	if got["lodash"] != "4.17.21" {
		t.Errorf("lodash = %q", got["lodash"])
	}
	if got["react"] != "18.2.0" {
		t.Errorf("react = %q", got["react"])
	}
	if got["@types/node"] != "20.10.5" {
		t.Errorf("@types/node = %q (scoped name should be preserved as is)", got["@types/node"])
	}
}

func TestParseYarnLock_DedupesMultiRangeBlocks(t *testing.T) {
	// `lodash@^4.0.0, lodash@^4.17.0:` is one block declaring two
	// ranges that resolve to the same version. Should yield one
	// Spec, not two.
	src := []byte(`lodash@^4.0.0, lodash@^4.17.0:
  version "4.17.21"
`)
	specs := ParseYarnLock(src)
	if len(specs) != 1 {
		t.Errorf("expected 1 deduped spec, got %d", len(specs))
	}
}

func TestParseYarnLock_Empty(t *testing.T) {
	if got := ParseYarnLock(nil); got != nil {
		t.Errorf("nil input should yield nil")
	}
}

func TestParsePnpmLock_v6(t *testing.T) {
	src := []byte(`lockfileVersion: '6.0'

settings:
  autoInstallPeers: true

importers:
  .:
    dependencies:
      react:
        specifier: ^18.2.0
        version: 18.2.0

packages:

  /react@18.2.0:
    resolution: {integrity: sha512-...}
    dependencies:
      loose-envify: 1.4.0

  /lodash@4.17.21:
    resolution: {integrity: sha512-...}

  /@types/node@20.10.5:
    resolution: {integrity: sha512-...}
`)
	specs := ParsePnpmLock(src)
	if len(specs) != 3 {
		t.Fatalf("expected 3 specs, got %d: %+v", len(specs), specs)
	}

	got := map[string]string{}
	for _, s := range specs {
		got[s.Path] = s.Version
		if s.Ecosystem != "npm" {
			t.Errorf("ecosystem = %q for %q", s.Ecosystem, s.Path)
		}
	}
	if got["react"] != "18.2.0" || got["lodash"] != "4.17.21" {
		t.Errorf("got %+v", got)
	}
	if got["@types/node"] != "20.10.5" {
		t.Errorf("scoped pkg @types/node = %q", got["@types/node"])
	}
}

func TestParsePnpmLock_StripsPeerSuffix(t *testing.T) {
	// pnpm encodes peer-dep resolutions in the key:
	// `/some-pkg@1.0.0_react@18.2.0` — the `_react@...` suffix
	// is the peer combination, not part of the version. We strip
	// it so the canonical version stays clean.
	src := []byte(`packages:
  /some-pkg@1.0.0_react@18.2.0:
    resolution: {integrity: sha512-...}
`)
	specs := ParsePnpmLock(src)
	if len(specs) != 1 || specs[0].Version != "1.0.0" {
		t.Errorf("expected version 1.0.0, got %+v", specs)
	}
}

func TestParsePnpmLock_Empty(t *testing.T) {
	if got := ParsePnpmLock(nil); got != nil {
		t.Errorf("nil input should yield nil")
	}
	if got := ParsePnpmLock([]byte("lockfileVersion: '6.0'\n")); len(got) != 0 {
		t.Errorf("manifest with no packages should yield empty specs")
	}
}

func TestParsePyProject_PEP621(t *testing.T) {
	src := []byte(`[project]
name = "myproj"
version = "0.1.0"
dependencies = [
    "litellm>=1.50.0",
    "docker>=7.0.0",
    "pyyaml",
    "flask[async]==2.0.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0.0",
    "ruff",
]
docs = [
    "mkdocs>=1.0",
]
`)
	specs := ParsePyProject(src)
	if len(specs) != 7 {
		t.Fatalf("expected 7 specs (4 prod + 2 dev + 1 docs), got %d: %+v", len(specs), specs)
	}

	got := map[string]Spec{}
	for _, s := range specs {
		got[s.Path] = s
	}
	if got["litellm"].Version != ">=1.50.0" {
		t.Errorf("litellm version = %q", got["litellm"].Version)
	}
	if got["pyyaml"].Version != "" {
		t.Errorf("unconstrained pkg should have empty version, got %q", got["pyyaml"].Version)
	}
	if got["flask"].Version != "==2.0.0" {
		t.Errorf("flask extras suffix should be stripped: %q", got["flask"].Version)
	}
	if got["pytest"].Replace != "dev" || !got["pytest"].Indirect {
		t.Errorf("pytest should be dev-indirect: %+v", got["pytest"])
	}
	if got["mkdocs"].Replace != "docs" {
		t.Errorf("mkdocs.Replace = %q", got["mkdocs"].Replace)
	}
}

func TestParsePyProject_Poetry(t *testing.T) {
	src := []byte(`[tool.poetry]
name = "myproj"

[tool.poetry.dependencies]
python = "^3.10"
requests = "^2.0"
django = { version = "^4.2", extras = ["bcrypt"] }

[tool.poetry.dev-dependencies]
pytest = "^8.0"
`)
	specs := ParsePyProject(src)
	got := map[string]Spec{}
	for _, s := range specs {
		got[s.Path] = s
	}

	if _, ok := got["python"]; ok {
		t.Errorf("python interpreter constraint must not produce a Spec")
	}
	if got["requests"].Version != "^2.0" {
		t.Errorf("requests version = %q", got["requests"].Version)
	}
	if got["django"].Version != "^4.2" {
		t.Errorf("django version (from table) = %q", got["django"].Version)
	}
	if got["pytest"].Replace != "dev" {
		t.Errorf("pytest should be dev: %+v", got["pytest"])
	}
}

func TestParseRequirementsTxt(t *testing.T) {
	src := []byte(`# top-level comment
flask>=2.0.0
django==4.2.7  # inline comment
requests
-r other.txt
-e .
--index-url https://pypi.org/simple

# blank line above

git+https://github.com/x/y.git ; sys_platform == "darwin"
`)
	specs := ParseRequirementsTxt(src)
	got := map[string]Spec{}
	for _, s := range specs {
		got[s.Path] = s
	}
	if got["flask"].Version != ">=2.0.0" {
		t.Errorf("flask version = %q", got["flask"].Version)
	}
	if got["django"].Version != "==4.2.7" {
		t.Errorf("django version (with inline comment stripped) = %q", got["django"].Version)
	}
	if _, ok := got["requests"]; !ok {
		t.Errorf("unconstrained 'requests' should still produce a spec")
	}
	if _, ok := got["other.txt"]; ok {
		t.Errorf("-r include must not be treated as a dep")
	}
	// `git+https://...` becomes `git` after splitPEP508 stripping;
	// it's a degraded recovery rather than ideal, but at least it
	// doesn't blow up. The git URL test pin documents current
	// behaviour.
	if _, ok := got["git"]; !ok {
		t.Logf("note: git+url shape recovers as 'git' (acknowledged degraded form)")
	}
}

func TestSplitPEP508(t *testing.T) {
	cases := []struct {
		in, name, version string
	}{
		{"requests>=2.0", "requests", ">=2.0"},
		{"flask[async]==2.0.0", "flask", "==2.0.0"},
		{"numpy", "numpy", ""},
		{"pkg ; python_version<'3.9'", "pkg", ""},
		{"foo @ https://example.com/foo.tar.gz", "foo", ""},
	}
	for _, c := range cases {
		name, version := splitPEP508(c.in)
		if name != c.name || version != c.version {
			t.Errorf("splitPEP508(%q) = (%q, %q), want (%q, %q)",
				c.in, name, version, c.name, c.version)
		}
	}
}

func TestParseCargoToml_AllBlocks(t *testing.T) {
	src := []byte(`[package]
name = "myapp"
version = "0.1.0"

[dependencies]
serde = "1.0"
tokio = { version = "1.30", features = ["full"] }
local-only = { path = "../local" }
git-only = { git = "https://example.com/git/repo.git" }

[dev-dependencies]
assert_cmd = "2"

[build-dependencies]
cc = "1"
`)
	specs := ParseCargoToml(src)
	got := map[string]Spec{}
	for _, s := range specs {
		got[s.Path] = s
		if s.Ecosystem != "cargo" {
			t.Errorf("ecosystem = %q for %q", s.Ecosystem, s.Path)
		}
	}

	if len(specs) != 4 {
		t.Errorf("expected 4 specs (serde + tokio + assert_cmd + cc; local/git skipped), got %d: %+v",
			len(specs), specs)
	}
	if got["serde"].Version != "1.0" {
		t.Errorf("serde version = %q", got["serde"].Version)
	}
	if got["tokio"].Version != "1.30" {
		t.Errorf("tokio version (from table) = %q", got["tokio"].Version)
	}
	if _, ok := got["local-only"]; ok {
		t.Errorf("path-only dep must not produce a Spec")
	}
	if _, ok := got["git-only"]; ok {
		t.Errorf("git-only dep without version must not produce a Spec")
	}
	if !got["assert_cmd"].Indirect || got["assert_cmd"].Replace != "dev" {
		t.Errorf("assert_cmd should be dev-indirect: %+v", got["assert_cmd"])
	}
	if got["cc"].Replace != "build" {
		t.Errorf("cc.Replace = %q (want build)", got["cc"].Replace)
	}
}

func TestParseCargoToml_UnderscoreDevDeps(t *testing.T) {
	// Older Cargo manifests sometimes use the underscore form.
	src := []byte(`[package]
name = "p"

[dev_dependencies]
mockall = "0.11"
`)
	specs := ParseCargoToml(src)
	if len(specs) != 1 {
		t.Fatalf("expected 1 spec, got %d", len(specs))
	}
	if specs[0].Replace != "dev" {
		t.Errorf("Replace = %q (underscore form should still be dev)", specs[0].Replace)
	}
}

func TestParseCargoToml_Empty(t *testing.T) {
	if got := ParseCargoToml(nil); got != nil {
		t.Errorf("nil input should yield nil")
	}
	if got := ParseCargoToml([]byte("[package]\nname = \"x\"\n")); len(got) != 0 {
		t.Errorf("manifest with no deps should yield empty specs")
	}
}

func TestParsePomXML_AllScopes(t *testing.T) {
	src := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<project>
  <groupId>com.example</groupId>
  <artifactId>myapp</artifactId>
  <version>1.0.0</version>
  <dependencies>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-core</artifactId>
      <version>5.3.0</version>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.15.0</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.13.2</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>servlet-api</artifactId>
      <version>2.5</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>
</project>`)
	specs := ParsePomXML(src)
	if len(specs) != 4 {
		t.Fatalf("expected 4 specs, got %d: %+v", len(specs), specs)
	}

	got := map[string]Spec{}
	for _, s := range specs {
		got[s.Path] = s
		if s.Ecosystem != "maven" {
			t.Errorf("ecosystem = %q for %q", s.Ecosystem, s.Path)
		}
	}
	if got["org.springframework:spring-core"].Version != "5.3.0" {
		t.Errorf("spring-core version = %q", got["org.springframework:spring-core"].Version)
	}
	// No-scope and explicit `compile` both treated as production.
	if got["org.springframework:spring-core"].Indirect {
		t.Errorf("missing scope should be production (Indirect=false)")
	}
	if got["com.fasterxml.jackson.core:jackson-databind"].Indirect {
		t.Errorf("explicit compile should be production")
	}
	if got["junit:junit"].Replace != "test" || !got["junit:junit"].Indirect {
		t.Errorf("junit/test scope wrong: %+v", got["junit:junit"])
	}
	if got["javax.servlet:servlet-api"].Replace != "provided" {
		t.Errorf("servlet-api Replace = %q", got["javax.servlet:servlet-api"].Replace)
	}
}

func TestParsePomXML_SkipsIncompleteCoordinate(t *testing.T) {
	src := []byte(`<?xml version="1.0"?>
<project>
  <dependencies>
    <dependency>
      <artifactId>missing-group</artifactId>
      <version>1.0</version>
    </dependency>
    <dependency>
      <groupId>com.example</groupId>
      <version>2.0</version>
    </dependency>
    <dependency>
      <groupId>com.example</groupId>
      <artifactId>real</artifactId>
      <version>3.0</version>
    </dependency>
  </dependencies>
</project>`)
	specs := ParsePomXML(src)
	if len(specs) != 1 {
		t.Errorf("expected 1 spec (the only complete coordinate), got %d", len(specs))
	}
}

func TestParsePomXML_PropertyVersionVerbatim(t *testing.T) {
	// Property substitution is left verbatim; resolving it from
	// <properties> is a future enhancement noted inline.
	src := []byte(`<?xml version="1.0"?>
<project>
  <dependencies>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-core</artifactId>
      <version>${spring.version}</version>
    </dependency>
  </dependencies>
</project>`)
	specs := ParsePomXML(src)
	if len(specs) != 1 || specs[0].Version != "${spring.version}" {
		t.Errorf("property reference should be kept verbatim, got %+v", specs)
	}
}

func TestLinkImports_LongestPrefix(t *testing.T) {
	g := graph.New()
	// Two import nodes — one for an exact match, one for a sub-package.
	g.AddNode(&graph.Node{
		ID:       "pkg/a.go::import::github.com/spf13/cobra",
		Kind:     graph.KindImport,
		FilePath: "pkg/a.go",
		Meta:     map[string]any{"path": "github.com/spf13/cobra"},
	})
	g.AddNode(&graph.Node{
		ID:       "pkg/b.go::import::github.com/spf13/cobra/doc",
		Kind:     graph.KindImport,
		FilePath: "pkg/b.go",
		Meta:     map[string]any{"path": "github.com/spf13/cobra/doc"},
	})
	g.AddNode(&graph.Node{
		ID:       "pkg/c.go::import::own/internal/foo",
		Kind:     graph.KindImport,
		FilePath: "pkg/c.go",
		Meta:     map[string]any{"path": "own/internal/foo"},
	})

	specs := []Spec{
		{Ecosystem: "go", Path: "github.com/spf13/cobra", Version: "v1.0.0"},
		{Ecosystem: "go", Path: "go.uber.org/zap", Version: "v1.27.1"},
	}

	emitted := LinkImports(g, specs, "own")
	if emitted != 2 {
		t.Errorf("expected 2 edges (cobra exact + cobra/doc prefix; own/internal skipped), got %d", emitted)
	}

	wantTo := "module::go:github.com/spf13/cobra@v1.0.0"
	hits := 0
	for _, e := range g.AllEdges() {
		if e.Kind == graph.EdgeDependsOnModule && e.To == wantTo {
			hits++
		}
	}
	if hits != 2 {
		t.Errorf("expected 2 edges to %q, got %d", wantTo, hits)
	}
}

func TestLinkImports_PrefersLongerSpecForVersionedImports(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{
		ID:       "f::import::github.com/foo/bar/v2/sub",
		Kind:     graph.KindImport,
		FilePath: "f.go",
		Meta:     map[string]any{"path": "github.com/foo/bar/v2/sub"},
	})

	// Both v1 and v2 exist — the longest match wins.
	specs := []Spec{
		{Ecosystem: "go", Path: "github.com/foo/bar", Version: "v1.0.0"},
		{Ecosystem: "go", Path: "github.com/foo/bar/v2", Version: "v2.1.0"},
	}

	if got := LinkImports(g, specs, ""); got != 1 {
		t.Fatalf("expected 1 edge, got %d", got)
	}
	for _, e := range g.AllEdges() {
		if e.Kind == graph.EdgeDependsOnModule {
			if e.To != "module::go:github.com/foo/bar/v2@v2.1.0" {
				t.Errorf("wrong module target: %q (longest spec should win)", e.To)
			}
		}
	}
}

func TestLinkImports_SkipsWhenNoMatch(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{
		ID:       "f::import::stdlib",
		Kind:     graph.KindImport,
		FilePath: "f.go",
		Meta:     map[string]any{"path": "fmt"},
	})

	specs := []Spec{
		{Ecosystem: "go", Path: "github.com/foo/bar"},
	}

	if got := LinkImports(g, specs, ""); got != 0 {
		t.Errorf("stdlib import shouldn't match external module, got %d edges", got)
	}
}

func TestShortName(t *testing.T) {
	cases := []struct {
		in, want string
	}{
		{"github.com/foo/bar", "bar"},
		{"github.com/foo/bar/v2", "bar"},
		{"github.com/foo/bar/v10", "bar"},
		{"foo", "foo"},
		{"", ""},
	}
	for _, c := range cases {
		if got := shortName(c.in); got != c.want {
			t.Errorf("shortName(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}
