package goanalysis

import (
	"sort"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

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

// stdlibCallerFixture writes a one-package module that calls fmt.Println
// (a top-level stdlib func) and uses fmt.Stringer (a stdlib interface).
// Drives the externals attribution tests.
func stdlibCallerFixture(t *testing.T, root string) {
	t.Helper()
	writeGoMod(t, root, "example.com/stdlib")
	writeFile(t, root, "main.go", `package main

import "fmt"

type Banner struct{}

func (b Banner) String() string { return "hi" }

func Greet() {
	fmt.Println("hello")
	var _ fmt.Stringer = Banner{}
}
`)
}

func TestExternals_AttributesStdlibCallToModuleNode(t *testing.T) {
	root := resolvedTempDir(t)
	stdlibCallerFixture(t, root)

	g := graph.New()
	g.AddNode(&graph.Node{
		ID: "main.go::Greet", Kind: graph.KindFunction, Name: "Greet",
		FilePath: "main.go", StartLine: 9, EndLine: 12, Language: "go",
	})
	g.AddNode(&graph.Node{
		ID: "main.go::Banner", Kind: graph.KindType, Name: "Banner",
		FilePath: "main.go", StartLine: 5, EndLine: 5, Language: "go",
	})
	g.AddNode(&graph.Node{
		ID: "main.go::Banner.String", Kind: graph.KindMethod, Name: "String",
		FilePath: "main.go", StartLine: 7, EndLine: 7, Language: "go",
	})

	p := newTestProvider(t)
	_, err := p.Enrich(g, root)
	require.NoError(t, err)

	// fmt.Println landed as ext::go:fmt::Println.
	println := g.GetNode("ext::go:fmt::Println")
	require.NotNil(t, println, "expected synthetic node for fmt.Println")
	assert.Equal(t, graph.KindFunction, println.Kind)
	assert.Equal(t, "Println", println.Name)
	assert.Equal(t, "go", println.Language)
	require.NotNil(t, println.Meta)
	assert.True(t, println.Meta["external"].(bool))
	assert.Equal(t, "stdlib", println.Meta["module_kind"])
	assert.Equal(t, "fmt", println.Meta["import_path"])
	assert.Equal(t, "go-types", println.Meta["semantic_source"])
	sig, _ := println.Meta["signature"].(string)
	assert.Contains(t, sig, "Println")

	// Stdlib module node materialised exactly once.
	stdlib := g.GetNode("module::go:stdlib")
	require.NotNil(t, stdlib, "expected synthetic stdlib module node")
	assert.Equal(t, graph.KindModule, stdlib.Kind)
	assert.Equal(t, "stdlib", stdlib.Meta["module_kind"])

	// Greet → fmt.Println via EdgeCalls at lsp_resolved.
	var callEdge *graph.Edge
	for _, e := range g.GetOutEdges("main.go::Greet") {
		if e.To == "ext::go:fmt::Println" && e.Kind == graph.EdgeCalls {
			callEdge = e
			break
		}
	}
	require.NotNil(t, callEdge, "expected EdgeCalls Greet → ext::go:fmt::Println")
	assert.Equal(t, graph.OriginLSPResolved, callEdge.Origin)
	assert.Equal(t, "go-types", callEdge.Meta["semantic_source"])

	// External symbol → module via EdgeDependsOnModule.
	var modEdge *graph.Edge
	for _, e := range g.GetOutEdges("ext::go:fmt::Println") {
		if e.To == "module::go:stdlib" && e.Kind == graph.EdgeDependsOnModule {
			modEdge = e
			break
		}
	}
	require.NotNil(t, modEdge, "external symbol must link to its module via EdgeDependsOnModule")
	assert.Equal(t, graph.OriginLSPResolved, modEdge.Origin)

	// fmt.Stringer landed as a KindInterface external node.
	stringer := g.GetNode("ext::go:fmt::Stringer")
	require.NotNil(t, stringer, "expected synthetic node for fmt.Stringer")
	assert.Equal(t, graph.KindInterface, stringer.Kind)
}

func TestExternals_DepInModuleCacheGetsModuleNode(t *testing.T) {
	root := resolvedTempDir(t)
	writeGoMod(t, root, "example.com/depcaller")
	// We rely on x/tools being in the module cache for the host machine —
	// it's an indirect dep of the gortex module, so go/packages will find
	// it. The fixture imports a tiny well-known type to keep the test
	// resilient to API shifts.
	writeFile(t, root, "go.mod", `module example.com/depcaller

go 1.21

require golang.org/x/tools v0.20.0
`)
	writeFile(t, root, "main.go", `package main

import "golang.org/x/tools/go/packages"

func Use() *packages.Config {
	return &packages.Config{}
}
`)
	// Skip cleanly when module cache isn't populated for this dep.
	g := graph.New()
	g.AddNode(&graph.Node{
		ID: "main.go::Use", Kind: graph.KindFunction, Name: "Use",
		FilePath: "main.go", StartLine: 5, EndLine: 7, Language: "go",
	})

	p := newTestProvider(t)
	res, err := p.Enrich(g, root)
	if err != nil {
		t.Skipf("dep not in module cache (CI without GOFLAGS=-mod=mod cache): %v", err)
	}
	require.NotNil(t, res)

	var modulePathPrefix = "module::go:golang.org/x/tools"
	var foundModule *graph.Node
	for _, n := range g.AllNodes() {
		if n.Kind != graph.KindModule {
			continue
		}
		if strings.HasPrefix(n.ID, modulePathPrefix) {
			foundModule = n
			break
		}
	}
	if foundModule == nil {
		t.Skipf("x/tools not loaded with module info — host environment probably uses GOFLAGS=-mod=vendor")
	}
	assert.Equal(t, "module_cache", foundModule.Meta["module_kind"])
	assert.Equal(t, "go", foundModule.Language)

	// Use → some ext::go:golang.org/x/tools/go/packages::Config edge.
	var edge *graph.Edge
	for _, e := range g.GetOutEdges("main.go::Use") {
		if strings.HasPrefix(e.To, "ext::go:golang.org/x/tools/go/packages::") {
			edge = e
			break
		}
	}
	require.NotNil(t, edge, "expected an edge from Use to a packages.* external node")
	assert.Equal(t, graph.OriginLSPResolved, edge.Origin)
}

func TestExternals_ClaimsResolverStubEdge(t *testing.T) {
	root := resolvedTempDir(t)
	stdlibCallerFixture(t, root)

	g := graph.New()
	g.AddNode(&graph.Node{
		ID: "main.go::Greet", Kind: graph.KindFunction, Name: "Greet",
		FilePath: "main.go", StartLine: 9, EndLine: 12, Language: "go",
	})
	g.AddNode(&graph.Node{
		ID: "main.go::Banner", Kind: graph.KindType, Name: "Banner",
		FilePath: "main.go", StartLine: 5, EndLine: 5, Language: "go",
	})
	g.AddNode(&graph.Node{
		ID: "main.go::Banner.String", Kind: graph.KindMethod, Name: "String",
		FilePath: "main.go", StartLine: 7, EndLine: 7, Language: "go",
	})

	// Pre-seed the resolver-shaped stub edge that the parser+resolver
	// would have written before externals attribution landed.
	// Goanalysis must rewrite this edge to point at the real ext::
	// node rather than leaving a duplicate.
	stubEdge := &graph.Edge{
		From: "main.go::Greet", To: "stdlib::fmt::Println", Kind: graph.EdgeCalls,
		FilePath: "main.go", Line: 10,
		Confidence: 0.0, ConfidenceLabel: "INFERRED",
		Origin: graph.OriginTextMatched,
	}
	g.AddEdge(stubEdge)

	p := newTestProvider(t)
	_, err := p.Enrich(g, root)
	require.NoError(t, err)

	// The original stub bucket must drain; the new bucket must hold the edge.
	stubInBucket := false
	for _, e := range g.GetInEdges("stdlib::fmt::Println") {
		_ = e
		stubInBucket = true
	}
	assert.False(t, stubInBucket, "stub bucket stdlib::fmt::Println must be empty after externals upgrade")

	var realEdge *graph.Edge
	for _, e := range g.GetOutEdges("main.go::Greet") {
		if e.To == "ext::go:fmt::Println" && e.Kind == graph.EdgeCalls {
			realEdge = e
			break
		}
	}
	require.NotNil(t, realEdge)
	assert.Equal(t, graph.OriginLSPResolved, realEdge.Origin, "claimed stub edges must be promoted to lsp_resolved")
	assert.Equal(t, 1.0, realEdge.Confidence)
	require.NotNil(t, realEdge.Meta)
	assert.Equal(t, "go-types", realEdge.Meta["semantic_source"])

	// And the stub edge identity (same pointer) must now point at the real node.
	assert.Same(t, stubEdge, realEdge, "claim should mutate the existing edge struct, not duplicate it")
}

func TestExternals_ClaimsFuzzyStubByLineAndName(t *testing.T) {
	// The parser emits `unresolved::*.Method` for method calls on
	// external receivers it can't classify (e.g. `os.Stdout.Write`).
	// The fuzzy claim path matches by (line, trailing-name) so the
	// stub doesn't survive alongside the real ext::go: node.
	root := resolvedTempDir(t)
	writeGoMod(t, root, "example.com/methodcall")
	writeFile(t, root, "main.go", `package main

import "os"

func Write() {
	os.Stdout.Write([]byte("x"))
}
`)

	g := graph.New()
	g.AddNode(&graph.Node{
		ID: "main.go::Write", Kind: graph.KindFunction, Name: "Write",
		FilePath: "main.go", StartLine: 5, EndLine: 7, Language: "go",
	})
	stubEdge := &graph.Edge{
		From: "main.go::Write", To: "unresolved::*.Write", Kind: graph.EdgeCalls,
		FilePath: "main.go", Line: 6,
		Confidence: 0.0, ConfidenceLabel: "INFERRED",
		Origin: graph.OriginTextMatched,
	}
	g.AddEdge(stubEdge)

	p := newTestProvider(t)
	_, err := p.Enrich(g, root)
	require.NoError(t, err)

	// stub bucket drained — fuzzy claim should have rewritten the edge.
	stubInBucket := false
	for _, e := range g.GetInEdges("unresolved::*.Write") {
		_ = e
		stubInBucket = true
	}
	assert.False(t, stubInBucket, "fuzzy claim must drain the unresolved::*.Write bucket")

	// And the real ext::go:os::File.Write node holds the call.
	realNode := g.GetNode("ext::go:os::File.Write")
	require.NotNil(t, realNode, "expected ext::go:os::File.Write to be created")
	assert.Equal(t, graph.KindMethod, realNode.Kind)
	assert.Equal(t, "File", realNode.Meta["receiver"])

	var realEdge *graph.Edge
	for _, e := range g.GetOutEdges("main.go::Write") {
		if e.To == "ext::go:os::File.Write" && e.Kind == graph.EdgeCalls {
			realEdge = e
			break
		}
	}
	require.NotNil(t, realEdge, "expected EdgeCalls Write → ext::go:os::File.Write")
	assert.Equal(t, graph.OriginLSPResolved, realEdge.Origin)
	assert.Same(t, stubEdge, realEdge, "fuzzy claim should mutate the existing edge struct")
}

func TestExternals_FuzzyClaimDoesNotMatchAcrossLines(t *testing.T) {
	// Conservatism check: if a stub edge is at a different line, the
	// fuzzy pass must NOT claim it — that would clobber unrelated
	// outbound calls that happen to share a name.
	root := resolvedTempDir(t)
	stdlibCallerFixture(t, root)

	g := graph.New()
	g.AddNode(&graph.Node{
		ID: "main.go::Greet", Kind: graph.KindFunction, Name: "Greet",
		FilePath: "main.go", StartLine: 9, EndLine: 12, Language: "go",
	})
	g.AddNode(&graph.Node{
		ID: "main.go::Banner", Kind: graph.KindType, Name: "Banner",
		FilePath: "main.go", StartLine: 5, EndLine: 5, Language: "go",
	})
	g.AddNode(&graph.Node{
		ID: "main.go::Banner.String", Kind: graph.KindMethod, Name: "String",
		FilePath: "main.go", StartLine: 7, EndLine: 7, Language: "go",
	})
	// Stub on a wildly-wrong line (line 99) — must survive untouched.
	wrongLineStub := &graph.Edge{
		From: "main.go::Greet", To: "unresolved::*.Println", Kind: graph.EdgeCalls,
		FilePath: "main.go", Line: 99,
	}
	g.AddEdge(wrongLineStub)

	p := newTestProvider(t)
	_, err := p.Enrich(g, root)
	require.NoError(t, err)

	// The off-line stub must still be in its bucket — we didn't claim it.
	stillThere := false
	for _, e := range g.GetInEdges("unresolved::*.Println") {
		_ = e
		stillThere = true
	}
	assert.True(t, stillThere, "fuzzy claim must not match across lines")
}

func TestExternals_SkipsBuiltinsAndUniverseScope(t *testing.T) {
	root := resolvedTempDir(t)
	writeGoMod(t, root, "example.com/builtins")
	writeFile(t, root, "main.go", `package main

func Loop(xs []int) int {
	n := 0
	for _, x := range xs {
		n += x
	}
	return len(xs) + n
}
`)

	g := graph.New()
	g.AddNode(&graph.Node{
		ID: "main.go::Loop", Kind: graph.KindFunction, Name: "Loop",
		FilePath: "main.go", StartLine: 3, EndLine: 9, Language: "go",
	})

	p := newTestProvider(t)
	_, err := p.Enrich(g, root)
	require.NoError(t, err)

	// `int` and `len` are universe-scope objects (Pkg() == nil). They must
	// not become external nodes — that would be noise.
	for _, n := range g.AllNodes() {
		if n.Kind == graph.KindModule {
			continue
		}
		if !strings.HasPrefix(n.ID, "ext::") {
			continue
		}
		t.Fatalf("unexpected external node from universe-scope obj: %s", n.ID)
	}
}

func TestExternals_IdempotentOnReEnrich(t *testing.T) {
	root := resolvedTempDir(t)
	stdlibCallerFixture(t, root)

	g := graph.New()
	g.AddNode(&graph.Node{
		ID: "main.go::Greet", Kind: graph.KindFunction, Name: "Greet",
		FilePath: "main.go", StartLine: 9, EndLine: 12, Language: "go",
	})
	g.AddNode(&graph.Node{
		ID: "main.go::Banner", Kind: graph.KindType, Name: "Banner",
		FilePath: "main.go", StartLine: 5, EndLine: 5, Language: "go",
	})
	g.AddNode(&graph.Node{
		ID: "main.go::Banner.String", Kind: graph.KindMethod, Name: "String",
		FilePath: "main.go", StartLine: 7, EndLine: 7, Language: "go",
	})

	p := newTestProvider(t)
	_, err := p.Enrich(g, root)
	require.NoError(t, err)
	_, err = p.Enrich(g, root)
	require.NoError(t, err)

	// Count Greet → fmt.Println edges. Re-running Enrich must not
	// duplicate them; the second pass should confirm the existing edge
	// in place.
	matchingEdges := 0
	for _, e := range g.GetOutEdges("main.go::Greet") {
		if e.To == "ext::go:fmt::Println" && e.Kind == graph.EdgeCalls {
			matchingEdges++
		}
	}
	assert.Equal(t, 1, matchingEdges, "re-enrichment must not duplicate the external call edge")

	// One stdlib module node only.
	count := 0
	for _, n := range g.AllNodes() {
		if n.ID == "module::go:stdlib" {
			count++
		}
	}
	assert.Equal(t, 1, count)
}

func TestExternals_GoModuleNodeID(t *testing.T) {
	tests := []struct {
		path    string
		version string
		want    string
	}{
		{"github.com/foo/bar", "v1.2.3", "module::go:github.com/foo/bar@v1.2.3"},
		{"github.com/foo/bar", "", "module::go:github.com/foo/bar"},
		{"stdlib", "", "module::go:stdlib"},
	}
	for _, tt := range tests {
		assert.Equal(t, tt.want, goModuleNodeID(tt.path, tt.version))
	}
}

func TestExternals_ShortModulePath(t *testing.T) {
	tests := map[string]string{
		"":                            "",
		"fmt":                         "fmt",
		"github.com/foo/bar":          "bar",
		"github.com/foo/bar/v2":       "bar",
		"github.com/foo/bar/v10":      "bar",
		"github.com/foo/bar/sub":      "sub",
		"single":                      "single",
		"vibrant":                     "vibrant", // not a major-version segment
	}
	keys := make([]string, 0, len(tests))
	for k := range tests {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		assert.Equal(t, tests[k], shortModulePath(k), "input=%q", k)
	}
}

func TestExternals_IsMajorVersion(t *testing.T) {
	cases := map[string]bool{
		"":         false,
		"v":        false,
		"v0":       true,
		"v1":       true,
		"v999":     true,
		"v1.0":     false, // dots are not part of the suffix
		"v1a":      false,
		"vibrant":  false, // first letter v but rest non-digit
		"version1": false,
	}
	keys := make([]string, 0, len(cases))
	for k := range cases {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		assert.Equal(t, cases[k], isMajorVersion(k), "input=%q", k)
	}
}
