// Thuban Go AST Helper
//
// Reads Go source from stdin, parses it with the standard library's
// go/parser (the same parser the Go toolchain itself uses), and prints a
// single-line JSON report to stdout describing:
//
//   unused_import     Imported packages never referenced anywhere in the file
//   unused_variable   A value assigned to a local variable that is
//                     overwritten before ever being read (ineffectual
//                     assignment) — Go's compiler already rejects locals
//                     that are NEVER used at all, so this targets the
//                     "assigned-then-clobbered" case real linters catch
//   high_complexity   Functions whose McCabe cyclomatic complexity exceeds
//                     COMPLEXITY_THRESHOLD
//   dead_code         Statements that are unreachable because they follow
//                     an unconditional return/panic/break/continue/goto in
//                     the same block
//   unchecked_error   A call to a well-known error-returning method
//                     (Close/Flush/Rollback/Commit/Sync) used as a bare
//                     statement, or a multi-value assignment whose LAST
//                     value (error, by Go convention) is discarded via `_`
//   goroutine_leak    `go func(){ ... }()` whose body contains an
//                     unconditional `for {}` loop with no select/break/
//                     return to ever stop it — a classic leaked goroutine
//   defer_in_loop     A `defer` statement directly inside a for/range loop
//                     body (accumulates until the enclosing function
//                     returns instead of releasing per-iteration)
//
// This script is invoked by packages/scanner/go-ast-analyzer.js as a
// subprocess (`go run go_ast_helper.go`, one call per Go file, source on
// stdin). It must never panic past main() — on any parse failure it
// prints {"ok": false, "error": "..."} and exits 0, so the caller can
// treat AST analysis as a best-effort supplement.
//
// Output contract (stdout, single JSON object):
//   {"ok": true, "issues": [{"type": str, "line": int, "name": str, "message": str}, ...]}
//   {"ok": false, "error": str}
//
// Only Go standard library packages are used (go/ast, go/parser, go/token,
// encoding/json) so this file runs directly with `go run` — no go.mod, no
// external modules, no build step.
package main

import (
	"encoding/json"
	"fmt"
	"go/ast"
	"go/parser"
	"go/token"
	"io"
	"os"
	"path"
	"sort"
	"strings"
)

const complexityThreshold = 10

// errorReturningMethods is a curated, low-false-positive set of standard
// library method names that return an `error` as their only/last result
// and are extremely commonly (and dangerously) left unchecked in real Go
// code. We don't have type information (no go/types, no build), so this
// list — rather than a general "does this call return an error" analysis
// — is what keeps unchecked_error precise instead of noisy.
var errorReturningMethods = map[string]bool{
	"Close":    true,
	"Flush":    true,
	"Rollback": true,
	"Commit":   true,
	"Sync":     true,
}

type issue struct {
	Type    string `json:"type"`
	Line    int    `json:"line"`
	Name    string `json:"name,omitempty"`
	Message string `json:"message"`
}

type result struct {
	OK     bool    `json:"ok"`
	Issues []issue `json:"issues,omitempty"`
	Error  string  `json:"error,omitempty"`
}

func printResult(r result) {
	b, err := json.Marshal(r)
	if err != nil {
		fmt.Println(`{"ok":false,"error":"json marshal failed"}`)
		return
	}
	fmt.Println(string(b))
}

func main() {
	src, err := io.ReadAll(os.Stdin)
	if err != nil {
		printResult(result{OK: false, Error: err.Error()})
		return
	}

	fset := token.NewFileSet()
	file, err := parser.ParseFile(fset, "input.go", src, parser.ParseComments)
	if err != nil {
		printResult(result{OK: false, Error: fmt.Sprintf("ParseError: %s", err)})
		return
	}

	var issues []issue

	func() {
		defer func() { recover() }() // never let one checker's bug break the whole helper
		issues = append(issues, checkUnusedImports(fset, file)...)
	}()

	for _, decl := range file.Decls {
		fn, ok := decl.(*ast.FuncDecl)
		if !ok || fn.Body == nil {
			continue
		}
		func() {
			defer func() { recover() }()
			issues = append(issues, checkIneffectualAssignments(fset, fn.Body)...)
		}()
		func() {
			defer func() { recover() }()
			issues = append(issues, checkComplexity(fset, fn)...)
		}()
		func() {
			defer func() { recover() }()
			issues = append(issues, checkDeadCode(fset, fn.Body)...)
		}()
		func() {
			defer func() { recover() }()
			issues = append(issues, checkUncheckedErrors(fset, fn.Body)...)
		}()
		func() {
			defer func() { recover() }()
			issues = append(issues, checkGoroutineLeaks(fset, fn.Body)...)
		}()
		func() {
			defer func() { recover() }()
			issues = append(issues, checkDeferInLoop(fset, fn.Body)...)
		}()
	}

	sort.SliceStable(issues, func(i, j int) bool { return issues[i].Line < issues[j].Line })
	printResult(result{OK: true, Issues: issues})
}

// ─────────────────────────────────────────────────────────────────────────
//  Unused imports
// ─────────────────────────────────────────────────────────────────────────

func importLocalName(imp *ast.ImportSpec) (name string, skip bool) {
	if imp.Name != nil {
		n := imp.Name.Name
		if n == "_" || n == "." {
			return "", true // blank/dot imports are intentionally side-effect-only
		}
		return n, false
	}
	p := strings.Trim(imp.Path.Value, `"`)
	return path.Base(p), false
}

func checkUnusedImports(fset *token.FileSet, file *ast.File) []issue {
	type importInfo struct {
		name string
		line int
	}
	var imports []importInfo
	for _, imp := range file.Imports {
		name, skip := importLocalName(imp)
		if skip {
			continue
		}
		imports = append(imports, importInfo{name: name, line: fset.Position(imp.Pos()).Line})
	}
	if len(imports) == 0 {
		return nil
	}

	used := map[string]bool{}
	ast.Inspect(file, func(n ast.Node) bool {
		sel, ok := n.(*ast.SelectorExpr)
		if !ok {
			return true
		}
		if id, ok := sel.X.(*ast.Ident); ok {
			used[id.Name] = true
		}
		return true
	})

	var issues []issue
	for _, imp := range imports {
		if used[imp.name] {
			continue
		}
		issues = append(issues, issue{
			Type:    "unused_import",
			Line:    imp.line,
			Name:    imp.name,
			Message: fmt.Sprintf("Unused import: '%s'", imp.name),
		})
	}
	return issues
}

// ─────────────────────────────────────────────────────────────────────────
//  Ineffectual assignments ("unused_variable" — Go itself already rejects
//  locals that are never read at all, so this targets values that ARE
//  assigned but overwritten before ever being read; heuristic, single-pass,
//  block-scoped, does not follow branches — matches the "best-effort
//  supplement" philosophy of the rest of this checker suite)
// ─────────────────────────────────────────────────────────────────────────

func exprIdentLoads(e ast.Expr, into map[string]bool) {
	ast.Inspect(e, func(n ast.Node) bool {
		if id, ok := n.(*ast.Ident); ok {
			into[id.Name] = true
		}
		return true
	})
}

func checkIneffectualAssignments(fset *token.FileSet, body *ast.BlockStmt) []issue {
	var issues []issue
	pending := map[string]int{} // varName -> line of the assignment awaiting a read

	markUsed := func(names map[string]bool) {
		for n := range names {
			delete(pending, n)
		}
	}

	var walkStmts func(stmts []ast.Stmt)
	walkStmts = func(stmts []ast.Stmt) {
		for _, stmt := range stmts {
			switch s := stmt.(type) {
			case *ast.AssignStmt:
				// First, any identifiers read on the RHS mark prior pending
				// assignments as used (e.g. `x = x + 1` reads x before
				// rewriting it).
				reads := map[string]bool{}
				for _, rhs := range s.Rhs {
					exprIdentLoads(rhs, reads)
				}
				// Non-simple LHS targets (e.g. `obj.Field = x`, `arr[i] = x`)
				// also count as a "read" of any identifiers they reference.
				for _, lhs := range s.Lhs {
					if _, simple := lhs.(*ast.Ident); !simple {
						exprIdentLoads(lhs, reads)
					}
				}
				markUsed(reads)

				if s.Tok == token.ASSIGN || s.Tok == token.DEFINE {
					for _, lhs := range s.Lhs {
						id, ok := lhs.(*ast.Ident)
						if !ok || id.Name == "_" {
							continue
						}
						if prevLine, exists := pending[id.Name]; exists && s.Tok == token.ASSIGN {
							issues = append(issues, issue{
								Type: "unused_variable",
								Line: prevLine,
								Name: id.Name,
								Message: fmt.Sprintf(
									"Value assigned to '%s' on this line is overwritten before being used",
									id.Name,
								),
							})
						}
						pending[id.Name] = fset.Position(id.Pos()).Line
					}
				}
			case *ast.ExprStmt:
				reads := map[string]bool{}
				exprIdentLoads(s.X, reads)
				markUsed(reads)
			case *ast.IfStmt:
				reads := map[string]bool{}
				if s.Cond != nil {
					exprIdentLoads(s.Cond, reads)
				}
				markUsed(reads)
				// Branch bodies are analyzed independently; conservatively
				// drop any pending assignment referenced by name inside a
				// branch so we never falsely flag across a branch boundary.
				dropAllReferencedIn(s.Body, pending)
				if s.Else != nil {
					dropAllReferencedIn(s.Else, pending)
				}
			case *ast.ForStmt, *ast.RangeStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.SelectStmt:
				// Loops/switches may run zero or many times and can jump
				// around in ways this single-pass heuristic can't safely
				// model — drop any pending state they might reference
				// rather than risk a false positive.
				dropAllReferencedIn(stmt, pending)
			case *ast.BlockStmt:
				walkStmts(s.List)
			case *ast.ReturnStmt:
				reads := map[string]bool{}
				for _, r := range s.Results {
					exprIdentLoads(r, reads)
				}
				markUsed(reads)
			default:
				reads := map[string]bool{}
				ast.Inspect(stmt, func(n ast.Node) bool {
					if id, ok := n.(*ast.Ident); ok {
						reads[id.Name] = true
					}
					return true
				})
				markUsed(reads)
			}
		}
	}

	walkStmts(body.List)
	return issues
}

// dropAllReferencedIn removes any pending-assignment entries whose
// variable name is referenced anywhere inside node, without asserting
// order — used to conservatively bail out of ambiguous control flow
// (branches, loops, switches) rather than risk a false positive.
func dropAllReferencedIn(node ast.Node, pending map[string]int) {
	ast.Inspect(node, func(n ast.Node) bool {
		if id, ok := n.(*ast.Ident); ok {
			delete(pending, id.Name)
		}
		return true
	})
}

// ─────────────────────────────────────────────────────────────────────────
//  Cyclomatic complexity
// ─────────────────────────────────────────────────────────────────────────

func checkComplexity(fset *token.FileSet, fn *ast.FuncDecl) []issue {
	complexity := 1
	ast.Inspect(fn.Body, func(n ast.Node) bool {
		switch node := n.(type) {
		case *ast.FuncLit:
			return false // nested closures are analyzed on their own merits elsewhere; don't double count
		case *ast.IfStmt:
			complexity++
		case *ast.ForStmt:
			complexity++
		case *ast.RangeStmt:
			complexity++
		case *ast.CaseClause:
			if len(node.List) > 0 { // default case doesn't add a decision point
				complexity++
			}
		case *ast.CommClause:
			if node.Comm != nil {
				complexity++
			}
		case *ast.BinaryExpr:
			if node.Op == token.LAND || node.Op == token.LOR {
				complexity++
			}
		}
		return true
	})

	if complexity <= complexityThreshold {
		return nil
	}
	return []issue{{
		Type: "high_complexity",
		Line: fset.Position(fn.Pos()).Line,
		Name: fn.Name.Name,
		Message: fmt.Sprintf(
			"Function '%s' has cyclomatic complexity %d (threshold %d)",
			fn.Name.Name, complexity, complexityThreshold,
		),
	}}
}

// ─────────────────────────────────────────────────────────────────────────
//  Dead code (unreachable statements)
// ─────────────────────────────────────────────────────────────────────────

func isPanicCall(stmt ast.Stmt) bool {
	exprStmt, ok := stmt.(*ast.ExprStmt)
	if !ok {
		return false
	}
	call, ok := exprStmt.X.(*ast.CallExpr)
	if !ok {
		return false
	}
	id, ok := call.Fun.(*ast.Ident)
	return ok && id.Name == "panic"
}

func isTerminator(stmt ast.Stmt) (bool, string) {
	switch s := stmt.(type) {
	case *ast.ReturnStmt:
		return true, "return"
	case *ast.BranchStmt:
		if s.Tok == token.BREAK || s.Tok == token.CONTINUE || s.Tok == token.GOTO {
			return true, strings.ToLower(s.Tok.String())
		}
		return false, ""
	default:
		if isPanicCall(stmt) {
			return true, "panic"
		}
		return false, ""
	}
}

func checkBlockForDeadCode(fset *token.FileSet, stmts []ast.Stmt, issues *[]issue) {
	for i, stmt := range stmts {
		if terminates, kind := isTerminator(stmt); terminates {
			if i+1 < len(stmts) {
				next := stmts[i+1]
				*issues = append(*issues, issue{
					Type:    "dead_code",
					Line:    fset.Position(next.Pos()).Line,
					Message: fmt.Sprintf("Unreachable code after '%s' statement", kind),
				})
			}
			break // only report the first unreachable run per block
		}
	}
}

func checkDeadCode(fset *token.FileSet, body *ast.BlockStmt) []issue {
	var issues []issue
	ast.Inspect(body, func(n ast.Node) bool {
		switch node := n.(type) {
		case *ast.BlockStmt:
			checkBlockForDeadCode(fset, node.List, &issues)
		case *ast.CaseClause:
			checkBlockForDeadCode(fset, node.Body, &issues)
		case *ast.CommClause:
			checkBlockForDeadCode(fset, node.Body, &issues)
		}
		return true
	})
	return issues
}

// ─────────────────────────────────────────────────────────────────────────
//  Unchecked errors
// ─────────────────────────────────────────────────────────────────────────

func checkUncheckedErrors(fset *token.FileSet, body *ast.BlockStmt) []issue {
	var issues []issue
	ast.Inspect(body, func(n ast.Node) bool {
		switch node := n.(type) {
		case *ast.ExprStmt:
			call, ok := node.X.(*ast.CallExpr)
			if !ok {
				return true
			}
			sel, ok := call.Fun.(*ast.SelectorExpr)
			if !ok {
				return true
			}
			if errorReturningMethods[sel.Sel.Name] {
				issues = append(issues, issue{
					Type: "unchecked_error",
					Line: fset.Position(node.Pos()).Line,
					Name: sel.Sel.Name,
					Message: fmt.Sprintf(
						"Return value of '%s()' is ignored — this method can return an error",
						sel.Sel.Name,
					),
				})
			}
		case *ast.AssignStmt:
			if len(node.Lhs) < 2 {
				return true
			}
			last, ok := node.Lhs[len(node.Lhs)-1].(*ast.Ident)
			if !ok || last.Name != "_" {
				return true
			}
			issues = append(issues, issue{
				Type: "unchecked_error",
				Line: fset.Position(node.Pos()).Line,
				Message: "Last return value (conventionally the error) is discarded via '_' " +
					"— verify this call cannot fail silently",
			})
		}
		return true
	})
	return issues
}

// ─────────────────────────────────────────────────────────────────────────
//  Goroutine leaks
// ─────────────────────────────────────────────────────────────────────────

func loopCanExit(loop *ast.ForStmt) bool {
	canExit := false
	ast.Inspect(loop.Body, func(n ast.Node) bool {
		switch node := n.(type) {
		case *ast.FuncLit:
			return false // exits inside a nested closure don't exit THIS loop
		case *ast.SelectStmt:
			canExit = true
			return false
		case *ast.BranchStmt:
			if node.Tok == token.BREAK {
				canExit = true
				return false
			}
		case *ast.ReturnStmt:
			canExit = true
			return false
		}
		return true
	})
	return canExit
}

func checkGoroutineLeaks(fset *token.FileSet, body *ast.BlockStmt) []issue {
	var issues []issue
	ast.Inspect(body, func(n ast.Node) bool {
		goStmt, ok := n.(*ast.GoStmt)
		if !ok {
			return true
		}
		lit, ok := goStmt.Call.Fun.(*ast.FuncLit)
		if !ok {
			return true // `go someFunc()` — cannot inspect an external function's body here
		}
		ast.Inspect(lit.Body, func(inner ast.Node) bool {
			forStmt, ok := inner.(*ast.ForStmt)
			if !ok || forStmt.Cond != nil {
				return true // only unconditional `for { ... }` loops are candidates
			}
			if !loopCanExit(forStmt) {
				issues = append(issues, issue{
					Type: "goroutine_leak",
					Line: fset.Position(goStmt.Pos()).Line,
					Message: "Goroutine runs an unconditional 'for {}' loop with no select/break/return " +
						"— it will never terminate and leaks for the life of the program",
				})
				return false
			}
			return true
		})
		return true
	})
	return issues
}

// ─────────────────────────────────────────────────────────────────────────
//  Defer inside a loop
// ─────────────────────────────────────────────────────────────────────────

func checkDeferInLoop(fset *token.FileSet, body *ast.BlockStmt) []issue {
	var issues []issue

	var inspectLoopBody func(n ast.Node)
	inspectLoopBody = func(n ast.Node) {
		ast.Inspect(n, func(inner ast.Node) bool {
			switch node := inner.(type) {
			case *ast.FuncLit:
				return false // defer inside a nested closure runs at the closure's return, not the loop's — not a leak
			case *ast.DeferStmt:
				issues = append(issues, issue{
					Type: "defer_in_loop",
					Line: fset.Position(node.Pos()).Line,
					Message: "'defer' inside a loop body accumulates until the enclosing function returns " +
						"instead of running per-iteration — wrap the loop body in a closure or call the " +
						"deferred cleanup directly",
				})
			}
			return true
		})
	}

	ast.Inspect(body, func(n ast.Node) bool {
		switch node := n.(type) {
		case *ast.ForStmt:
			inspectLoopBody(node.Body)
			return false
		case *ast.RangeStmt:
			inspectLoopBody(node.Body)
			return false
		}
		return true
	})
	return issues
}
