package resolver

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/zzet/gortex/internal/graph"
)

func TestResolveAll_InternalCall(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "pkg/a.go", Kind: graph.KindFile, Name: "a.go", FilePath: "pkg/a.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "pkg/b.go", Kind: graph.KindFile, Name: "b.go", FilePath: "pkg/b.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "pkg/a.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "pkg/a.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "pkg/b.go::Bar", Kind: graph.KindFunction, Name: "Bar", FilePath: "pkg/b.go", Language: "go"})

	// Foo calls Bar (unresolved).
	callEdge := &graph.Edge{From: "pkg/a.go::Foo", To: "unresolved::Bar", Kind: graph.EdgeCalls, FilePath: "pkg/a.go", Line: 5}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.Resolved)
	assert.Equal(t, "pkg/b.go::Bar", callEdge.To)
}

func TestResolveAll_ExternalImport(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "main.go", Kind: graph.KindFile, Name: "main.go", FilePath: "main.go", Language: "go"})

	importEdge := &graph.Edge{From: "main.go", To: "unresolved::import::fmt", Kind: graph.EdgeImports, FilePath: "main.go", Line: 3}
	g.AddEdge(importEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.External)
	assert.Equal(t, "external::fmt", importEdge.To)
}

func TestResolveAll_MethodCall(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "pkg/a.go", Kind: graph.KindFile, Name: "a.go", FilePath: "pkg/a.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "pkg/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "pkg/a.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "pkg/b.go::Server.Start", Kind: graph.KindMethod, Name: "Start", FilePath: "pkg/b.go", Language: "go"})

	callEdge := &graph.Edge{From: "pkg/a.go::Caller", To: "unresolved::*.Start", Kind: graph.EdgeCalls, FilePath: "pkg/a.go", Line: 10}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.Resolved)
	assert.Equal(t, "pkg/b.go::Server.Start", callEdge.To)
}

func TestResolveAll_Unresolvable(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "a.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "a.go", Language: "go"})

	callEdge := &graph.Edge{From: "a.go::Foo", To: "unresolved::NonExistent", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 5}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.Unresolved)
}

// Stdlib classification: Go stdlib paths have no dot in the first
// segment ("fmt", "encoding/json"), and extern:: edges for them get a
// `stdlib::` prefix after resolution.
func TestResolveAll_ExternStdlib(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "a.go", Kind: graph.KindFile, Name: "a.go", FilePath: "a.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "a.go", Language: "go"})

	callEdge := &graph.Edge{
		From: "a.go::Caller", To: "unresolved::extern::encoding/json::NewEncoder",
		Kind: graph.EdgeCalls, FilePath: "a.go", Line: 5,
	}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.External)
	assert.Equal(t, "stdlib::encoding/json::NewEncoder", callEdge.To)
}

// Third-party module paths always carry a dot in the first segment
// (github.com/..., golang.org/...) and resolve to a `dep::` stub.
func TestResolveAll_ExternDep(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "a.go", Kind: graph.KindFile, Name: "a.go", FilePath: "a.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "a.go", Language: "go"})

	callEdge := &graph.Edge{
		From: "a.go::Caller", To: "unresolved::extern::github.com/pkg/errors::Wrap",
		Kind: graph.EdgeCalls, FilePath: "a.go", Line: 5,
	}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.External)
	assert.Equal(t, "dep::github.com/pkg/errors::Wrap", callEdge.To)
}

// A TypeScript selector call into a String built-in (`.startsWith`) has
// no in-graph target, but we classify it rather than mark it unresolved
// — flow traces render `builtin (js · string)` instead of plain
// `unresolved`.
func TestResolveAll_BuiltinFromTS(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "src/foo.ts", Kind: graph.KindFile, Name: "foo.ts", FilePath: "src/foo.ts", Language: "typescript"})
	g.AddNode(&graph.Node{ID: "src/foo.ts::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "src/foo.ts", Language: "typescript"})

	callEdge := &graph.Edge{
		From: "src/foo.ts::Caller", To: "unresolved::*.startsWith",
		Kind: graph.EdgeCalls, FilePath: "src/foo.ts", Line: 5,
	}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.External)
	assert.Equal(t, "builtin::ts::string::startsWith", callEdge.To)
}

// Python `list.append` — verifies the py classifier branch.
func TestResolveAll_BuiltinFromPython(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "a.py", Kind: graph.KindFile, Name: "a.py", FilePath: "a.py", Language: "python"})
	g.AddNode(&graph.Node{ID: "a.py::caller", Kind: graph.KindFunction, Name: "caller", FilePath: "a.py", Language: "python"})

	callEdge := &graph.Edge{
		From: "a.py::caller", To: "unresolved::*.append",
		Kind: graph.EdgeCalls, FilePath: "a.py", Line: 2,
	}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.External)
	assert.Equal(t, "builtin::py::list::append", callEdge.To)
}

// Unknown method on a known language still falls through to Unresolved
// — the classifier is not a catch-all.
func TestResolveAll_UnknownBuiltinStaysUnresolved(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "src/foo.ts", Kind: graph.KindFile, Name: "foo.ts", FilePath: "src/foo.ts", Language: "typescript"})
	g.AddNode(&graph.Node{ID: "src/foo.ts::caller", Kind: graph.KindFunction, Name: "caller", FilePath: "src/foo.ts", Language: "typescript"})

	callEdge := &graph.Edge{
		From: "src/foo.ts::caller", To: "unresolved::*.mySuperObscureThing",
		Kind: graph.EdgeCalls, FilePath: "src/foo.ts", Line: 2,
	}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.Unresolved)
}

// When the referenced symbol is actually indexed (e.g. another repo in
// a multi-repo setup), the extern edge rewires to the real node.
func TestResolveAll_ExternResolvesToIndexedSymbol(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "consumer/main.go", Kind: graph.KindFile, Name: "main.go", FilePath: "consumer/main.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "consumer/main.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "consumer/main.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "mylib/pkg/pkg.go", Kind: graph.KindFile, Name: "pkg.go", FilePath: "mylib/pkg/pkg.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "mylib/pkg/pkg.go::DoThing", Kind: graph.KindFunction, Name: "DoThing", FilePath: "mylib/pkg/pkg.go", Language: "go"})

	callEdge := &graph.Edge{
		From: "consumer/main.go::Caller", To: "unresolved::extern::mylib/pkg::DoThing",
		Kind: graph.EdgeCalls, FilePath: "consumer/main.go", Line: 5,
	}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.Resolved)
	assert.Equal(t, "mylib/pkg/pkg.go::DoThing", callEdge.To)
}

func TestInferImplements(t *testing.T) {
	g := graph.New()

	// Interface with two methods.
	g.AddNode(&graph.Node{
		ID: "pkg/store.go::Repository", Kind: graph.KindInterface, Name: "Repository",
		FilePath: "pkg/store.go", Language: "go", StartLine: 1,
		Meta: map[string]any{"methods": []string{"FindByID", "Save"}},
	})

	// Type that satisfies the interface.
	g.AddNode(&graph.Node{
		ID: "pkg/db.go::SQLRepo", Kind: graph.KindType, Name: "SQLRepo",
		FilePath: "pkg/db.go", Language: "go", StartLine: 1,
	})
	g.AddNode(&graph.Node{
		ID: "pkg/db.go::SQLRepo.FindByID", Kind: graph.KindMethod, Name: "FindByID",
		FilePath: "pkg/db.go", Language: "go", StartLine: 5,
	})
	g.AddNode(&graph.Node{
		ID: "pkg/db.go::SQLRepo.Save", Kind: graph.KindMethod, Name: "Save",
		FilePath: "pkg/db.go", Language: "go", StartLine: 10,
	})
	g.AddEdge(&graph.Edge{From: "pkg/db.go::SQLRepo.FindByID", To: "pkg/db.go::SQLRepo", Kind: graph.EdgeMemberOf, FilePath: "pkg/db.go", Line: 5})
	g.AddEdge(&graph.Edge{From: "pkg/db.go::SQLRepo.Save", To: "pkg/db.go::SQLRepo", Kind: graph.EdgeMemberOf, FilePath: "pkg/db.go", Line: 10})

	// Type that does NOT satisfy (missing Save).
	g.AddNode(&graph.Node{
		ID: "pkg/db.go::ReadOnly", Kind: graph.KindType, Name: "ReadOnly",
		FilePath: "pkg/db.go", Language: "go", StartLine: 20,
	})
	g.AddNode(&graph.Node{
		ID: "pkg/db.go::ReadOnly.FindByID", Kind: graph.KindMethod, Name: "FindByID",
		FilePath: "pkg/db.go", Language: "go", StartLine: 22,
	})
	g.AddEdge(&graph.Edge{From: "pkg/db.go::ReadOnly.FindByID", To: "pkg/db.go::ReadOnly", Kind: graph.EdgeMemberOf, FilePath: "pkg/db.go", Line: 22})

	r := New(g)
	added := r.InferImplements()

	assert.Equal(t, 1, added)

	// Verify the implements edge was added.
	edges := g.GetInEdges("pkg/store.go::Repository")
	var implEdges []*graph.Edge
	for _, e := range edges {
		if e.Kind == graph.EdgeImplements {
			implEdges = append(implEdges, e)
		}
	}
	assert.Len(t, implEdges, 1)
	assert.Equal(t, "pkg/db.go::SQLRepo", implEdges[0].From)
}

func TestInferImplements_EmptyInterface(t *testing.T) {
	g := graph.New()

	// Empty interface should not match anything.
	g.AddNode(&graph.Node{
		ID: "pkg/any.go::Any", Kind: graph.KindInterface, Name: "Any",
		FilePath: "pkg/any.go", Language: "go", StartLine: 1,
		Meta: map[string]any{"methods": []string{}},
	})
	g.AddNode(&graph.Node{
		ID: "pkg/db.go::Foo", Kind: graph.KindType, Name: "Foo",
		FilePath: "pkg/db.go", Language: "go", StartLine: 1,
	})

	r := New(g)
	added := r.InferImplements()
	assert.Equal(t, 0, added)
}

func TestResolveAll_PackageQualifiedFunctionCall(t *testing.T) {
	g := graph.New()
	// Caller in languages package calls parser.ParseFile — dispatched as "*.ParseFile"
	g.AddNode(&graph.Node{ID: "internal/parser/languages/golang.go::GoExtractor.Extract", Kind: graph.KindMethod, Name: "Extract", FilePath: "internal/parser/languages/golang.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "internal/parser/treesitter.go::ParseFile", Kind: graph.KindFunction, Name: "ParseFile", FilePath: "internal/parser/treesitter.go", Language: "go"})

	// This is how the Go extractor encodes pkg.Func() calls: "unresolved::*.ParseFile"
	callEdge := &graph.Edge{From: "internal/parser/languages/golang.go::GoExtractor.Extract", To: "unresolved::*.ParseFile", Kind: graph.EdgeCalls, FilePath: "internal/parser/languages/golang.go", Line: 94}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.Resolved)
	assert.Equal(t, "internal/parser/treesitter.go::ParseFile", callEdge.To)
}

func TestResolveMethodCall_TypeAware(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "pkg/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "pkg/a.go", Language: "go"})

	// Two methods named "Start" on different types.
	g.AddNode(&graph.Node{
		ID: "pkg/server.go::Server.Start", Kind: graph.KindMethod, Name: "Start",
		FilePath: "pkg/server.go", Language: "go",
		Meta: map[string]any{"receiver": "Server"},
	})
	g.AddNode(&graph.Node{
		ID: "pkg/client.go::Client.Start", Kind: graph.KindMethod, Name: "Start",
		FilePath: "pkg/client.go", Language: "go",
		Meta: map[string]any{"receiver": "Client"},
	})

	// Call edge with type hint → should resolve to Server.Start.
	callEdge := &graph.Edge{
		From: "pkg/a.go::Caller", To: "unresolved::*.Start",
		Kind: graph.EdgeCalls, FilePath: "pkg/a.go", Line: 10,
		Meta: map[string]any{"receiver_type": "Server"},
	}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.Resolved)
	assert.Equal(t, "pkg/server.go::Server.Start", callEdge.To)
	assert.Equal(t, 0.95, callEdge.Confidence) // same directory + exact type
}

func TestResolveMethodCall_TypeAware_Fallback(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "pkg/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "pkg/a.go", Language: "go"})
	g.AddNode(&graph.Node{
		ID: "pkg/b.go::Server.Start", Kind: graph.KindMethod, Name: "Start",
		FilePath: "pkg/b.go", Language: "go",
		Meta: map[string]any{"receiver": "Server"},
	})

	// Call edge with unknown type → falls back to name-only.
	callEdge := &graph.Edge{
		From: "pkg/a.go::Caller", To: "unresolved::*.Start",
		Kind: graph.EdgeCalls, FilePath: "pkg/a.go", Line: 10,
		Meta: map[string]any{"receiver_type": "UnknownType"},
	}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.Resolved)
	assert.Equal(t, "pkg/b.go::Server.Start", callEdge.To) // falls back to any method match
}

func TestResolveMethodCall_NoMeta(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "pkg/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "pkg/a.go", Language: "go"})
	g.AddNode(&graph.Node{
		ID: "pkg/b.go::Server.Start", Kind: graph.KindMethod, Name: "Start",
		FilePath: "pkg/b.go", Language: "go",
		Meta: map[string]any{"receiver": "Server"},
	})

	// Call edge with nil Meta → existing behavior.
	callEdge := &graph.Edge{
		From: "pkg/a.go::Caller", To: "unresolved::*.Start",
		Kind: graph.EdgeCalls, FilePath: "pkg/a.go", Line: 10,
	}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.Resolved)
	assert.Equal(t, "pkg/b.go::Server.Start", callEdge.To)
}

// Regression: a method passed as a *value* (e.g. `mux.HandleFunc("/p", h.foo)`)
// is captured by the Go extractor as `unresolved::*.foo` with kind=EdgeReads.
// Before the fix the resolver would re-target the edge to the method but
// leave the kind as EdgeReads, hiding it from both get_callers and
// find_usages. The fix promotes the kind to EdgeReferences when the
// field-then-method fallback lands on a method — so HTTP handlers,
// command tables, callback maps all become visible.
func TestResolveAll_MethodValuePromotesReadToReferences(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{
		ID: "pkg/routes.go::RegisterRoutes", Kind: graph.KindFunction, Name: "RegisterRoutes",
		FilePath: "pkg/routes.go", Language: "go",
	})
	g.AddNode(&graph.Node{
		ID: "pkg/handler.go::Handler.HandleHealth", Kind: graph.KindMethod, Name: "HandleHealth",
		FilePath: "pkg/handler.go", Language: "go",
		Meta: map[string]any{"receiver": "Handler"},
	})

	// Mirror what the Go extractor emits for `h.HandleHealth` in arg position.
	edge := &graph.Edge{
		From: "pkg/routes.go::RegisterRoutes", To: "unresolved::*.HandleHealth",
		Kind: graph.EdgeReads, FilePath: "pkg/routes.go", Line: 42,
		Meta: map[string]any{"receiver_type": "Handler"},
	}
	g.AddEdge(edge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.Resolved, "method-value reference should resolve")
	assert.Equal(t, "pkg/handler.go::Handler.HandleHealth", edge.To,
		"edge should point at the real method")
	assert.Equal(t, graph.EdgeReferences, edge.Kind,
		"kind should be promoted Reads→References so get_callers/find_usages surface it")
}

// Regression: a return-type or param-type reference must resolve to
// a TYPE, never to a same-named function or method. Before the fix
// the resolver's default case routed `EdgeReturns` / `EdgeTypedAs`
// through resolveFunctionCall, so `func GetLanguage() *tsitter.Language`
// landed on a method named `Language` somewhere unrelated in the
// repo — leaving the `Language` type alias visibly unused and
// triggering false-positive dead-code skulls on every cross-package
// type re-export pattern.
func TestResolveAll_ReturnsAndTypedAsResolveToType(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{
		ID: "pkg/tsitter/tsitter.go::Language", Kind: graph.KindType, Name: "Language",
		FilePath: "pkg/tsitter/tsitter.go", StartLine: 30, Language: "go",
	})
	// Decoy method with the same name — pre-fix the resolver picked
	// this because it tried resolveFunctionCall first.
	g.AddNode(&graph.Node{
		ID: "pkg/apex/apex.go::Apex.Language", Kind: graph.KindMethod, Name: "Language",
		FilePath: "pkg/apex/apex.go", StartLine: 11, Language: "go",
		Meta: map[string]any{"receiver": "Apex"},
	})
	g.AddNode(&graph.Node{
		ID: "pkg/ocaml/ocaml.go::GetLanguage", Kind: graph.KindFunction, Name: "GetLanguage",
		FilePath: "pkg/ocaml/ocaml.go", StartLine: 10, Language: "go",
	})
	g.AddNode(&graph.Node{
		ID: "pkg/ocaml/ocaml.go::Run#param:lang", Kind: graph.KindParam, Name: "lang",
		FilePath: "pkg/ocaml/ocaml.go", StartLine: 20, Language: "go",
	})

	// `func GetLanguage() *tsitter.Language` → EdgeReturns
	retEdge := &graph.Edge{
		From: "pkg/ocaml/ocaml.go::GetLanguage", To: "unresolved::Language",
		Kind: graph.EdgeReturns, FilePath: "pkg/ocaml/ocaml.go", Line: 10,
	}
	g.AddEdge(retEdge)

	// `func Run(lang *tsitter.Language)` → EdgeTypedAs on the param
	typedAsEdge := &graph.Edge{
		From: "pkg/ocaml/ocaml.go::Run#param:lang", To: "unresolved::Language",
		Kind: graph.EdgeTypedAs, FilePath: "pkg/ocaml/ocaml.go", Line: 20,
	}
	g.AddEdge(typedAsEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 2, stats.Resolved)
	assert.Equal(t, "pkg/tsitter/tsitter.go::Language", retEdge.To,
		"EdgeReturns must resolve to the type, not the same-named method")
	assert.Equal(t, "pkg/tsitter/tsitter.go::Language", typedAsEdge.To,
		"EdgeTypedAs must resolve to the type, not the same-named method")
}

// Regression: a *bare* function name passed as a value (e.g.
// `&cobra.Command{RunE: runClean}`) is extracted as EdgeReads with
// To=`unresolved::runClean`. The heuristic default case calls
// resolveFunctionCall which lands on the function — but until the
// promotion was added the kind stayed as EdgeReads, hiding the
// wire-up site from get_callers / find_usages. Every cobra subcommand
// (and every other "function pointer as struct field" pattern)
// looked like dead code.
func TestResolveAll_BareFunctionValuePromotesReadToReferences(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{
		ID: "cmd/x/main.go::registerClean", Kind: graph.KindFunction, Name: "registerClean",
		FilePath: "cmd/x/main.go", Language: "go",
	})
	g.AddNode(&graph.Node{
		ID: "cmd/x/clean.go::runClean", Kind: graph.KindFunction, Name: "runClean",
		FilePath: "cmd/x/clean.go", Language: "go",
	})

	edge := &graph.Edge{
		From: "cmd/x/main.go::registerClean", To: "unresolved::runClean",
		Kind: graph.EdgeReads, FilePath: "cmd/x/main.go", Line: 7,
	}
	g.AddEdge(edge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.Resolved, "bare function-value reference should resolve")
	assert.Equal(t, "cmd/x/clean.go::runClean", edge.To, "edge should point at the function")
	assert.Equal(t, graph.EdgeReferences, edge.Kind,
		"kind should be promoted Reads→References so get_callers/find_usages surface the wire-up site")
}

// Regression guard: a *real* field read should stay as EdgeReads even after
// the method-value promotion path was added. We add a struct field named
// `count` and a same-named method on a different type, and make sure the
// field-typed receiver picks the field (not the method) and keeps EdgeReads.
func TestResolveAll_FieldReadStaysAsReads(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{
		ID: "pkg/a.go::Caller", Kind: graph.KindFunction, Name: "Caller",
		FilePath: "pkg/a.go", Language: "go",
	})
	// The actual field we want resolution to pick.
	g.AddNode(&graph.Node{
		ID: "pkg/types.go::Counter.count", Kind: graph.KindField, Name: "count",
		FilePath: "pkg/types.go", Language: "go",
		Meta: map[string]any{"receiver": "Counter"},
	})

	edge := &graph.Edge{
		From: "pkg/a.go::Caller", To: "unresolved::*.count",
		Kind: graph.EdgeReads, FilePath: "pkg/a.go", Line: 7,
		Meta: map[string]any{"receiver_type": "Counter"},
	}
	g.AddEdge(edge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.Resolved)
	assert.Equal(t, "pkg/types.go::Counter.count", edge.To)
	assert.Equal(t, graph.EdgeReads, edge.Kind,
		"field-typed resolution must NOT be promoted — only method-typed fallback should be")
}

func TestResolveFile(t *testing.T) {
	g := graph.New()
	g.AddNode(&graph.Node{ID: "a.go", Kind: graph.KindFile, Name: "a.go", FilePath: "a.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "a.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "a.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "b.go::Bar", Kind: graph.KindFunction, Name: "Bar", FilePath: "b.go", Language: "go"})

	callEdge := &graph.Edge{From: "a.go::Foo", To: "unresolved::Bar", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 5}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveFile("a.go")

	assert.Equal(t, 1, stats.Resolved)
	assert.Equal(t, "b.go::Bar", callEdge.To)
}

// TestResolveMethodCall_ImportReachabilityFilter exercises Pass 0:
// when two methods named "Register" exist in different packages and
// only one of those packages is imported by the caller's file, the
// resolver must pick the imported package's method — not the
// alphabetically-first one across the whole graph (which was the
// original RegisterAll → OverlayManager.Register bug).
func TestResolveMethodCall_ImportReachabilityFilter(t *testing.T) {
	g := graph.New()

	// Caller file in pkg/parser/languages/. Imports pkg/parser only.
	g.AddNode(&graph.Node{ID: "pkg/parser/languages/register.go", Kind: graph.KindFile, Name: "register.go", FilePath: "pkg/parser/languages/register.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "pkg/parser/languages/register.go::RegisterAll", Kind: graph.KindFunction, Name: "RegisterAll", FilePath: "pkg/parser/languages/register.go", Language: "go"})

	// Imported package: pkg/parser
	g.AddNode(&graph.Node{ID: "pkg/parser/registry.go", Kind: graph.KindFile, Name: "registry.go", FilePath: "pkg/parser/registry.go", Language: "go"})
	g.AddNode(&graph.Node{
		ID: "pkg/parser/registry.go::Registry.Register", Kind: graph.KindMethod, Name: "Register",
		FilePath: "pkg/parser/registry.go", Language: "go",
		Meta: map[string]any{"receiver": "Registry"},
	})

	// Unrelated package: pkg/daemon. NOT imported.
	g.AddNode(&graph.Node{ID: "pkg/daemon/overlay.go", Kind: graph.KindFile, Name: "overlay.go", FilePath: "pkg/daemon/overlay.go", Language: "go"})
	g.AddNode(&graph.Node{
		ID: "pkg/daemon/overlay.go::OverlayManager.Register", Kind: graph.KindMethod, Name: "Register",
		FilePath: "pkg/daemon/overlay.go", Language: "go",
		Meta: map[string]any{"receiver": "OverlayManager"},
	})

	// Import edge: register.go → pkg/parser/registry.go (pre-resolved).
	g.AddEdge(&graph.Edge{
		From:     "pkg/parser/languages/register.go",
		To:       "pkg/parser/registry.go",
		Kind:     graph.EdgeImports,
		FilePath: "pkg/parser/languages/register.go", Line: 3,
	})

	// Unresolved method call without receiver_type — exercises the
	// fallback path. With Pass 0 in place, only Registry.Register
	// survives the reachability filter.
	callEdge := &graph.Edge{
		From:     "pkg/parser/languages/register.go::RegisterAll",
		To:       "unresolved::*.Register",
		Kind:     graph.EdgeCalls,
		FilePath: "pkg/parser/languages/register.go",
		Line:     7,
	}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.Resolved)
	assert.Equal(t, "pkg/parser/registry.go::Registry.Register", callEdge.To,
		"reachability filter should pick the imported package's method")
}

// TestResolveMethodCall_LocalityPrefersSamePackage covers the locality
// fallback: when two methods named "Do" survive Pass 0 (both packages
// imported), the same-package method beats any cross-package candidate
// regardless of name order.
func TestResolveMethodCall_LocalityPrefersSamePackage(t *testing.T) {
	g := graph.New()

	g.AddNode(&graph.Node{ID: "pkg/a/main.go", Kind: graph.KindFile, FilePath: "pkg/a/main.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "pkg/a/main.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "pkg/a/main.go", Language: "go"})

	// Same-package candidate: pkg/a/helpers.go::Helper.Do
	g.AddNode(&graph.Node{ID: "pkg/a/helpers.go", Kind: graph.KindFile, FilePath: "pkg/a/helpers.go", Language: "go"})
	g.AddNode(&graph.Node{
		ID: "pkg/a/helpers.go::Helper.Do", Kind: graph.KindMethod, Name: "Do",
		FilePath: "pkg/a/helpers.go", Language: "go",
		Meta: map[string]any{"receiver": "Helper"},
	})

	// Imported but cross-package: pkg/aaa/util.go::AAA.Do (sorts first
	// alphabetically — the old fallback would pick this).
	g.AddNode(&graph.Node{ID: "pkg/aaa/util.go", Kind: graph.KindFile, FilePath: "pkg/aaa/util.go", Language: "go"})
	g.AddNode(&graph.Node{
		ID: "pkg/aaa/util.go::AAA.Do", Kind: graph.KindMethod, Name: "Do",
		FilePath: "pkg/aaa/util.go", Language: "go",
		Meta: map[string]any{"receiver": "AAA"},
	})

	// Caller's file imports pkg/aaa, so both packages are reachable.
	g.AddEdge(&graph.Edge{
		From:     "pkg/a/main.go",
		To:       "pkg/aaa/util.go",
		Kind:     graph.EdgeImports,
		FilePath: "pkg/a/main.go", Line: 3,
	})

	callEdge := &graph.Edge{
		From:     "pkg/a/main.go::Caller",
		To:       "unresolved::*.Do",
		Kind:     graph.EdgeCalls,
		FilePath: "pkg/a/main.go",
		Line:     10,
	}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.Resolved)
	assert.Equal(t, "pkg/a/helpers.go::Helper.Do", callEdge.To,
		"same-package candidate should win the locality fallback")
}

// TestResolveMethodCall_InterfaceDispatchFanOut verifies that when the
// receiver type names a graph interface AND multiple reachable methods
// of that name exist, the edge is annotated as interface dispatch.
func TestResolveMethodCall_InterfaceDispatchFanOut(t *testing.T) {
	g := graph.New()

	g.AddNode(&graph.Node{ID: "pkg/main.go", Kind: graph.KindFile, FilePath: "pkg/main.go", Language: "go"})
	g.AddNode(&graph.Node{ID: "pkg/main.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "pkg/main.go", Language: "go"})

	// Interface "Notifier" with method "Notify".
	g.AddNode(&graph.Node{
		ID: "pkg/notifier.go::Notifier", Kind: graph.KindInterface, Name: "Notifier",
		FilePath: "pkg/notifier.go", Language: "go",
	})

	// Two implementations of Notify in the same package as the caller.
	g.AddNode(&graph.Node{
		ID: "pkg/email.go::EmailNotifier.Notify", Kind: graph.KindMethod, Name: "Notify",
		FilePath: "pkg/email.go", Language: "go",
		Meta: map[string]any{"receiver": "EmailNotifier"},
	})
	g.AddNode(&graph.Node{
		ID: "pkg/sms.go::SMSNotifier.Notify", Kind: graph.KindMethod, Name: "Notify",
		FilePath: "pkg/sms.go", Language: "go",
		Meta: map[string]any{"receiver": "SMSNotifier"},
	})

	// Receiver type is the interface itself — ambiguous dispatch.
	callEdge := &graph.Edge{
		From:     "pkg/main.go::Caller",
		To:       "unresolved::*.Notify",
		Kind:     graph.EdgeCalls,
		FilePath: "pkg/main.go",
		Line:     10,
		Meta:     map[string]any{"receiver_type": "Notifier"},
	}
	g.AddEdge(callEdge)

	r := New(g)
	stats := r.ResolveAll()

	assert.Equal(t, 1, stats.Resolved)
	require.NotNil(t, callEdge.Meta)
	assert.Equal(t, "interface", callEdge.Meta["dispatch"],
		"edge should be marked as interface dispatch when receiver is an interface and multiple impls exist")
	assert.Equal(t, graph.OriginLSPDispatch, callEdge.Origin)
}
