package goanalysis

import (
	"go/types"
	"strings"

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

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

// stdlibModuleID is the synthetic KindModule node ID used to attribute Go
// standard-library symbols. The standard library has no go.mod entry, so
// we materialise a single shared module node so dependency queries
// ("which modules does pkg X reach into?") return a stdlib bucket
// alongside real module-cache modules.
const stdlibModuleID = "module::go:stdlib"

// modulePathStdlib is the canonical "path" for the stdlib module node. Kept
// in sync with the existing internal/modules naming so any consumer that
// groups KindModule nodes by Meta["path"] can find stdlib alongside real
// go.mod entries.
const modulePathStdlib = "stdlib"

// externalsAttribution holds bookkeeping for a single Enrich() pass. It
// owns:
//
//   - pkgByPath: every transitively loaded *packages.Package, indexed by
//     import path. Top-level packages.Load returns only the repo's own
//     packages; we walk pkg.Imports recursively so external symbol
//     resolution can find the owning module for stdlib / module-cache
//     packages too.
//   - moduleByPath: import path → KindModule node ID. Cached so the
//     stdlib (and each dep module) materialises at most once per pass.
//   - extByObj: types.Object → external node ID. Caches lookups across
//     multiple Uses of the same external symbol.
//
// Statistics counters surface back through ExternalsResult so the caller
// can report nodes/edges added.
type externalsAttribution struct {
	g            *graph.Graph
	pkgByPath    map[string]*packages.Package
	moduleByPath map[string]string
	extByObj     map[types.Object]string
	provider     string

	nodesAdded     int
	edgesAdded     int
	edgesUpgraded  int
	modulesLinked  int
	stdlibCreated  bool
	missingPkgInfo int
}

// newExternalsAttribution prepares externalsAttribution from the loaded
// roots. Walking pkg.Imports collects every dep — stdlib and module-cache
// alike — so resolveSymbol can find the owning *packages.Package for an
// arbitrary types.Object.
func newExternalsAttribution(g *graph.Graph, roots []*packages.Package, provider string) *externalsAttribution {
	pkgByPath := make(map[string]*packages.Package)
	var visit func(p *packages.Package)
	visit = func(p *packages.Package) {
		if p == nil || p.PkgPath == "" {
			return
		}
		if _, seen := pkgByPath[p.PkgPath]; seen {
			return
		}
		pkgByPath[p.PkgPath] = p
		for _, imp := range p.Imports {
			visit(imp)
		}
	}
	for _, r := range roots {
		visit(r)
	}
	return &externalsAttribution{
		g:            g,
		pkgByPath:    pkgByPath,
		moduleByPath: make(map[string]string),
		extByObj:     make(map[types.Object]string),
		provider:     provider,
	}
}

// resolveSymbol returns the graph node ID for an external go/types object,
// creating it (and the owning KindModule node, if not already present)
// on first sight. Returns "" when the object is unsuitable for
// externalisation: builtins / universe-scope (no Pkg), unsupported obj
// kinds, or a missing *packages.Package entry (rare — would mean a Use
// pointed at a package the loader didn't see).
//
// External symbols become first-class graph nodes so the call graph can
// reach into stdlib / module-cache without leaving stub-string targets
// behind. Each external symbol gains an EdgeDependsOnModule to its
// owning KindModule — i.e. stdlib/dep attribution.
func (e *externalsAttribution) resolveSymbol(obj types.Object) string {
	if obj == nil || obj.Pkg() == nil {
		return ""
	}
	if id, ok := e.extByObj[obj]; ok {
		return id
	}

	importPath := obj.Pkg().Path()
	if importPath == "" {
		return ""
	}
	pkg, ok := e.pkgByPath[importPath]
	if !ok {
		// Loader didn't see this package — happens when packages.Load
		// returns errors and the user-facing pkg list is partial. Bail
		// rather than synthesize a half-formed node.
		e.missingPkgInfo++
		return ""
	}
	moduleID := e.ensureModuleNode(pkg)
	if moduleID == "" {
		// pkg belongs to the indexed repo (Main module). Caller should
		// have routed obj through objToNode before falling back to
		// externals — return "" so the caller drops the use.
		return ""
	}

	kind := externalNodeKind(obj)
	if kind == "" {
		return ""
	}
	nodeID := externalNodeID(importPath, obj)
	if nodeID == "" {
		return ""
	}

	if existing := e.g.GetNode(nodeID); existing == nil {
		e.g.AddNode(buildExternalNode(nodeID, kind, importPath, moduleID, pkg, obj, e.provider))
		e.nodesAdded++

		// Attribute the symbol to its module via EdgeDependsOnModule. The
		// schema's existing convention is "file/package/import →
		// KindModule"; an external symbol is morally equivalent — it's
		// a first-class entity that depends on the module providing it.
		e.g.AddEdge(&graph.Edge{
			From:            nodeID,
			To:              moduleID,
			Kind:            graph.EdgeDependsOnModule,
			FilePath:        externalFilePath(importPath),
			Line:            0,
			Confidence:      1.0,
			ConfidenceLabel: "EXTRACTED",
			Origin:          graph.OriginLSPResolved,
			Meta: map[string]any{
				"semantic_source": e.provider,
			},
		})
		e.edgesAdded++
		e.modulesLinked++
	}
	e.extByObj[obj] = nodeID
	return nodeID
}

// claimAndUpgradeStub looks for an existing edge from caller to one of the
// resolver's stub targets for this external symbol (stdlib::, dep::, or
// unresolved::extern::) and rewrites its To to point at the new external
// node. Returns the rewritten edge for the caller to confirm, or nil
// when no stub is found.
//
// Two passes:
//
//  1. Exact stub-string lookup. The parser+resolver shape for direct
//     package calls like `fmt.Println(...)` lands as `stdlib::fmt::Println`
//     after the resolver runs (or `unresolved::extern::fmt::Println` if
//     resolution didn't happen yet). We replace the To with the real
//     external node ID.
//  2. Fuzzy line-and-name match. Method calls on external types (e.g.
//     `os.Stdout.Write(...)`) land as `unresolved::*.Write` because the
//     parser doesn't know `os.Stdout` resolves to the os.File receiver.
//     The fuzzy pass scans the caller's outgoing edges at the same line
//     and matches by trailing-name, which is enough to correctly claim
//     the stub without bringing in line-unrelated false positives.
//
// Why this matters: previously the resolver wrote stub-string targets like
// "stdlib::fmt::Println" that no node holds. Once goanalysis materialises
// the real ext::go:fmt::Println node, leaving the stub edge in place
// would double-count the call (one stub, one real). ReindexEdge migrates
// the byTo bucket so find_usages on the new node returns the correct
// caller and the stub bucket drains.
func (e *externalsAttribution) claimAndUpgradeStub(callerID string, importPath string, obj types.Object, newTarget string, line int) *graph.Edge {
	if edge := e.claimByExactStub(callerID, importPath, obj, newTarget); edge != nil {
		return edge
	}
	if edge := e.claimByLineAndName(callerID, obj, newTarget, line); edge != nil {
		return edge
	}
	return nil
}

// claimByExactStub handles the canonical resolver-shaped targets. Pulled
// out so the fuzzy pass can layer on top.
func (e *externalsAttribution) claimByExactStub(callerID string, importPath string, obj types.Object, newTarget string) *graph.Edge {
	candidates := stubEdgeTargets(importPath, obj)
	for _, target := range candidates {
		edge := semantic.FindEdgeByTarget(e.g, callerID, target)
		if edge == nil {
			continue
		}
		oldTo := edge.To
		edge.To = newTarget
		e.g.ReindexEdge(edge, oldTo)
		semantic.ConfirmEdge(edge, e.provider)
		e.edgesUpgraded++
		return edge
	}
	return nil
}

// claimByLineAndName scans the caller's outgoing edges at line `line` for
// any edge whose target is still a stub-string (`unresolved::`, `external::`,
// `stdlib::`, `dep::`) and whose trailing-name matches obj.Name(). Used
// for method calls on external types where the parser's `unresolved::*.M`
// shape doesn't carry the import path.
//
// Conservative — only matches stub targets so we never overwrite a
// resolver-confirmed real edge — and only when both line and trailing
// name match, which together pin the use-site uniquely.
func (e *externalsAttribution) claimByLineAndName(callerID string, obj types.Object, newTarget string, line int) *graph.Edge {
	if line <= 0 {
		return nil
	}
	name := obj.Name()
	if name == "" {
		return nil
	}
	expected := wantedEdgeKind(obj)
	for _, edge := range e.g.GetOutEdges(callerID) {
		if edge.Line != line {
			continue
		}
		if expected != "" && edge.Kind != expected {
			continue
		}
		if !isStubTarget(edge.To) {
			continue
		}
		if !stubTargetTrailingNameMatches(edge.To, name) {
			continue
		}
		oldTo := edge.To
		edge.To = newTarget
		e.g.ReindexEdge(edge, oldTo)
		semantic.ConfirmEdge(edge, e.provider)
		e.edgesUpgraded++
		return edge
	}
	return nil
}

// wantedEdgeKind returns the EdgeKind goanalysis would emit for obj, used
// to scope the fuzzy claim so we don't accidentally rewrite an unrelated
// edge with a different semantic.
func wantedEdgeKind(obj types.Object) graph.EdgeKind {
	if obj == nil {
		return ""
	}
	switch obj.(type) {
	case *types.Func:
		return graph.EdgeCalls
	case *types.TypeName, *types.Var, *types.Const:
		return graph.EdgeReferences
	}
	return ""
}

// isStubTarget reports whether a target ID is one of the bookkeeping
// strings the resolver writes for unresolved or external lookups.
func isStubTarget(to string) bool {
	switch {
	case strings.HasPrefix(to, "unresolved::"),
		strings.HasPrefix(to, "external::"),
		strings.HasPrefix(to, "stdlib::"),
		strings.HasPrefix(to, "dep::"):
		return true
	}
	return false
}

// stubTargetTrailingNameMatches reports whether the trailing name of a
// stub target (everything after the final `::` or after `*.`) equals
// `name`. The encodings include:
//
//	unresolved::FooBar              → trailing FooBar
//	unresolved::*.Method            → trailing Method
//	unresolved::extern::pkg::Sym    → trailing Sym
//	stdlib::pkg::Sym                → trailing Sym
//	dep::pkg::Sym                   → trailing Sym
//	external::pkg                   → trailing pkg (file-level imports)
func stubTargetTrailingNameMatches(to, name string) bool {
	trailing := to
	if idx := strings.LastIndex(trailing, "::"); idx >= 0 {
		trailing = trailing[idx+2:]
	}
	trailing = strings.TrimPrefix(trailing, "*.")
	return trailing == name
}

// ensureModuleNode finds (or creates) the KindModule node for pkg's owning
// module. Returns the module node ID, or "" when pkg belongs to the
// indexed repo's main module (no externalisation needed).
//
// The stdlib (pkg.Module == nil for stdlib) shares one synthetic
// "module::go:stdlib" node so callers can group stdlib references under
// a single edge target. Module-cache packages reuse any existing
// KindModule node materialised from go.mod by internal/modules and
// otherwise fall back to creating one — go/types is the source of
// truth when go.mod parsing missed the dep (e.g. tooling/test fixtures).
func (e *externalsAttribution) ensureModuleNode(pkg *packages.Package) string {
	if pkg == nil {
		return ""
	}
	importPath := pkg.PkgPath
	if id, ok := e.moduleByPath[importPath]; ok {
		return id
	}

	if pkg.Module == nil {
		// No module info → assume Go stdlib. The stdlib has no go.mod
		// entry; one shared module node covers every stdlib package.
		if !e.stdlibCreated {
			if existing := e.g.GetNode(stdlibModuleID); existing == nil {
				e.g.AddNode(&graph.Node{
					ID:       stdlibModuleID,
					Kind:     graph.KindModule,
					Name:     "stdlib",
					FilePath: externalFilePath("std"),
					Language: "go",
					Meta: map[string]any{
						"ecosystem":       "go",
						"path":            modulePathStdlib,
						"version":         "",
						"module_kind":     "stdlib",
						"semantic_source": e.provider,
					},
				})
				e.nodesAdded++
			}
			e.stdlibCreated = true
		}
		e.moduleByPath[importPath] = stdlibModuleID
		return stdlibModuleID
	}

	if pkg.Module.Main {
		// The indexed repo itself — caller should treat this obj as
		// internal and look it up in objToNode instead.
		e.moduleByPath[importPath] = ""
		return ""
	}

	mod := pkg.Module
	if mod.Replace != nil {
		mod = mod.Replace
	}
	moduleID := goModuleNodeID(mod.Path, mod.Version)
	if existing := e.g.GetNode(moduleID); existing == nil {
		meta := map[string]any{
			"ecosystem":       "go",
			"path":            mod.Path,
			"version":         mod.Version,
			"module_kind":     "module_cache",
			"indirect":        mod.Indirect,
			"semantic_source": e.provider,
		}
		if pkg.Module.Replace != nil {
			meta["replace"] = mod.Path + "@" + mod.Version
			meta["replaced_path"] = pkg.Module.Path
		}
		e.g.AddNode(&graph.Node{
			ID:       moduleID,
			Kind:     graph.KindModule,
			Name:     shortModulePath(mod.Path),
			FilePath: externalFilePath(mod.Path),
			Language: "go",
			Meta:     meta,
		})
		e.nodesAdded++
	}
	e.moduleByPath[importPath] = moduleID
	return moduleID
}

// stubEdgeTargets enumerates every stub-string the resolver might have
// written for an external obj. Order matches resolver precedence:
// stdlib::/dep:: are produced post-resolve, unresolved::extern:: is the
// raw form when resolveExtern wasn't run.
func stubEdgeTargets(importPath string, obj types.Object) []string {
	if obj == nil {
		return nil
	}
	name := obj.Name()
	if name == "" {
		return nil
	}
	return []string{
		"stdlib::" + importPath + "::" + name,
		"dep::" + importPath + "::" + name,
		"unresolved::extern::" + importPath + "::" + name,
	}
}

// buildExternalNode constructs a graph node for an external symbol. The
// FilePath is a synthetic "external::go:<importPath>" string so byFile
// lookups don't pollute real source-file buckets.
func buildExternalNode(nodeID string, kind graph.NodeKind, importPath, moduleID string, pkg *packages.Package, obj types.Object, provider string) *graph.Node {
	moduleKind, version := classifyPackage(pkg)
	meta := map[string]any{
		"external":        true,
		"import_path":     importPath,
		"module_path":     modulePathOf(pkg),
		"module_kind":     moduleKind,
		"module_id":       moduleID,
		"semantic_source": provider,
	}
	if version != "" {
		meta["version"] = version
	}
	if sig := types.ObjectString(obj, nil); sig != "" {
		meta["signature"] = sig
	}
	if typeStr := types.TypeString(obj.Type(), nil); typeStr != "" && typeStr != "invalid type" {
		meta["semantic_type"] = typeStr
	}
	if recv := receiverTypeName(obj); recv != "" {
		meta["receiver"] = recv
	}
	qualName := importPath + "." + obj.Name()
	if recv := receiverTypeName(obj); recv != "" {
		qualName = importPath + "." + recv + "." + obj.Name()
	}
	return &graph.Node{
		ID:       nodeID,
		Kind:     kind,
		Name:     obj.Name(),
		QualName: qualName,
		FilePath: externalFilePath(importPath),
		Language: "go",
		Meta:     meta,
	}
}

// classifyPackage labels a loaded package as stdlib / module_cache / main.
// Returns (kind, version) — version is empty for stdlib and main.
func classifyPackage(pkg *packages.Package) (string, string) {
	if pkg == nil {
		return "", ""
	}
	if pkg.Module == nil {
		return "stdlib", ""
	}
	if pkg.Module.Main {
		return "main", ""
	}
	mod := pkg.Module
	if mod.Replace != nil {
		mod = mod.Replace
	}
	return "module_cache", mod.Version
}

// modulePathOf returns the owning module's path for pkg. Stdlib packages
// fall back to the package import path so callers can group by package
// (the stdlib has no module path proper); module-cache packages return
// the module path which may be a prefix of the import path.
func modulePathOf(pkg *packages.Package) string {
	if pkg == nil {
		return ""
	}
	if pkg.Module == nil {
		return pkg.PkgPath
	}
	mod := pkg.Module
	if mod.Replace != nil {
		mod = mod.Replace
	}
	return mod.Path
}

// receiverTypeName returns the bare type name of obj's receiver when obj
// is a method. Strips pointer wrappers — Go forbids both `func (T) M()`
// and `func (*T) M()` on the same type, so the bare name uniquely
// identifies the method's owner. Returns "" for non-methods.
func receiverTypeName(obj types.Object) string {
	fn, ok := obj.(*types.Func)
	if !ok {
		return ""
	}
	sig, ok := fn.Type().(*types.Signature)
	if !ok || sig.Recv() == nil {
		return ""
	}
	t := sig.Recv().Type()
	if ptr, ok := t.(*types.Pointer); ok {
		t = ptr.Elem()
	}
	if named, ok := t.(*types.Named); ok {
		return named.Obj().Name()
	}
	return ""
}

// externalNodeID returns the canonical ID for an external symbol node.
// Methods are disambiguated by receiver type so `os.File.Write` and a
// hypothetical `os.Foo.Write` would land on distinct nodes; pure
// functions / vars / consts / types use the package-qualified short
// name. Returns "" when name is empty.
func externalNodeID(importPath string, obj types.Object) string {
	name := obj.Name()
	if name == "" {
		return ""
	}
	if recv := receiverTypeName(obj); recv != "" {
		return "ext::go:" + importPath + "::" + recv + "." + name
	}
	return "ext::go:" + importPath + "::" + name
}

// externalNodeKind classifies a types.Object into the matching graph
// NodeKind. Funcs with a receiver become methods; type names with an
// interface underlying type become interfaces. Unsupported obj kinds
// (PkgName, Label, Builtin, Nil) return "" so resolveSymbol drops them.
func externalNodeKind(obj types.Object) graph.NodeKind {
	switch t := obj.(type) {
	case *types.Func:
		if sig, ok := t.Type().(*types.Signature); ok && sig.Recv() != nil {
			return graph.KindMethod
		}
		return graph.KindFunction
	case *types.TypeName:
		if t.Type() == nil {
			return graph.KindType
		}
		if _, isIface := t.Type().Underlying().(*types.Interface); isIface {
			return graph.KindInterface
		}
		return graph.KindType
	case *types.Var:
		return graph.KindVariable
	case *types.Const:
		return graph.KindConstant
	default:
		return ""
	}
}

// externalFilePath returns the synthetic file-path used for external
// nodes. Kept consistent so byFile lookups don't pollute real file
// buckets.
func externalFilePath(importPath string) string {
	if importPath == "" {
		return "external::go"
	}
	return "external::go:" + importPath
}

// goModuleNodeID returns the canonical KindModule node ID for a Go
// module. Mirrors internal/modules/scanner.go::ModuleNodeID without
// importing scanner — that import would create graph→modules→graph
// cycle when scanner already imports graph.
func goModuleNodeID(path, version string) string {
	id := "module::go:" + path
	if version != "" {
		id += "@" + version
	}
	return id
}

// shortModulePath returns the last meaningful segment of a module path,
// stripping the /vN major-version suffix so `github.com/foo/bar/v2`
// surfaces as `bar` not `v2`. Mirrors internal/modules/scanner.go::shortName.
func shortModulePath(path string) string {
	if path == "" {
		return ""
	}
	parts := strings.Split(path, "/")
	last := parts[len(parts)-1]
	if len(parts) >= 2 && isMajorVersion(last) {
		last = parts[len(parts)-2]
	}
	return last
}

// isMajorVersion matches "v2", "v3", "v10" etc. — the Go-modules SemVer
// major-version suffix that lives at the end of a module path.
func isMajorVersion(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
}
