package languages

import (
	"strings"
	"unicode"

	"github.com/zzet/gortex/internal/graph"
	"github.com/zzet/gortex/internal/parser"
	sitter "github.com/zzet/gortex/internal/parser/tsitter"
	"github.com/zzet/gortex/internal/parser/tsitter/python"
)

// qPyAll is a single tree-sitter query alternating over every pattern
// the Python extractor needs. One tree walk per file replaces the 10
// `parser.RunQuery` calls the previous design made (each of which
// recompiled its query and ran an independent cursor over the whole
// tree). Capture names are disjoint across patterns so the dispatch in
// Extract can branch on which name is set. Class membership is
// resolved via a strict parent walk on the captured node — the function
// must be a direct child of `block` whose direct parent is
// `class_definition`. This mirrors the legacy `pyQClassMethod` nesting
// (decorated methods land in the free-function bucket — same bug, same
// behaviour).
const qPyAll = `
[
  (function_definition
    name: (identifier) @func.name) @func.def

  (class_definition
    name: (identifier) @class.name) @class.def

  (import_statement
    name: (dotted_name) @import.name) @import.def

  (import_from_statement
    module_name: (dotted_name) @import.module) @importfrom.def

  (import_from_statement
    module_name: (relative_import) @importfrom.rel) @importfrom_rel.def

  (call
    function: (identifier) @call.name) @call.expr

  (call
    function: (attribute
      object: (_) @callattr.receiver
      attribute: (identifier) @callattr.method)) @callattr.expr

  (assignment
    left: (identifier) @var.name) @var.def

  (assignment
    left: (identifier) @tvar.name
    type: (type (identifier) @tvar.type)) @tvar.def

  (assignment
    left: (identifier) @uvar.name
    right: (call
      function: (identifier) @uvar.callee)) @uvar.def
]
`

// PythonExtractor extracts Python source files into graph nodes and edges.
type PythonExtractor struct {
	lang *sitter.Language
	qAll *parser.PreparedQuery
}

func NewPythonExtractor() *PythonExtractor {
	lang := python.GetLanguage()
	return &PythonExtractor{
		lang: lang,
		qAll: parser.MustPreparedQuery(qPyAll, lang),
	}
}

func (e *PythonExtractor) Language() string     { return "python" }
func (e *PythonExtractor) Extensions() []string { return []string{".py"} }

// --- Deferred match buffers ----------------------------------------

type pyDeferredCall struct {
	name     string
	receiver string // attribute receiver text
	line     int
	isAttr   bool
	expr     *sitter.Node // for FastAPI Depends() arg lookup
}

func (e *PythonExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
	tree, err := parser.ParseFile(src, e.lang)
	if err != nil {
		return nil, err
	}
	defer tree.Close()

	root := tree.RootNode()
	result := &parser.ExtractionResult{}

	fileNode := &graph.Node{
		ID: filePath, Kind: graph.KindFile, Name: filePath,
		FilePath: filePath, StartLine: 1, EndLine: int(root.EndPoint().Row) + 1,
		Language: "python",
	}
	fileID := fileNode.ID
	result.Nodes = append(result.Nodes, fileNode)

	seen := make(map[string]bool)
	annotationSeen := make(map[string]bool)
	imports := map[string]string{} // alias → module path
	tenv := make(typeEnv)
	tenvHasExplicit := make(map[string]bool) // names with Tier 0 type, lock from Tier 1 overwrite

	var calls []pyDeferredCall

	parser.EachMatch(e.qAll, root, src, func(m parser.QueryResult) {
		switch {

		case m.Captures["func.def"] != nil:
			e.emitFunction(m, filePath, fileID, src, result, seen, annotationSeen)

		case m.Captures["class.def"] != nil:
			e.emitClass(m, filePath, fileID, src, result, seen, annotationSeen)

		case m.Captures["import.def"] != nil:
			e.emitImport(m, filePath, fileID, src, result, imports)

		case m.Captures["importfrom.def"] != nil:
			e.emitImportFrom(m, filePath, fileID, src, result, imports)

		case m.Captures["importfrom_rel.def"] != nil:
			e.emitImportFromRelative(m, filePath, fileID, src, result, imports)

		case m.Captures["callattr.expr"] != nil:
			expr := m.Captures["callattr.expr"]
			calls = append(calls, pyDeferredCall{
				name:     m.Captures["callattr.method"].Text,
				receiver: m.Captures["callattr.receiver"].Text,
				line:     expr.StartLine + 1,
				isAttr:   true,
				expr:     expr.Node,
			})

		case m.Captures["call.expr"] != nil:
			expr := m.Captures["call.expr"]
			calls = append(calls, pyDeferredCall{
				name: m.Captures["call.name"].Text,
				line: expr.StartLine + 1,
				expr: expr.Node,
			})

		case m.Captures["tvar.def"] != nil:
			// Tier 0: explicit type annotation — overwrite tenv.
			name := m.Captures["tvar.name"].Text
			typeName := normalizePyTypeName(m.Captures["tvar.type"].Text)
			if typeName != "" {
				tenv[name] = typeName
				tenvHasExplicit[name] = true
			}

		case m.Captures["uvar.def"] != nil:
			// Tier 1: constructor-call inference. Only fills in keys
			// that didn't get an explicit type — match the legacy
			// `if _, exists := tenv[name]; exists { continue }` guard.
			name := m.Captures["uvar.name"].Text
			if tenvHasExplicit[name] {
				return
			}
			if _, exists := tenv[name]; exists {
				return
			}
			callee := m.Captures["uvar.callee"].Text
			if callee != "" && unicode.IsUpper(rune(callee[0])) {
				tenv[name] = callee
			}

		case m.Captures["var.def"] != nil:
			e.emitTopLevelVar(m, filePath, fileID, result, seen)
		}
	})

	// All function/method nodes have been emitted; map call sites to
	// their enclosing definition.
	funcRanges := buildFuncRanges(result)
	for _, c := range calls {
		callerID := findEnclosingFunc(funcRanges, c.line)
		if callerID == "" {
			continue
		}
		if c.isAttr {
			// Module-qualified call (requests.get, np.array, os.path.join):
			// attach the import path so resolver can classify externally.
			if importPath, ok := lookupPyImport(c.receiver, imports); ok {
				result.Edges = append(result.Edges, &graph.Edge{
					From: callerID, To: "unresolved::extern::" + importPath + "::" + c.name,
					Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
				})
				continue
			}

			edge := &graph.Edge{
				From: callerID, To: "unresolved::*." + c.name,
				Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
			}
			if recvType, ok := tenv[c.receiver]; ok {
				edge.Meta = map[string]any{"receiver_type": recvType}
			} else if strings.Contains(c.receiver, ".") || strings.Contains(c.receiver, "(") {
				if chainType := resolveChainType(c.receiver, tenv, result); chainType != "" {
					edge.Meta = map[string]any{"receiver_type": chainType}
				}
			}
			result.Edges = append(result.Edges, edge)
			continue
		}

		// Plain call. When the call name itself is bound by a
		// `from X import Y [as Z]` (or `import X as Y`) statement,
		// route the edge through the same module-attributed
		// extern stub the attribute-call branch uses — that's
		// what makes `from numpy import array; array(...)` and
		// `import numpy as np; np.array(...)` both attribute to
		// numpy after the resolver post-pass.
		if importPath, ok := lookupPyImport(c.name, imports); ok {
			result.Edges = append(result.Edges, &graph.Edge{
				From: callerID, To: "unresolved::extern::" + importPath + "::" + c.name,
				Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
			})
		} else {
			result.Edges = append(result.Edges, &graph.Edge{
				From: callerID, To: "unresolved::" + c.name,
				Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
			})
		}

		// FastAPI dependency injection: Depends(target) — emit a direct
		// edge from the enclosing function to the first identifier
		// argument of Depends so the target shows up as a caller
		// relationship.
		if c.name == "Depends" && c.expr != nil {
			if dep := firstIdentifierArg(c.expr, src); dep != "" {
				result.Edges = append(result.Edges, &graph.Edge{
					From: callerID, To: "unresolved::" + dep,
					Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line,
					Meta: map[string]any{"via": "fastapi.Depends"},
				})
			}
		}
	}

	// --- Event pub/sub edges ---
	pubsubImportPaths := importPathValues(imports)
	var pubsubEvents []pubsubEvent
	for _, c := range calls {
		if !c.isAttr || c.expr == nil {
			continue
		}
		if ev, ok := detectPyPubsubCall(c.expr, c.name, src, pubsubImportPaths, c.line); ok {
			pubsubEvents = append(pubsubEvents, ev)
		}
	}
	emitPubsubEvents(pubsubEvents,
		func(line int) string { return findEnclosingFunc(funcRanges, line) },
		filePath, "python", result)

	MaybeEnrichDatabricks(filePath, fileID, src, result)

	return result, nil
}

// --- Per-match emit helpers -----------------------------------------

// emitFunction handles every function_definition. Strict parent check
// (function → block → class_definition) mirrors the legacy nested
// pyQClassMethod pattern: only direct children of a class body emit as
// receiver-qualified methods. Decorated methods (wrapped in
// decorated_definition) and nested functions land in the free-function
// bucket — same as the legacy code.
func (e *PythonExtractor) emitFunction(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen, annotationSeen map[string]bool) {
	name := m.Captures["func.name"].Text
	def := m.Captures["func.def"]
	startLine1 := def.StartLine + 1

	doc := pyDocstringFromDef(def.Node, src)
	visibility := VisibilityByUnderscore(name)
	decorators := pyDecoratorNodes(def.Node)
	complexity := 0
	if def.Node != nil {
		if body := def.Node.ChildByFieldName("body"); body != nil {
			complexity = PyComplexity(body)
		}
	}

	className := pyDirectClassParent(def.Node, src)
	if className != "" {
		id := filePath + "::" + className + "." + name
		if seen[id] {
			return
		}
		seen[id] = true
		node := &graph.Node{
			ID: id, Kind: graph.KindMethod, Name: name,
			FilePath: filePath, StartLine: startLine1, EndLine: def.EndLine + 1,
			Language: "python", Meta: map[string]any{
				"receiver":   className,
				"signature":  "def " + name + "(...)",
				"visibility": visibility,
			},
		}
		if doc != "" {
			node.Meta["doc"] = doc
		}
		if complexity > 1 {
			node.Meta["complexity"] = complexity
		}
		if def.Node != nil {
			if rt := extractPyReturnType(def.Node, src); rt != "" {
				node.Meta["return_type"] = rt
			}
		}
		result.Nodes = append(result.Nodes, node)
		result.Edges = append(result.Edges, &graph.Edge{
			From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine1,
		})
		typeID := filePath + "::" + className
		result.Edges = append(result.Edges, &graph.Edge{
			From: id, To: typeID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: startLine1,
		})
		emitPyAnnotationEdges(decorators, id, filePath, src, result, annotationSeen)
		emitPyThrowsEdges(def.Node, src, id, filePath, startLine1, result)
		emitPyFunctionShape(id, def.Node, src, filePath, startLine1, result)
		return
	}

	// Free function (top-level or nested in another function).
	id := filePath + "::" + name
	if seen[id] {
		return
	}
	seen[id] = true
	meta := map[string]any{
		"signature":  "def " + name + "(...)",
		"visibility": visibility,
	}
	if doc != "" {
		meta["doc"] = doc
	}
	if complexity > 1 {
		meta["complexity"] = complexity
	}
	result.Nodes = append(result.Nodes, &graph.Node{
		ID: id, Kind: graph.KindFunction, Name: name,
		FilePath: filePath, StartLine: startLine1, EndLine: def.EndLine + 1,
		Language: "python", Meta: meta,
	})
	result.Edges = append(result.Edges, &graph.Edge{
		From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine1,
	})
	emitPyAnnotationEdges(decorators, id, filePath, src, result, annotationSeen)
	emitPyThrowsEdges(def.Node, src, id, filePath, startLine1, result)
	emitPyFunctionShape(id, def.Node, src, filePath, startLine1, result)
}

// emitPyThrowsEdges walks a function/method body for raise_statement
// nodes and emits an EdgeThrows per distinct exception name.
// `raise SomeError` and `raise SomeError("...")` both surface; bare
// `raise` (re-raise) is skipped because there's no specific type.
// `Origin: ASTInferred` because Python doesn't enforce a checked-
// exception contract — the body scan is a best-effort summary, not
// a proof of every exception that can propagate.
func emitPyThrowsEdges(funcNode *sitter.Node, src []byte, fromID, filePath string, fromLine int, result *parser.ExtractionResult) {
	if funcNode == nil {
		return
	}
	body := funcNode.ChildByFieldName("body")
	if body == nil {
		return
	}
	seen := map[string]bool{}
	pyWalkRaises(body, src, fromID, filePath, fromLine, seen, result)
}

func pyWalkRaises(node *sitter.Node, src []byte, fromID, filePath string, fromLine int, seen map[string]bool, result *parser.ExtractionResult) {
	if node == nil {
		return
	}
	if node.Type() == "raise_statement" {
		name := pyRaiseExceptionName(node, src)
		if name != "" && !seen[name] {
			seen[name] = true
			result.Edges = append(result.Edges, &graph.Edge{
				From:     fromID,
				To:       "unresolved::" + name,
				Kind:     graph.EdgeThrows,
				FilePath: filePath,
				Line:     int(node.StartPoint().Row) + 1,
				Origin:   graph.OriginASTInferred,
			})
		}
		return
	}
	for i := 0; i < int(node.NamedChildCount()); i++ {
		c := node.NamedChild(i)
		// Don't descend into nested function/class bodies — their raises
		// belong to those functions, not to us.
		if c == nil {
			continue
		}
		if c.Type() == "function_definition" || c.Type() == "class_definition" || c.Type() == "decorated_definition" || c.Type() == "lambda" {
			continue
		}
		pyWalkRaises(c, src, fromID, filePath, fromLine, seen, result)
	}
}

// pyRaiseExceptionName returns the exception type name from a
// raise_statement. Handles `raise X`, `raise X("msg")`, `raise X(...)`,
// and chained `raise X from Y` (we record X). Returns "" for bare
// `raise` (re-raise).
func pyRaiseExceptionName(raise *sitter.Node, src []byte) string {
	for i := 0; i < int(raise.NamedChildCount()); i++ {
		c := raise.NamedChild(i)
		if c == nil {
			continue
		}
		switch c.Type() {
		case "identifier":
			return c.Content(src)
		case "attribute":
			// e.g. errors.MyError → take the trailing attribute name.
			text := c.Content(src)
			if i := strings.LastIndex(text, "."); i >= 0 {
				return strings.TrimSpace(text[i+1:])
			}
			return strings.TrimSpace(text)
		case "call":
			fn := c.ChildByFieldName("function")
			if fn == nil {
				continue
			}
			text := fn.Content(src)
			if i := strings.LastIndex(text, "."); i >= 0 {
				return strings.TrimSpace(text[i+1:])
			}
			return strings.TrimSpace(text)
		}
	}
	return ""
}

func (e *PythonExtractor) emitClass(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen, annotationSeen map[string]bool) {
	name := m.Captures["class.name"].Text
	def := m.Captures["class.def"]
	id := filePath + "::" + name
	decorators := pyDecoratorNodes(def.Node)
	if seen[id] {
		emitPyAnnotationEdges(decorators, id, filePath, src, result, annotationSeen)
		return
	}
	seen[id] = true
	meta := map[string]any{"visibility": VisibilityByUnderscore(name)}
	if doc := pyDocstringFromDef(def.Node, src); doc != "" {
		meta["doc"] = doc
	}
	result.Nodes = append(result.Nodes, &graph.Node{
		ID: id, Kind: graph.KindType, Name: name,
		FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1,
		Language: "python",
		Meta:     meta,
	})
	result.Edges = append(result.Edges, &graph.Edge{
		From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1,
	})
	emitPyAnnotationEdges(decorators, id, filePath, src, result, annotationSeen)
	// PEP-695 generic class declarations (`class Foo[T]:`) carry a
	// `type_parameters` child same as functions; reuse the helper.
	emitPyGenericParamNodes(id, def.Node, src, filePath, def.StartLine+1, result)
	// ORM model attribution: emit EdgeModelsTable when the class
	// inherits from a known ORM base (SQLAlchemy / Django).
	detectPythonORMModel(def.Node, src, id, name, filePath, result)
}

// pyDecoratorNodes returns the `decorator` AST nodes attached to a
// function_definition or class_definition. In tree-sitter Python the
// decorators wrap the def in a `decorated_definition` parent —
// children of that parent that come before the def are the decorators.
func pyDecoratorNodes(defNode *sitter.Node) []*sitter.Node {
	if defNode == nil {
		return nil
	}
	parent := defNode.Parent()
	if parent == nil || parent.Type() != "decorated_definition" {
		return nil
	}
	var out []*sitter.Node
	for i := 0; i < int(parent.ChildCount()); i++ {
		c := parent.Child(i)
		if c != nil && c.Type() == "decorator" {
			out = append(out, c)
		}
	}
	return out
}

// emitPyAnnotationEdges emits an EdgeAnnotated edge per decorator node
// applied to the function/class identified by `fromID`. The decorator
// expression after the `@` is taken as-is; identifier-only decorators
// (`@deprecated`) and call decorators (`@app.route("/x")`) both map
// to the bare callable name.
func emitPyAnnotationEdges(decorators []*sitter.Node, fromID, filePath string, src []byte, result *parser.ExtractionResult, seen map[string]bool) {
	for _, dec := range decorators {
		name, args := pyDecoratorNameAndArgs(dec, src)
		if name == "" {
			continue
		}
		EmitAnnotationEdge(fromID, "python", name, args, filePath, int(dec.StartPoint().Row)+1, result, seen)
	}
}

// pyDecoratorNameAndArgs reads a `decorator` AST node. Tree-sitter
// Python wraps the post-`@` expression as the named child — typically
// `identifier`, `attribute`, or `call`.
func pyDecoratorNameAndArgs(dec *sitter.Node, src []byte) (string, string) {
	if dec == nil {
		return "", ""
	}
	for i := 0; i < int(dec.NamedChildCount()); i++ {
		c := dec.NamedChild(i)
		if c == nil {
			continue
		}
		switch c.Type() {
		case "identifier", "attribute", "dotted_name":
			return c.Content(src), ""
		case "call":
			fn := c.ChildByFieldName("function")
			args := c.ChildByFieldName("arguments")
			name := ""
			if fn != nil {
				name = fn.Content(src)
			}
			argText := ""
			if args != nil {
				argText = args.Content(src)
				if len(argText) >= 2 && argText[0] == '(' && argText[len(argText)-1] == ')' {
					argText = argText[1 : len(argText)-1]
				}
			}
			return name, argText
		}
	}
	return "", ""
}

// pyDocstringFromDef returns the docstring of a function_definition or
// class_definition node — the first string literal in the body block —
// or "" when none is present. Returns "" for nil nodes.
func pyDocstringFromDef(defNode *sitter.Node, src []byte) string {
	if defNode == nil {
		return ""
	}
	body := defNode.ChildByFieldName("body")
	if body == nil {
		return ""
	}
	for i := 0; i < int(body.NamedChildCount()); i++ {
		stmt := body.NamedChild(i)
		if stmt == nil {
			continue
		}
		if stmt.Type() != "expression_statement" {
			return ""
		}
		// Walk into expression_statement to find a string literal.
		for j := 0; j < int(stmt.NamedChildCount()); j++ {
			c := stmt.NamedChild(j)
			if c == nil {
				continue
			}
			if c.Type() == "string" {
				return ExtractPyDocstring(c.Content(src))
			}
		}
		return ""
	}
	return ""
}

// emitImport handles `import os`, `import os.path`, `import numpy as np`.
// Walks the import_statement node to populate the alias→module map used
// by attribute-call classification.
func (e *PythonExtractor) emitImport(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, imports map[string]string) {
	name := m.Captures["import.name"]
	pyEmitImportNode(filePath, fileID, name.Text, "", name.StartLine+1, result)
	result.Edges = append(result.Edges, &graph.Edge{
		From: fileID, To: "unresolved::import::" + name.Text,
		Kind: graph.EdgeImports, FilePath: filePath, Line: name.StartLine + 1,
	})
	def, ok := m.Captures["import.def"]
	if !ok || def.Node == nil {
		return
	}
	stmt := def.Node
	for i := 0; i < int(stmt.NamedChildCount()); i++ {
		child := stmt.NamedChild(i)
		switch child.Type() {
		case "dotted_name":
			dotted := child.Content(src)
			alias := dotted
			if j := strings.Index(dotted, "."); j >= 0 {
				alias = dotted[:j] // `import os.path` binds `os`
			}
			imports[alias] = dotted
		case "aliased_import":
			var modulePath, alias string
			for j := 0; j < int(child.NamedChildCount()); j++ {
				cc := child.NamedChild(j)
				switch cc.Type() {
				case "dotted_name":
					modulePath = cc.Content(src)
				case "identifier":
					alias = cc.Content(src)
				}
			}
			if alias != "" && modulePath != "" {
				imports[alias] = modulePath
			}
		}
	}
}

// emitImportFrom handles `from X import Y`, `from X import Y as Z`, and
// `from X import Y, Z`. Each imported name is registered in the per-
// file alias map so attribute-style calls (`Y(...)`) — and any later
// `Z(...)` aliases — resolve through `lookupPyImport` to the right
// dotted module path. Without this, every `from`-imported name fell
// through to the bare-call branch and never attributed to its module.
func (e *PythonExtractor) emitImportFrom(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, imports map[string]string) {
	mod := m.Captures["import.module"]
	pyEmitImportNode(filePath, fileID, mod.Text, "", mod.StartLine+1, result)
	result.Edges = append(result.Edges, &graph.Edge{
		From: fileID, To: "unresolved::import::" + mod.Text,
		Kind: graph.EdgeImports, FilePath: filePath, Line: mod.StartLine + 1,
	})

	def, ok := m.Captures["importfrom.def"]
	if !ok || def.Node == nil {
		return
	}
	stmt := def.Node
	// Walk the statement's named children. The module is the first
	// `dotted_name` (already captured); subsequent `dotted_name`
	// children are imported names. `aliased_import` children carry
	// the optional `as <alias>`. `wildcard_import` (`from X import *`)
	// is intentionally skipped — there's no specific name to bind.
	moduleSeen := false
	for i := 0; i < int(stmt.NamedChildCount()); i++ {
		child := stmt.NamedChild(i)
		switch child.Type() {
		case "dotted_name":
			if !moduleSeen {
				moduleSeen = true
				continue
			}
			name := child.Content(src)
			if name == "" {
				continue
			}
			imports[name] = mod.Text + "." + name
		case "aliased_import":
			var importedName, alias string
			for j := 0; j < int(child.NamedChildCount()); j++ {
				cc := child.NamedChild(j)
				switch cc.Type() {
				case "dotted_name":
					importedName = cc.Content(src)
				case "identifier":
					alias = cc.Content(src)
				}
			}
			if importedName == "" || alias == "" {
				continue
			}
			imports[alias] = mod.Text + "." + importedName
		}
	}
}

// emitImportFromRelative handles `from . import foo`, `from .sub import
// bar`, `from ..pkg.deep import x` shapes whose `module_name` is a
// `relative_import` rather than a `dotted_name`. Without this branch the
// parser silently dropped every relative import, leaving the graph
// blind to in-project package edges.
//
// The handler maps the relative reference to a project-rooted file-path
// stem so the resolver post-pass `resolveRelativeImports` can land the
// edge on the actual `KindFile` node. Imported names are bound in the
// per-file alias map with a `pyrel::<stem>` marker; the call-resolution
// loop in Extract recognises that shape and emits dataflow edges that
// the post-pass also rewrites onto internal file symbols.
func (e *PythonExtractor) emitImportFromRelative(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, _ map[string]string) {
	relCap := m.Captures["importfrom.rel"]
	if relCap.Node == nil {
		return
	}
	relNode := relCap.Node
	dots := 0
	modPath := ""
	for i := 0; i < int(relNode.NamedChildCount()); i++ {
		child := relNode.NamedChild(i)
		switch child.Type() {
		case "import_prefix":
			dots += strings.Count(child.Content(src), ".")
		case "dotted_name":
			modPath = child.Content(src)
		}
	}
	// Some grammar versions place the dot prefix in unnamed children;
	// scan all children for `.` tokens when the named-child walk yields
	// zero dots so we don't silently drop `from . import x`.
	if dots == 0 {
		for i := 0; i < int(relNode.ChildCount()); i++ {
			child := relNode.Child(i)
			if child.Type() == "." {
				dots++
			}
		}
	}
	if dots == 0 {
		return
	}

	stem := pyResolveRelativeStem(filePath, dots, modPath)
	if stem == "" {
		return
	}
	importLine := int(relNode.StartPoint().Row) + 1

	// When the relative import names a module (`from .util import a`),
	// emit one EdgeImports edge to the module stem — the imported
	// names live inside that module. When the dot prefix carries no
	// module name (`from . import util`), each imported NAME is itself
	// a submodule of the current package, so emit one edge per name
	// targeting `<stem>/<name>`. This is the same shape the absolute
	// `from numpy import array` path uses (one import edge to the
	// originating module) — the only difference is that for `from .
	// import X` the module name comes from the import list.
	if modPath != "" {
		pyEmitImportNode(filePath, fileID, modPath, "", importLine, result)
		result.Edges = append(result.Edges, &graph.Edge{
			From: fileID, To: "unresolved::pyrel::" + stem,
			Kind: graph.EdgeImports, FilePath: filePath, Line: importLine,
		})
	}

	def, ok := m.Captures["importfrom_rel.def"]
	if !ok || def.Node == nil {
		return
	}
	stmt := def.Node
	moduleSeen := false
	emittedSub := map[string]bool{}
	emitSubmodule := func(name, alias string) {
		if modPath != "" || name == "" {
			return
		}
		subStem := stem + "/" + name
		if emittedSub[subStem] {
			return
		}
		emittedSub[subStem] = true
		pyEmitImportNode(filePath, fileID, name, alias, importLine, result)
		result.Edges = append(result.Edges, &graph.Edge{
			From: fileID, To: "unresolved::pyrel::" + subStem,
			Kind: graph.EdgeImports, FilePath: filePath, Line: importLine,
		})
	}
	for i := 0; i < int(stmt.NamedChildCount()); i++ {
		child := stmt.NamedChild(i)
		switch child.Type() {
		case "relative_import":
			moduleSeen = true
			continue
		case "dotted_name":
			if !moduleSeen {
				moduleSeen = true
				continue
			}
			name := child.Content(src)
			emitSubmodule(name, "")
		case "aliased_import":
			var importedName, alias string
			for j := 0; j < int(child.NamedChildCount()); j++ {
				cc := child.NamedChild(j)
				switch cc.Type() {
				case "dotted_name":
					importedName = cc.Content(src)
				case "identifier":
					alias = cc.Content(src)
				}
			}
			emitSubmodule(importedName, alias)
		}
	}
}

// pyResolveRelativeStem maps a (filePath, dotCount, modPath) triple to
// the project-rooted file-path stem the import targets. Returns "" when
// the dot prefix would walk to or above the repo root — CPython raises
// ImportError on the same shape, so we refuse rather than invent a
// project-root pseudo-package.
//
//	pyResolveRelativeStem("app/main.py", 1, "")        → "app"
//	pyResolveRelativeStem("app/main.py", 1, "util")    → "app/util"
//	pyResolveRelativeStem("app/sub/x.py", 2, "parent") → "app/parent"
//	pyResolveRelativeStem("app/sub/x.py", 3, "y")      → "" (above root)
//	pyResolveRelativeStem("main.py", 1, "x")           → "" (no package)
func pyResolveRelativeStem(filePath string, dots int, modPath string) string {
	if dots <= 0 || filePath == "" {
		return ""
	}
	dir := ""
	if i := strings.LastIndex(filePath, "/"); i >= 0 {
		dir = filePath[:i]
	}
	levels := 0
	if dir != "" {
		levels = strings.Count(dir, "/") + 1
	}
	// `dots-1` is the number of parent-package walks. Walking to or
	// past `levels` means we'd land at (or above) the implicit repo
	// root with no real package — refuse rather than fabricate one.
	if dots-1 >= levels {
		return ""
	}
	for i := 1; i < dots; i++ {
		if j := strings.LastIndex(dir, "/"); j >= 0 {
			dir = dir[:j]
		} else {
			dir = ""
		}
	}
	if modPath == "" {
		return dir
	}
	suffix := strings.ReplaceAll(modPath, ".", "/")
	if dir == "" {
		return suffix
	}
	return dir + "/" + suffix
}

// pyEmitImportNode appends a KindImport node + Defines edge for a
// Python `import X` or `from X import …` statement. is_external is
// true when the path doesn't begin with a relative prefix and isn't
// in the small stdlib whitelist — close enough for routing UX while
// being trivially cheap.
func pyEmitImportNode(filePath, fileID, importPath, alias string, line int, result *parser.ExtractionResult) {
	if importPath == "" {
		return
	}
	importNodeID := filePath + "::import::" + importPath
	meta := map[string]any{
		"path":        importPath,
		"is_external": isExternalPyImport(importPath),
	}
	if alias != "" {
		meta["alias"] = alias
	}
	result.Nodes = append(result.Nodes, &graph.Node{
		ID:        importNodeID,
		Kind:      graph.KindImport,
		Name:      pyImportDisplayName(importPath),
		FilePath:  filePath,
		StartLine: line,
		EndLine:   line,
		Language:  "python",
		Meta:      meta,
	})
	result.Edges = append(result.Edges, &graph.Edge{
		From: fileID, To: importNodeID,
		Kind: graph.EdgeDefines, FilePath: filePath, Line: line,
	})
}

func isExternalPyImport(path string) bool {
	if path == "" || strings.HasPrefix(path, ".") {
		return false
	}
	// Best-effort stdlib check: a small whitelist of common stdlib
	// roots avoids tagging them as external. False negatives are
	// harmless (the agent can still query the import), false
	// positives would mislead "is this a third-party dep?" queries.
	first := path
	if i := strings.Index(path, "."); i >= 0 {
		first = path[:i]
	}
	switch first {
	case "os", "sys", "io", "re", "json", "math", "random", "time",
		"datetime", "collections", "itertools", "functools", "typing",
		"asyncio", "logging", "pathlib", "subprocess", "threading",
		"multiprocessing", "abc", "enum", "dataclasses", "contextlib",
		"copy", "tempfile", "shutil", "string", "struct", "hashlib",
		"unittest", "ast", "types", "warnings", "weakref", "inspect":
		return false
	}
	return true
}

func pyImportDisplayName(path string) string {
	if i := strings.LastIndex(path, "."); i >= 0 {
		return path[i+1:]
	}
	return path
}

func (e *PythonExtractor) emitTopLevelVar(m parser.QueryResult, filePath, fileID string, result *parser.ExtractionResult, seen map[string]bool) {
	name := m.Captures["var.name"].Text
	def := m.Captures["var.def"]
	if def.Node == nil || def.Node.Parent() == nil || def.Node.Parent().Type() != "module" {
		return
	}
	id := filePath + "::" + name
	if seen[id] || strings.HasPrefix(name, "_") {
		return
	}
	seen[id] = true
	result.Nodes = append(result.Nodes, &graph.Node{
		ID: id, Kind: graph.KindVariable, Name: name,
		FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1,
		Language: "python",
	})
	result.Edges = append(result.Edges, &graph.Edge{
		From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1,
	})
}

// --- Helpers --------------------------------------------------------

// pyDirectClassParent returns the enclosing class name when fn is a
// direct child of a class_definition's body block, mirroring the
// legacy nested pyQClassMethod pattern. Returns "" for decorated
// methods (wrapped in decorated_definition), nested functions, and
// top-level functions — preserving legacy behaviour exactly.
func pyDirectClassParent(fn *sitter.Node, src []byte) string {
	if fn == nil {
		return ""
	}
	parent := fn.Parent()
	if parent == nil || parent.Type() != "block" {
		return ""
	}
	grand := parent.Parent()
	if grand == nil || grand.Type() != "class_definition" {
		return ""
	}
	nameNode := grand.ChildByFieldName("name")
	if nameNode == nil {
		return ""
	}
	return nameNode.Content(src)
}

// firstIdentifierArg returns the string content of the first positional
// argument to a Python call_expression when that argument is a bare
// identifier (function name or class name), or "" otherwise. Used for
// FastAPI's Depends(target) where we want the target, not Depends
// itself, to show up as the called symbol. Non-identifier arguments —
// lambdas, attribute access, calls — are skipped because they can't
// be statically resolved to a graph node.
func firstIdentifierArg(callNode *sitter.Node, src []byte) string {
	args := callNode.ChildByFieldName("arguments")
	if args == nil {
		return ""
	}
	for i := 0; i < int(args.NamedChildCount()); i++ {
		arg := args.NamedChild(i)
		if arg == nil {
			continue
		}
		// Skip keyword arguments — Depends(use_cache=False, ...) shouldn't
		// produce a call edge to `False`.
		if arg.Type() == "keyword_argument" {
			continue
		}
		if arg.Type() == "identifier" {
			return arg.Content(src)
		}
		return ""
	}
	return ""
}

// extractPyReturnType walks a function_definition node for a return_type child
// (the `-> Type` annotation) and returns the normalized type name.
func extractPyReturnType(funcNode *sitter.Node, src []byte) string {
	for i := 0; i < int(funcNode.NamedChildCount()); i++ {
		child := funcNode.NamedChild(i)
		if child.Type() == "type" {
			// Check if preceding sibling token is "->".
			// In tree-sitter Python grammar, the return type is a "type" child
			// that appears after the parameters.
			return normalizePyTypeName(child.Content(src))
		}
	}
	return ""
}

// normalizePyTypeName strips Optional[], List[], etc. and skips builtins.
func normalizePyTypeName(t string) string {
	t = strings.TrimSpace(t)
	// Strip Optional[...], List[...], etc.
	if idx := strings.Index(t, "["); idx > 0 {
		t = t[:idx]
	}
	switch t {
	case "int", "float", "str", "bool", "bytes", "None", "list", "dict", "set", "tuple", "object":
		return ""
	}
	if t == "" || (t[0] >= 'a' && t[0] <= 'z') {
		return ""
	}
	return t
}

// lookupPyImport resolves a dotted Python receiver to an import path.
// Tries the full receiver first, then progressively shorter prefixes
// (os.path → os), so `os.path.join(x)` with `import os.path` finds the
// right module.
func lookupPyImport(receiver string, imports map[string]string) (string, bool) {
	if p, ok := imports[receiver]; ok {
		return p, true
	}
	for i := strings.LastIndex(receiver, "."); i > 0; i = strings.LastIndex(receiver[:i], ".") {
		prefix := receiver[:i]
		if p, ok := imports[prefix]; ok {
			return p, true
		}
	}
	if i := strings.Index(receiver, "."); i > 0 {
		if p, ok := imports[receiver[:i]]; ok {
			return p, true
		}
	}
	return "", false
}
