package main

import (
	"bytes"
	"errors"
	"fmt"
	"go/ast"
	"go/format"
	"go/parser"
	"go/token"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"unicode"

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

type bindRule struct {
	Scope  string
	Action string
	Intent string
	Set    map[string]string
}

type intentTypeMeta struct {
	Fields map[string]string
}

type enumValueMeta struct {
	ConstName string
	Literal   string
}

type parsedRule struct {
	Rule bindRule
	Errs []error
}

type parsedFile struct {
	path string
	file *ast.File
}

var (
	scopeSegmentPattern = regexp.MustCompile(`^[a-z0-9_]+$`)
	actionPattern       = regexp.MustCompile(`^[a-z0-9_]+$`)
)

func main() {
	repoRoot, err := findRepoRoot()
	if err != nil {
		fmt.Fprintf(os.Stderr, "find repo root: %v\n", err)
		os.Exit(1)
	}

	intents, err := collectIntentTypeMeta(filepath.Join(repoRoot, "internal/ui/intents"))
	if err != nil {
		fmt.Fprintf(os.Stderr, "collect intent metadata: %v\n", err)
		os.Exit(1)
	}
	enums, err := collectEnumTypeMeta(filepath.Join(repoRoot, "internal/ui/intents"))
	if err != nil {
		fmt.Fprintf(os.Stderr, "collect enum metadata: %v\n", err)
		os.Exit(1)
	}

	rules, err := collectBindRules(filepath.Join(repoRoot, "internal/ui/intents"))
	if err != nil {
		fmt.Fprintf(os.Stderr, "collect bind rules: %v\n", err)
		os.Exit(1)
	}

	if err := validateRules(rules, intents, enums); err != nil {
		fmt.Fprintf(os.Stderr, "validate bind rules: %v\n", err)
		os.Exit(1)
	}
	actionIDs := deriveActionIDs(rules)

	actionArgSchemas, actionRequiredArgs, err := deriveActionArgSchemas(rules, intents, enums)
	if err != nil {
		fmt.Fprintf(os.Stderr, "derive action arg schemas: %v\n", err)
		os.Exit(1)
	}
	actionScopes := deriveActionScopes(rules)
	if err := validateActionMetadata(actionIDs, actionScopes); err != nil {
		fmt.Fprintf(os.Stderr, "validate action metadata: %v\n", err)
		os.Exit(1)
	}

	src, err := generateCatalogSource(rules, intents, enums)
	if err != nil {
		fmt.Fprintf(os.Stderr, "generate catalog: %v\n", err)
		os.Exit(1)
	}
	if err := os.WriteFile(filepath.Join(repoRoot, "internal/ui/actions/catalog_gen.go"), src, 0o644); err != nil {
		fmt.Fprintf(os.Stderr, "write generated file: %v\n", err)
		os.Exit(1)
	}

	meta, err := generateActionMetaSource(actionArgSchemas, actionRequiredArgs, actionScopes)
	if err != nil {
		fmt.Fprintf(os.Stderr, "generate action meta: %v\n", err)
		os.Exit(1)
	}
	actionMetaDir := filepath.Join(repoRoot, "internal/ui/actionmeta")
	if err := os.MkdirAll(actionMetaDir, 0o755); err != nil {
		fmt.Fprintf(os.Stderr, "mkdir actionmeta: %v\n", err)
		os.Exit(1)
	}
	if err := os.WriteFile(filepath.Join(actionMetaDir, "builtins_gen.go"), meta, 0o644); err != nil {
		fmt.Fprintf(os.Stderr, "write action meta file: %v\n", err)
		os.Exit(1)
	}

	luaTypes, err := generateLuaTypesSource(actionIDs, actionArgSchemas, actionRequiredArgs)
	if err != nil {
		fmt.Fprintf(os.Stderr, "generate lua types: %v\n", err)
		os.Exit(1)
	}
	if err := os.WriteFile(filepath.Join(repoRoot, "internal/config/default/types.lua"), luaTypes, 0o644); err != nil {
		fmt.Fprintf(os.Stderr, "write types.lua: %v\n", err)
		os.Exit(1)
	}
}

func findRepoRoot() (string, error) {
	dir, err := os.Getwd()
	if err != nil {
		return "", err
	}
	for {
		if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
			return dir, nil
		}
		parent := filepath.Dir(dir)
		if parent == dir {
			return "", errors.New("go.mod not found in current directory or parents")
		}
		dir = parent
	}
}

func generateCatalogSource(rules []bindRule, intents map[string]intentTypeMeta, enums map[string][]enumValueMeta) ([]byte, error) {
	scopes := uniqueScopes(rules)
	scopeRules := rulesByScope(rules)
	useActionArgs := rulesUseActionArgs(rules)

	var b bytes.Buffer
	b.WriteString("// Code generated by cmd/genactions; DO NOT EDIT.\n")
	b.WriteString("package actions\n\n")
	b.WriteString("import (\n")
	b.WriteString("\tkeybindings \"github.com/idursun/jjui/internal/ui/bindings\"\n")
	b.WriteString("\t\"github.com/idursun/jjui/internal/ui/intents\"\n")
	if useActionArgs {
		b.WriteString("\t\"github.com/idursun/jjui/internal/ui/actionargs\"\n")
	}
	b.WriteString(")\n\n")

	b.WriteString("const (\n")
	for _, scope := range scopes {
		b.WriteString(fmt.Sprintf("\t%s = %q\n", scopeConst(scope), scope))
	}
	b.WriteString(")\n\n")

	b.WriteString("func ResolveIntent(scope string, action keybindings.Action, args map[string]any) (intents.Intent, bool) {\n")
	b.WriteString("\tswitch scope {\n")
	for _, scope := range scopes {
		b.WriteString(fmt.Sprintf("\tcase %s:\n", scopeConst(scope)))
		b.WriteString("\t\tswitch action {\n")
		for _, r := range scopeRules[scope] {
			b.WriteString(fmt.Sprintf("\t\tcase keybindings.Action(%q):\n\t\t\treturn %s, true\n", canonicalActionID(scope, r.Action), buildIntentExpr(r, intents, enums)))
		}
		b.WriteString("\t\t}\n")
	}
	b.WriteString("\t}\n")
	b.WriteString("\treturn nil, false\n")
	b.WriteString("}\n\n")

	for _, enumType := range sortedEnumTypesUsedByRules(rules, intents, enums) {
		b.WriteString(fmt.Sprintf("func enumArg%s(args map[string]any, name string) intents.%s {\n", toCamel(enumType), enumType))
		b.WriteString(fmt.Sprintf("\tvar zero intents.%s\n", enumType))
		b.WriteString("\tif args == nil {\n\t\treturn zero\n\t}\n")
		b.WriteString("\tv, ok := args[name]\n\tif !ok {\n\t\treturn zero\n\t}\n")
		b.WriteString("\ts, ok := v.(string)\n\tif !ok {\n\t\treturn zero\n\t}\n")
		b.WriteString("\tswitch s {\n")
		for _, val := range enums[enumType] {
			b.WriteString(fmt.Sprintf("\tcase %q:\n\t\treturn intents.%s\n", val.Literal, val.ConstName))
		}
		b.WriteString("\tdefault:\n\t\treturn zero\n\t}\n")
		b.WriteString("}\n\n")
	}

	src, err := format.Source(b.Bytes())
	if err != nil {
		return nil, fmt.Errorf("format generated source: %w\n%s", err, b.String())
	}
	return src, nil
}

func generateActionMetaSource(actionArgSchemas map[string]map[string]string, actionRequiredArgs map[string][]string, actionScopes map[string][]string) ([]byte, error) {
	var b bytes.Buffer
	b.WriteString("// Code generated by cmd/genactions; DO NOT EDIT.\n")
	b.WriteString("package actionmeta\n\n")
	b.WriteString("import (\n")
	b.WriteString("\t\"fmt\"\n")
	b.WriteString("\t\"sort\"\n")
	b.WriteString("\t\"strings\"\n")
	b.WriteString(")\n\n")

	b.WriteString("var builtInActionScopes = map[string][]string{\n")
	scopeActionNames := make([]string, 0, len(actionScopes))
	for action := range actionScopes {
		scopeActionNames = append(scopeActionNames, action)
	}
	sort.Strings(scopeActionNames)
	for _, action := range scopeActionNames {
		b.WriteString(fmt.Sprintf("\t%q: {", action))
		for i, scope := range actionScopes[action] {
			if i > 0 {
				b.WriteString(", ")
			}
			b.WriteString(fmt.Sprintf("%q", scope))
		}
		b.WriteString("},\n")
	}
	b.WriteString("}\n\n")

	b.WriteString("var builtInActionArgSchemas = map[string]map[string]string{\n")
	actionNames := make([]string, 0, len(actionArgSchemas))
	for action := range actionArgSchemas {
		actionNames = append(actionNames, action)
	}
	sort.Strings(actionNames)
	for _, action := range actionNames {
		schema := actionArgSchemas[action]
		b.WriteString(fmt.Sprintf("\t%q: {\n", action))
		argNames := make([]string, 0, len(schema))
		for arg := range schema {
			argNames = append(argNames, arg)
		}
		sort.Strings(argNames)
		for _, arg := range argNames {
			b.WriteString(fmt.Sprintf("\t\t%q: %q,\n", arg, schema[arg]))
		}
		b.WriteString("\t},\n")
	}
	b.WriteString("}\n\n")

	b.WriteString("var builtInActionRequiredArgs = map[string][]string{\n")
	requiredActionNames := make([]string, 0, len(actionRequiredArgs))
	for action := range actionRequiredArgs {
		requiredActionNames = append(requiredActionNames, action)
	}
	sort.Strings(requiredActionNames)
	for _, action := range requiredActionNames {
		b.WriteString(fmt.Sprintf("\t%q: {", action))
		for i, arg := range actionRequiredArgs[action] {
			if i > 0 {
				b.WriteString(", ")
			}
			b.WriteString(fmt.Sprintf("%q", arg))
		}
		b.WriteString("},\n")
	}
	b.WriteString("}\n\n")

	b.WriteString("func ActionScopes(action string) []string {\n")
	b.WriteString("\taction = strings.TrimSpace(action)\n")
	b.WriteString("\tscopes, ok := builtInActionScopes[action]\n")
	b.WriteString("\tif !ok {\n\t\treturn nil\n\t}\n")
	b.WriteString("\treturn append([]string(nil), scopes...)\n")
	b.WriteString("}\n\n")

	b.WriteString("func ActionArgSchema(action string) map[string]string {\n")
	b.WriteString("\treturn builtInActionArgSchemas[action]\n")
	b.WriteString("}\n\n")

	b.WriteString("func ActionRequiredArgs(action string) []string {\n")
	b.WriteString("\treturn builtInActionRequiredArgs[action]\n")
	b.WriteString("}\n\n")

	b.WriteString("func BuiltInActions() []string {\n")
	b.WriteString("\tout := make([]string, 0, len(builtInActionScopes))\n")
	b.WriteString("\tfor action := range builtInActionScopes {\n")
	b.WriteString("\t\tout = append(out, action)\n")
	b.WriteString("\t}\n")
	b.WriteString("\tsort.Strings(out)\n")
	b.WriteString("\treturn out\n")
	b.WriteString("}\n\n")

	b.WriteString("func IsBuiltInAction(action string) bool {\n")
	b.WriteString("\taction = strings.TrimSpace(action)\n")
	b.WriteString("\t_, ok := builtInActionScopes[action]\n")
	b.WriteString("\treturn ok\n")
	b.WriteString("}\n\n")

	b.WriteString("func ValidateBuiltInActionArgs(action string, args map[string]any) error {\n")
	b.WriteString("\taction = strings.TrimSpace(action)\n")
	b.WriteString("\tif !IsBuiltInAction(action) {\n")
	b.WriteString("\t\treturn fmt.Errorf(\"unknown built-in action %q\", action)\n")
	b.WriteString("\t}\n")
	b.WriteString("\tschema := builtInActionArgSchemas[action]\n")
	b.WriteString("\tfor _, required := range builtInActionRequiredArgs[action] {\n")
	b.WriteString("\t\tif _, ok := args[required]; !ok {\n")
	b.WriteString("\t\t\treturn fmt.Errorf(\"action %q requires arg %q\", action, required)\n")
	b.WriteString("\t\t}\n")
	b.WriteString("\t}\n")
	b.WriteString("\tfor key, value := range args {\n")
	b.WriteString("\t\texpectedType, ok := schema[key]\n")
	b.WriteString("\t\tif !ok {\n")
	b.WriteString("\t\t\treturn fmt.Errorf(\"action %q does not accept arg %q\", action, key)\n")
	b.WriteString("\t\t}\n")
	b.WriteString("\t\tswitch expectedType {\n")
	b.WriteString("\t\tcase \"bool\":\n")
	b.WriteString("\t\t\tif _, ok := value.(bool); !ok {\n")
	b.WriteString("\t\t\t\treturn fmt.Errorf(\"action %q arg %q expects bool\", action, key)\n")
	b.WriteString("\t\t\t}\n")
	b.WriteString("\t\tcase \"string\":\n")
	b.WriteString("\t\t\tif _, ok := value.(string); !ok {\n")
	b.WriteString("\t\t\t\treturn fmt.Errorf(\"action %q arg %q expects string\", action, key)\n")
	b.WriteString("\t\t\t}\n")
	b.WriteString("\t\tdefault:\n")
	b.WriteString("\t\t\tif strings.HasPrefix(expectedType, \"enum:\") {\n")
	b.WriteString("\t\t\t\ts, ok := value.(string)\n")
	b.WriteString("\t\t\t\tif !ok {\n")
	b.WriteString("\t\t\t\t\treturn fmt.Errorf(\"action %q arg %q expects string enum\", action, key)\n")
	b.WriteString("\t\t\t\t}\n")
	b.WriteString("\t\t\t\tallowed := strings.Split(strings.TrimPrefix(expectedType, \"enum:\"), \"|\")\n")
	b.WriteString("\t\t\t\tvalid := false\n")
	b.WriteString("\t\t\t\tfor _, item := range allowed {\n")
	b.WriteString("\t\t\t\t\tif s == item {\n")
	b.WriteString("\t\t\t\t\t\tvalid = true\n")
	b.WriteString("\t\t\t\t\t\tbreak\n")
	b.WriteString("\t\t\t\t\t}\n")
	b.WriteString("\t\t\t\t}\n")
	b.WriteString("\t\t\t\tif !valid {\n")
	b.WriteString("\t\t\t\t\treturn fmt.Errorf(\"action %q arg %q invalid value %q (accepted: %s)\", action, key, s, strings.Join(allowed, \", \"))\n")
	b.WriteString("\t\t\t\t}\n")
	b.WriteString("\t\t\t\tcontinue\n")
	b.WriteString("\t\t\t}\n")
	b.WriteString("\t\t\treturn fmt.Errorf(\"action %q arg %q has unsupported expected type %q\", action, key, expectedType)\n")
	b.WriteString("\t\t}\n")
	b.WriteString("\t}\n")
	b.WriteString("\treturn nil\n")
	b.WriteString("}\n")

	src, err := format.Source(b.Bytes())
	if err != nil {
		return nil, fmt.Errorf("format generated actionmeta source: %w\n%s", err, b.String())
	}
	return src, nil
}

func loadIntentsFiles(dir string) ([]parsedFile, error) {
	cfg := &packages.Config{
		Dir:  dir,
		Mode: packages.NeedName | packages.NeedSyntax | packages.NeedCompiledGoFiles,
		ParseFile: func(fset *token.FileSet, filename string, src []byte) (*ast.File, error) {
			return parser.ParseFile(fset, filename, src, parser.ParseComments)
		},
	}

	pkgs, err := packages.Load(cfg, ".")
	if err != nil {
		return nil, err
	}
	if len(pkgs) != 1 {
		return nil, fmt.Errorf("expected 1 package in %s, got %d", dir, len(pkgs))
	}

	pkg := pkgs[0]
	if len(pkg.Errors) > 0 {
		errs := make([]string, 0, len(pkg.Errors))
		for _, err := range pkg.Errors {
			errs = append(errs, err.Msg)
		}
		return nil, fmt.Errorf("load %s: %s", dir, strings.Join(errs, "; "))
	}
	if pkg.Name != "intents" {
		return nil, fmt.Errorf("intents package not found in %s", dir)
	}

	files := make([]parsedFile, 0, len(pkg.Syntax))
	for i, file := range pkg.Syntax {
		files = append(files, parsedFile{
			path: pkg.CompiledGoFiles[i],
			file: file,
		})
	}

	sort.Slice(files, func(i, j int) bool {
		return files[i].path < files[j].path
	})

	return files, nil
}

func collectBindRules(dir string) ([]bindRule, error) {
	files, err := loadIntentsFiles(dir)
	if err != nil {
		return nil, err
	}

	var out []bindRule
	seen := map[string]struct{}{}
	var errs []string

	for _, pf := range files {
		for _, decl := range pf.file.Decls {
			gen, ok := decl.(*ast.GenDecl)
			if !ok || gen.Tok != token.TYPE {
				continue
			}
			for _, spec := range gen.Specs {
				ts, ok := spec.(*ast.TypeSpec)
				if !ok {
					continue
				}
				doc := ts.Doc
				if doc == nil {
					doc = gen.Doc
				}
				if doc == nil {
					continue
				}
				parsed := parseBindDirectives(doc, ts.Name.Name)
				for _, pr := range parsed {
					for _, e := range pr.Errs {
						errs = append(errs, fmt.Sprintf("%s: %v", filepath.Base(pf.path), e))
					}
					if pr.Rule.Scope == "" || pr.Rule.Action == "" {
						continue
					}
					key := pr.Rule.Scope + "|" + pr.Rule.Action
					if _, ok := seen[key]; ok {
						errs = append(errs, fmt.Sprintf("%s: duplicate binding for scope=%q action=%q", filepath.Base(pf.path), pr.Rule.Scope, pr.Rule.Action))
						continue
					}
					seen[key] = struct{}{}
					out = append(out, pr.Rule)
				}
			}
		}
	}

	if len(errs) > 0 {
		return nil, errors.New(strings.Join(errs, "; "))
	}

	sort.Slice(out, func(i, j int) bool {
		if out[i].Scope != out[j].Scope {
			return out[i].Scope < out[j].Scope
		}
		if out[i].Action != out[j].Action {
			return out[i].Action < out[j].Action
		}
		return out[i].Intent < out[j].Intent
	})
	return out, nil
}

func parseBindDirectives(doc *ast.CommentGroup, intentType string) []parsedRule {
	var out []parsedRule
	for _, c := range doc.List {
		line := strings.TrimSpace(strings.TrimPrefix(c.Text, "//"))
		if !strings.HasPrefix(line, "jjui:bind") {
			continue
		}
		rest := strings.TrimSpace(strings.TrimPrefix(line, "jjui:bind"))
		parts := strings.Fields(rest)
		rule := bindRule{Intent: intentType, Set: map[string]string{}}
		var errs []error

		for _, part := range parts {
			kv := strings.SplitN(part, "=", 2)
			if len(kv) != 2 {
				errs = append(errs, fmt.Errorf("invalid token %q in directive %q", part, line))
				continue
			}
			k := strings.TrimSpace(kv[0])
			v := strings.TrimSpace(kv[1])
			switch k {
			case "scope":
				rule.Scope = v
			case "action":
				rule.Action = v
			case "set":
				for assign := range strings.SplitSeq(v, ",") {
					fv := strings.SplitN(assign, ":", 2)
					if len(fv) != 2 {
						errs = append(errs, fmt.Errorf("invalid set assignment %q in directive %q", assign, line))
						continue
					}
					field := strings.TrimSpace(fv[0])
					value := strings.TrimSpace(fv[1])
					if field == "" || value == "" {
						errs = append(errs, fmt.Errorf("invalid set assignment %q in directive %q", assign, line))
						continue
					}
					if _, exists := rule.Set[field]; exists {
						errs = append(errs, fmt.Errorf("duplicate set field %q in directive %q", field, line))
						continue
					}
					rule.Set[field] = value
				}
			default:
				errs = append(errs, fmt.Errorf("unknown directive key %q in %q", k, line))
			}
		}

		if rule.Scope == "" {
			errs = append(errs, fmt.Errorf("missing scope in directive %q", line))
		}
		if rule.Action == "" {
			errs = append(errs, fmt.Errorf("missing action in directive %q", line))
		}

		out = append(out, parsedRule{Rule: rule, Errs: errs})
	}
	return out
}

func collectIntentTypeMeta(dir string) (map[string]intentTypeMeta, error) {
	files, err := loadIntentsFiles(dir)
	if err != nil {
		return nil, err
	}

	meta := map[string]intentTypeMeta{}
	for _, pf := range files {
		for _, decl := range pf.file.Decls {
			gen, ok := decl.(*ast.GenDecl)
			if !ok || gen.Tok != token.TYPE {
				continue
			}
			for _, spec := range gen.Specs {
				ts, ok := spec.(*ast.TypeSpec)
				if !ok {
					continue
				}
				fields := map[string]string{}
				if st, ok := ts.Type.(*ast.StructType); ok {
					for _, f := range st.Fields.List {
						ft := exprTypeName(f.Type)
						for _, n := range f.Names {
							fields[n.Name] = ft
						}
					}
				}
				meta[ts.Name.Name] = intentTypeMeta{Fields: fields}
			}
		}
	}
	return meta, nil
}

func collectEnumTypeMeta(dir string) (map[string][]enumValueMeta, error) {
	files, err := loadIntentsFiles(dir)
	if err != nil {
		return nil, err
	}

	out := map[string][]enumValueMeta{}
	for _, pf := range files {
		for _, decl := range pf.file.Decls {
			gen, ok := decl.(*ast.GenDecl)
			if !ok || gen.Tok != token.CONST {
				continue
			}
			var currentType string
			for _, spec := range gen.Specs {
				vs, ok := spec.(*ast.ValueSpec)
				if !ok || len(vs.Names) == 0 {
					continue
				}
				if vs.Type != nil {
					if ident, ok := vs.Type.(*ast.Ident); ok {
						currentType = ident.Name
					} else {
						currentType = ""
					}
				}
				if currentType == "" {
					continue
				}
				for _, name := range vs.Names {
					out[currentType] = append(out[currentType], enumValueMeta{
						ConstName: name.Name,
						Literal:   enumLiteral(currentType, name.Name),
					})
				}
			}
		}
	}
	return out, nil
}

func deriveActionIDs(rules []bindRule) []string {
	seen := map[string]struct{}{}
	actions := make([]string, 0, len(rules))
	for _, rule := range rules {
		fullID := canonicalActionID(rule.Scope, rule.Action)
		if _, ok := seen[fullID]; ok {
			continue
		}
		seen[fullID] = struct{}{}
		actions = append(actions, fullID)
	}
	sort.Strings(actions)
	return actions
}

func deriveActionArgSchemas(rules []bindRule, intents map[string]intentTypeMeta, enums map[string][]enumValueMeta) (map[string]map[string]string, map[string][]string, error) {
	out := map[string]map[string]string{}
	required := map[string]map[string]struct{}{}
	for _, rule := range rules {
		intent, ok := intents[rule.Intent]
		if !ok {
			continue
		}
		for field, value := range rule.Set {
			fieldType, ok := intent.Fields[field]
			if !ok {
				continue
			}
			argName, argType, argRequired, ok := parseArgRef(fieldType, value, enums)
			if !ok {
				continue
			}
			fullID := canonicalActionID(rule.Scope, rule.Action)
			if out[fullID] == nil {
				out[fullID] = map[string]string{}
			}
			if existing, exists := out[fullID][argName]; exists && existing != argType {
				merged, ok := mergeArgTypes(existing, argType)
				if !ok {
					return nil, nil, fmt.Errorf("action %q arg %q has conflicting types %q and %q", fullID, argName, existing, argType)
				}
				out[fullID][argName] = merged
				continue
			}
			out[fullID][argName] = argType
			if argRequired {
				if required[fullID] == nil {
					required[fullID] = map[string]struct{}{}
				}
				required[fullID][argName] = struct{}{}
			}
		}
	}

	requiredOut := map[string][]string{}
	for action, requiredSet := range required {
		args := make([]string, 0, len(requiredSet))
		for arg := range requiredSet {
			args = append(args, arg)
		}
		sort.Strings(args)
		requiredOut[action] = args
	}
	return out, requiredOut, nil
}

func mergeArgTypes(a, b string) (string, bool) {
	if a == b {
		return a, true
	}
	if !strings.HasPrefix(a, "enum:") || !strings.HasPrefix(b, "enum:") {
		return "", false
	}
	seen := map[string]struct{}{}
	var merged []string
	for _, item := range append(strings.Split(strings.TrimPrefix(a, "enum:"), "|"), strings.Split(strings.TrimPrefix(b, "enum:"), "|")...) {
		if item == "" {
			continue
		}
		if _, ok := seen[item]; ok {
			continue
		}
		seen[item] = struct{}{}
		merged = append(merged, item)
	}
	return "enum:" + strings.Join(merged, "|"), true
}

func deriveActionScopes(rules []bindRule) map[string][]string {
	scopes := map[string]map[string]struct{}{}
	for _, rule := range rules {
		fullID := canonicalActionID(rule.Scope, rule.Action)
		if scopes[fullID] == nil {
			scopes[fullID] = map[string]struct{}{}
		}
		scopes[fullID][rule.Scope] = struct{}{}
	}

	out := map[string][]string{}
	for action, scopeSet := range scopes {
		list := make([]string, 0, len(scopeSet))
		for scope := range scopeSet {
			list = append(list, scope)
		}
		sort.Strings(list)
		out[action] = list
	}
	return out
}

func validateRules(rules []bindRule, intents map[string]intentTypeMeta, enums map[string][]enumValueMeta) error {
	var errs []string
	seenFullIDs := map[string]struct{}{}
	for _, rule := range rules {
		if err := validateScopeFormat(rule.Scope); err != nil {
			errs = append(errs, fmt.Sprintf("invalid scope %q: %v", rule.Scope, err))
		}
		if err := validateActionFormat(rule.Action); err != nil {
			errs = append(errs, fmt.Sprintf("invalid action %q: %v", rule.Action, err))
		}
		fullID := canonicalActionID(rule.Scope, rule.Action)
		if _, ok := seenFullIDs[fullID]; ok {
			errs = append(errs, fmt.Sprintf("duplicate full action id %q", fullID))
		} else {
			seenFullIDs[fullID] = struct{}{}
		}
		intent, ok := intents[rule.Intent]
		if !ok {
			errs = append(errs, fmt.Sprintf("unknown intent type %q for scope=%q action=%q", rule.Intent, rule.Scope, rule.Action))
			continue
		}
		for field, value := range rule.Set {
			fieldType, ok := intent.Fields[field]
			if !ok {
				errs = append(errs, fmt.Sprintf("unknown field %q on intent %q (scope=%q action=%q)", field, rule.Intent, rule.Scope, rule.Action))
				continue
			}
			if err := validateSetValueType(fieldType, value, enums); err != nil {
				errs = append(errs, fmt.Sprintf("invalid set value for %s.%s (%s): %v", rule.Intent, field, rule.Action, err))
			}
		}
	}
	if len(errs) > 0 {
		return errors.New(strings.Join(errs, "; "))
	}
	return nil
}

func validateScopeFormat(scope string) error {
	if scope == "" {
		return errors.New("scope is empty")
	}
	segments := strings.SplitSeq(scope, ".")
	for segment := range segments {
		if segment == "" {
			return errors.New("scope has empty segment")
		}
		if !scopeSegmentPattern.MatchString(segment) {
			return fmt.Errorf("scope segment %q must match [a-z0-9_]+", segment)
		}
	}
	return nil
}

func validateActionFormat(action string) error {
	if action == "" {
		return errors.New("action is empty")
	}
	if strings.Contains(action, ".") {
		return errors.New("action cannot contain dots")
	}
	if !actionPattern.MatchString(action) {
		return errors.New("action must match [a-z0-9_]+")
	}
	return nil
}

func canonicalActionID(scope, action string) string {
	return scope + "." + action
}

func validateActionMetadata(actionIDs []string, actionScopes map[string][]string) error {
	var errs []string
	for _, action := range actionIDs {
		if len(actionScopes[action]) == 0 {
			errs = append(errs, fmt.Sprintf("action %q has no scopes", action))
		}
	}
	if len(errs) > 0 {
		return errors.New(strings.Join(errs, "; "))
	}
	return nil
}

func validateSetValueType(fieldType, value string, enums map[string][]enumValueMeta) error {
	switch fieldType {
	case "bool":
		if value == "true" || value == "false" || isBoolArg(value) {
			return nil
		}
		return fmt.Errorf("expected bool or $bool(...), got %q", value)
	case "int":
		if _, err := strconv.Atoi(value); err == nil {
			return nil
		}
		return fmt.Errorf("expected int, got %q", value)
	case "string":
		if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") {
			return nil
		}
		if isStringArg(value) || isOptionalStringArg(value) {
			return nil
		}
		return fmt.Errorf("expected quoted string, $string(...), or $string?(...), got %q", value)
	default:
		if argName, ok := parseEnumArg(value); ok {
			if len(enums[fieldType]) == 0 {
				return fmt.Errorf("expected identifier for %q, got %q", fieldType, value)
			}
			if strings.TrimSpace(argName) == "" {
				return fmt.Errorf("invalid enum arg %q", value)
			}
			return nil
		}
		if isIdentifier(value) {
			return nil
		}
		return fmt.Errorf("expected identifier for %q, got %q", fieldType, value)
	}
}

func exprTypeName(expr ast.Expr) string {
	switch t := expr.(type) {
	case *ast.Ident:
		return t.Name
	case *ast.SelectorExpr:
		if pkg, ok := t.X.(*ast.Ident); ok {
			return pkg.Name + "." + t.Sel.Name
		}
		return t.Sel.Name
	default:
		return fmt.Sprintf("%T", expr)
	}
}

func isBoolArg(value string) bool {
	return strings.HasPrefix(value, "$bool(") && strings.HasSuffix(value, ")")
}

func isStringArg(value string) bool {
	return strings.HasPrefix(value, "$string(") && strings.HasSuffix(value, ")")
}

func isOptionalStringArg(value string) bool {
	return strings.HasPrefix(value, "$string?(") && strings.HasSuffix(value, ")")
}

func parseArgRef(fieldType, value string, enums map[string][]enumValueMeta) (name string, typ string, required bool, ok bool) {
	if isBoolArg(value) {
		name := strings.TrimSuffix(strings.TrimPrefix(value, "$bool("), ")")
		if name == "" {
			return "", "", false, false
		}
		return name, "bool", false, true
	}
	if argName, ok := parseEnumArg(value); ok {
		if len(enums[fieldType]) == 0 || argName == "" {
			return "", "", false, false
		}
		return argName, enumSchemaForType(fieldType, enums), true, true
	}
	if isOptionalStringArg(value) {
		name := strings.TrimSuffix(strings.TrimPrefix(value, "$string?("), ")")
		if name == "" {
			return "", "", false, false
		}
		return name, "string", false, true
	}
	if isStringArg(value) {
		name := strings.TrimSuffix(strings.TrimPrefix(value, "$string("), ")")
		if name == "" {
			return "", "", false, false
		}
		return name, "string", true, true
	}
	return "", "", false, false
}

func parseEnumArg(value string) (string, bool) {
	if strings.HasPrefix(value, "$enum(") && strings.HasSuffix(value, ")") {
		name := strings.TrimSuffix(strings.TrimPrefix(value, "$enum("), ")")
		return strings.TrimSpace(name), true
	}
	return "", false
}

func enumSchemaForType(typeName string, enums map[string][]enumValueMeta) string {
	values := enums[typeName]
	literals := make([]string, 0, len(values))
	for _, value := range values {
		literals = append(literals, value.Literal)
	}
	return "enum:" + strings.Join(literals, "|")
}

func enumLiteral(typeName, constName string) string {
	suffix := strings.TrimPrefix(constName, typeName)
	if suffix == "" {
		suffix = constName
	}
	switch suffix {
	case "Destination":
		return "onto"
	case "Descendants":
		return "source"
	}
	var out []rune
	for i, r := range suffix {
		if unicode.IsUpper(r) && i > 0 {
			out = append(out, '_')
		}
		out = append(out, unicode.ToLower(r))
	}
	return string(out)
}

func sortedEnumTypesUsedByRules(rules []bindRule, intents map[string]intentTypeMeta, enums map[string][]enumValueMeta) []string {
	seen := map[string]struct{}{}
	var out []string
	for _, rule := range rules {
		intent, ok := intents[rule.Intent]
		if !ok {
			continue
		}
		for field, value := range rule.Set {
			if _, ok := parseEnumArg(value); !ok {
				continue
			}
			fieldType := intent.Fields[field]
			if len(enums[fieldType]) == 0 {
				continue
			}
			if _, ok := seen[fieldType]; ok {
				continue
			}
			seen[fieldType] = struct{}{}
			out = append(out, fieldType)
		}
	}
	sort.Strings(out)
	return out
}

func rulesByScope(rules []bindRule) map[string][]bindRule {
	byScope := map[string][]bindRule{}
	for _, r := range rules {
		byScope[r.Scope] = append(byScope[r.Scope], r)
	}
	for scope := range byScope {
		sort.Slice(byScope[scope], func(i, j int) bool {
			return byScope[scope][i].Action < byScope[scope][j].Action
		})
	}
	return byScope
}

func buildIntentExpr(rule bindRule, intents map[string]intentTypeMeta, enums map[string][]enumValueMeta) string {
	if len(rule.Set) == 0 {
		return fmt.Sprintf("intents.%s{}", rule.Intent)
	}
	intent := intents[rule.Intent]
	fields := make([]string, 0, len(rule.Set))
	for field := range rule.Set {
		fields = append(fields, field)
	}
	sort.Strings(fields)
	parts := make([]string, 0, len(fields))
	for _, field := range fields {
		parts = append(parts, fmt.Sprintf("%s: %s", field, renderValue(intent.Fields[field], rule.Set[field], enums)))
	}
	return fmt.Sprintf("intents.%s{%s}", rule.Intent, strings.Join(parts, ", "))
}

func renderValue(fieldType, value string, enums map[string][]enumValueMeta) string {
	if strings.HasPrefix(value, "$bool(") && strings.HasSuffix(value, ")") {
		name := strings.TrimSuffix(strings.TrimPrefix(value, "$bool("), ")")
		return fmt.Sprintf("actionargs.BoolArg(args, %q, false)", name)
	}
	if isStringArg(value) {
		name := strings.TrimSuffix(strings.TrimPrefix(value, "$string("), ")")
		return fmt.Sprintf("actionargs.StringArg(args, %q, \"\")", name)
	}
	if isOptionalStringArg(value) {
		name := strings.TrimSuffix(strings.TrimPrefix(value, "$string?("), ")")
		return fmt.Sprintf("actionargs.StringArg(args, %q, \"\")", name)
	}
	if argName, ok := parseEnumArg(value); ok && len(enums[fieldType]) > 0 {
		return fmt.Sprintf("enumArg%s(args, %q)", toCamel(fieldType), argName)
	}
	if value == "true" || value == "false" {
		return value
	}
	if _, err := strconv.Atoi(value); err == nil {
		return value
	}
	if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") {
		return value
	}
	if isIdentifier(value) {
		return "intents." + value
	}
	panic("unsupported set value: " + value)
}

func rulesUseActionArgs(rules []bindRule) bool {
	for _, rule := range rules {
		for _, value := range rule.Set {
			if strings.HasPrefix(value, "$bool(") && strings.HasSuffix(value, ")") {
				return true
			}
			if isStringArg(value) || isOptionalStringArg(value) {
				return true
			}
		}
	}
	return false
}

func isIdentifier(s string) bool {
	if s == "" {
		return false
	}
	for i, r := range s {
		if i == 0 {
			if !(unicode.IsLetter(r) || r == '_') {
				return false
			}
			continue
		}
		if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_') {
			return false
		}
	}
	return true
}

func uniqueScopes(rules []bindRule) []string {
	seen := map[string]struct{}{}
	scopes := make([]string, 0, len(rules))
	for _, r := range rules {
		if _, ok := seen[r.Scope]; ok {
			continue
		}
		seen[r.Scope] = struct{}{}
		scopes = append(scopes, r.Scope)
	}
	sort.Strings(scopes)
	return scopes
}

func scopeConst(scope string) string {
	parts := strings.Split(scope, ".")
	if len(parts) > 1 && parts[0] == "revisions" {
		parts = parts[1:]
	}
	for i := range parts {
		parts[i] = toCamel(parts[i])
	}
	return "Scope" + strings.Join(parts, "")
}

func toCamel(s string) string {
	s = filepath.Base(s)
	parts := strings.Split(s, "_")
	for i := range parts {
		if parts[i] == "" {
			continue
		}
		parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
	}
	return strings.Join(parts, "")
}

// Hand-written Lua API stubs that cannot be derived from annotations.
const luaHandWrittenClasses = `---@class jjui.revisions
---@field current fun(): string? Get the current selected revision's change ID
---@field checked fun(): string[] Get array of checked revision change IDs
---@field refresh fun(options?: {keep_selections?: boolean, selected_revision?: string}) Refresh revisions
---@field navigate fun(options?: {target?: "parent"|"child"|"children"|"working"|"working_copy"|"work", by?: integer, page?: boolean, to?: string, fallback?: string, ensureView?: boolean, allowStream?: boolean}) Navigate revisions

---@class jjui.revset
---@field current fun(): string Get current revset string
---@field default fun(): string Get default revset string

---@class jjui.context
---@field change_id fun(): string? Get selected item's change ID
---@field commit_id fun(): string? Get selected item's commit ID
---@field file fun(): string? Get selected file path
---@field operation_id fun(): string? Get selected operation ID
---@field checked_files fun(): string[] Get array of checked files
---@field checked_change_ids fun(): string[] Get array of checked change IDs
---@field checked_commit_ids fun(): string[] Get array of checked commit IDs

`

type luaFunctionSpec struct {
	Comment     string
	ParamDocs   []string
	ReturnDocs  []string
	Declaration string
	FieldName   string
	FieldType   string
}

var luaHandWrittenFunctions = []luaFunctionSpec{
	{
		Comment:     "Run a jj command asynchronously (yields, resumes after completion)",
		ParamDocs:   []string{"---@param ... string|string[] Command arguments for jj (variadic strings or a single table)"},
		Declaration: "function jj_async(...) end",
		FieldName:   "jj_async",
		FieldType:   "fun(...: string|string[])",
	},
	{
		Comment:     "Run a jj command interactively (takes over the terminal)",
		ParamDocs:   []string{"---@param ... string|string[] Command arguments for jj (variadic strings or a single table)"},
		Declaration: "function jj_interactive(...) end",
		FieldName:   "jj_interactive",
		FieldType:   "fun(...: string|string[])",
	},
	{
		Comment:     "Run a jj command synchronously and return its output",
		ParamDocs:   []string{"---@param ... string|string[] Command arguments for jj (variadic strings or a single table)"},
		ReturnDocs:  []string{"---@return string? output The command output (nil on error)", "---@return string? error The error message (nil on success)"},
		Declaration: "function jj(...) end",
		FieldName:   "jj",
		FieldType:   "fun(...: string|string[]): string?, string?",
	},
	{
		Comment:     "Show a flash message in the status bar",
		ParamDocs:   []string{"---@param text_or_options string|{text: string, error?: boolean, sticky?: boolean} Message text or options table"},
		Declaration: "function flash(text_or_options) end",
		FieldName:   "flash",
		FieldType:   "fun(text_or_options: string|{text: string, error?: boolean, sticky?: boolean})",
	},
	{
		Comment:     "Change the active runtime theme",
		ParamDocs:   []string{"---@param name string Theme name"},
		Declaration: "function set_theme(name) end",
		FieldName:   "set_theme",
		FieldType:   "fun(name: string)",
	},
	{
		Comment:     "Copy text to the system clipboard",
		ParamDocs:   []string{"---@param text string Text to copy"},
		ReturnDocs:  []string{"---@return boolean? ok True on success", "---@return string? error The error message (nil on success)"},
		Declaration: "function copy_to_clipboard(text) end",
		FieldName:   "copy_to_clipboard",
		FieldType:   "fun(text: string): boolean?, string?",
	},
	{
		Comment:     "Execute a shell command",
		ParamDocs:   []string{"---@param command string Shell command to execute"},
		Declaration: "function exec_shell(command) end",
		FieldName:   "exec_shell",
		FieldType:   "fun(command: string)",
	},
	{
		Comment:     "Split text into an array of lines",
		ParamDocs:   []string{"---@param text string Text to split", "---@param keepEmpty? boolean Keep empty lines (default: false)"},
		ReturnDocs:  []string{"---@return string[] lines Array of lines"},
		Declaration: "function split_lines(text, keepEmpty) end",
		FieldName:   "split_lines",
		FieldType:   "fun(text: string, keepEmpty?: boolean): string[]",
	},
	{
		Comment:     "Show a choice dialog and wait for user selection (yields)",
		ParamDocs:   []string{"---@param ... string|string[]|{options?: string[]|string, title?: string, filter?: boolean, ordered?: boolean} Options as variadic strings, a single array, or an options table"},
		ReturnDocs:  []string{"---@return string|nil selected The selected option, or nil if cancelled"},
		Declaration: "function choose(...) end",
		FieldName:   "choose",
		FieldType:   "fun(...: string|string[]|{options?: string[]|string, title?: string, filter?: boolean, ordered?: boolean}): string|nil",
	},
	{
		Comment:     "Show a text input dialog and wait for user input (yields)",
		ParamDocs:   []string{"---@param options? {title?: string, prompt?: string} Input options"},
		ReturnDocs:  []string{"---@return string|nil value The entered text, or nil if cancelled"},
		Declaration: "function input(options) end",
		FieldName:   "input",
		FieldType:   "fun(options?: {title?: string, prompt?: string}): string|nil",
	},
	{
		Comment:     "Yield and wait for the current view to close",
		ReturnDocs:  []string{"---@return boolean applied True when the closed view was applied"},
		Declaration: "function wait_close() end",
		FieldName:   "wait_close",
		FieldType:   "fun(): boolean",
	},
	{
		Comment:     "Yield and wait for revisions to be updated",
		Declaration: "function wait_refresh() end",
		FieldName:   "wait_refresh",
		FieldType:   "fun()",
	},
}

func writeLuaHandWrittenFunctions(b *bytes.Buffer) {
	for _, fn := range luaHandWrittenFunctions {
		if fn.Comment != "" {
			b.WriteString("---" + fn.Comment + "\n")
		}
		for _, line := range fn.ParamDocs {
			b.WriteString(line + "\n")
		}
		for _, line := range fn.ReturnDocs {
			b.WriteString(line + "\n")
		}
		b.WriteString(fn.Declaration + "\n\n")
	}
}

func writeLuaHandWrittenRootFields(b *bytes.Buffer) {
	for _, fn := range luaHandWrittenFunctions {
		b.WriteString(fmt.Sprintf("---@field %s %s\n", fn.FieldName, fn.FieldType))
	}
}

type scopeNode struct {
	name     string
	fullName string
	children map[string]*scopeNode
	actions  []luaActionSpec
}

type luaActionSpec struct {
	Canonical               string
	Token                   string
	Schema                  map[string]string
	Required                []string
	RequiredSet             map[string]struct{}
	HasSingleRequiredString bool
}

func generateLuaTypesSource(actionIDs []string, argSchemas map[string]map[string]string, requiredArgs map[string][]string) ([]byte, error) {
	root := buildLuaScopeTree(actionIDs, argSchemas, requiredArgs)
	scopes := collectLuaScopes(root)
	topLevel := sortedScopeNames(root.children)

	var b bytes.Buffer
	b.WriteString("---@meta\n")
	b.WriteString("-- LuaLS type definitions for jjui Lua API\n")
	b.WriteString("-- Code generated by cmd/genactions; DO NOT EDIT.\n\n")

	// Hand-written API stubs
	b.WriteString(luaHandWrittenClasses)
	b.WriteString("\n")
	writeLuaHandWrittenFunctions(&b)

	// Generate action classes for each scope.
	for _, scope := range scopes {
		className := "jjui." + scope.fullName
		b.WriteString(fmt.Sprintf("---@class %s\n", className))

		// Add child fields
		childNames := sortedScopeNames(scope.children)
		for _, childName := range childNames {
			childScope := scope.fullName + "." + childName
			b.WriteString(fmt.Sprintf("---@field %s jjui.%s\n", childName, childScope))
		}

		// Add action functions
		hasCancel := false
		hasClose := false
		for _, action := range scope.actions {
			if action.Token == "cancel" {
				hasCancel = true
			}
			if action.Token == "close" {
				hasClose = true
			}
			writeActionFunction(&b, action)
		}
		// Mirror the runtime "close" alias for "cancel"
		if hasCancel && !hasClose {
			b.WriteString("---@field close fun()\n")
		}
		b.WriteString("\n")
	}

	// Root jjui class
	b.WriteString("---@class jjui\n")
	b.WriteString("---@field revisions jjui.revisions\n")
	b.WriteString("---@field revset jjui.revset\n")
	b.WriteString("---@field context jjui.context\n")

	// Add top-level scope children (those without dots).
	for _, name := range topLevel {
		// Skip revisions, revset, context — already declared above with hand-written types
		if name == "revisions" || name == "revset" || name == "context" {
			continue
		}
		b.WriteString(fmt.Sprintf("---@field %s jjui.%s\n", name, name))
	}

	b.WriteString("---@field builtin jjui.builtin\n")

	// Write hand-written function fields on jjui
	writeLuaHandWrittenRootFields(&b)
	b.WriteString("\n")

	// jjui.builtin class — mirrors the generated action structure
	b.WriteString("---@class jjui.builtin\n")
	for _, name := range topLevel {
		b.WriteString(fmt.Sprintf("---@field %s jjui.%s\n", name, name))
	}
	b.WriteString("\n")

	// Global aliases
	b.WriteString("---@type jjui\n")
	b.WriteString("jjui = {}\n\n")

	b.WriteString("---@type jjui.revisions\n")
	b.WriteString("revisions = {}\n\n")

	b.WriteString("---@type jjui.revset\n")
	b.WriteString("revset = {}\n\n")

	b.WriteString("---@type jjui.context\n")
	b.WriteString("context = {}\n\n")

	// Expose diff and ui as globals if they exist as top-level scopes.
	for _, name := range topLevel {
		if name == "diff" || name == "ui" {
			b.WriteString(fmt.Sprintf("---@type jjui.%s\n", name))
			b.WriteString(fmt.Sprintf("%s = {}\n\n", name))
		}
	}

	return b.Bytes(), nil
}

func buildLuaScopeTree(actionIDs []string, argSchemas map[string]map[string]string, requiredArgs map[string][]string) *scopeNode {
	root := &scopeNode{children: map[string]*scopeNode{}}
	for _, actionID := range actionIDs {
		scopeName, token := splitCanonicalActionID(actionID)
		if scopeName == "" || token == "" {
			continue
		}
		current := root
		var pathParts []string
		for _, seg := range strings.Split(scopeName, ".") {
			pathParts = append(pathParts, seg)
			if current.children[seg] == nil {
				current.children[seg] = &scopeNode{
					name:     seg,
					fullName: strings.Join(pathParts, "."),
					children: map[string]*scopeNode{},
				}
			}
			current = current.children[seg]
		}
		current.actions = append(current.actions, newLuaActionSpec(actionID, token, argSchemas[actionID], requiredArgs[actionID]))
	}
	sortLuaScopeTree(root)
	return root
}

func newLuaActionSpec(canonical, token string, schema map[string]string, required []string) luaActionSpec {
	requiredSet := make(map[string]struct{}, len(required))
	for _, name := range required {
		requiredSet[name] = struct{}{}
	}
	return luaActionSpec{
		Canonical:               canonical,
		Token:                   token,
		Schema:                  schema,
		Required:                required,
		RequiredSet:             requiredSet,
		HasSingleRequiredString: len(required) == 1 && schema[required[0]] == "string",
	}
}

func sortLuaScopeTree(node *scopeNode) {
	sort.Slice(node.actions, func(i, j int) bool {
		return node.actions[i].Canonical < node.actions[j].Canonical
	})
	for _, childName := range sortedScopeNames(node.children) {
		sortLuaScopeTree(node.children[childName])
	}
}

func collectLuaScopes(root *scopeNode) []*scopeNode {
	var out []*scopeNode
	for _, childName := range sortedScopeNames(root.children) {
		collectLuaScopesInto(&out, root.children[childName])
	}
	return out
}

func collectLuaScopesInto(out *[]*scopeNode, node *scopeNode) {
	*out = append(*out, node)
	for _, childName := range sortedScopeNames(node.children) {
		collectLuaScopesInto(out, node.children[childName])
	}
}

func sortedScopeNames(m map[string]*scopeNode) []string {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	return keys
}

func splitCanonicalActionID(canonical string) (scope string, token string) {
	idx := strings.LastIndex(canonical, ".")
	if idx < 0 {
		return "", canonical
	}
	return canonical[:idx], canonical[idx+1:]
}

func writeActionFunction(b *bytes.Buffer, action luaActionSpec) {
	if len(action.Schema) == 0 {
		b.WriteString(fmt.Sprintf("---@field %s fun()\n", action.Token))
		return
	}

	// Build args type annotation
	argNames := make([]string, 0, len(action.Schema))
	for name := range action.Schema {
		argNames = append(argNames, name)
	}
	sort.Strings(argNames)

	var params []string
	for _, name := range argNames {
		typ := action.Schema[name]
		_, isRequired := action.RequiredSet[name]
		luaType := schemaTypeToLua(typ)
		optional := ""
		if !isRequired {
			optional = "?"
		}
		params = append(params, fmt.Sprintf("%s%s: %s", name, optional, luaType))
	}

	if action.HasSingleRequiredString {
		// Can be called with just the string value or a table
		b.WriteString(fmt.Sprintf("---@field %s fun(value?: string|{%s})\n", action.Token, strings.Join(params, ", ")))
	} else {
		b.WriteString(fmt.Sprintf("---@field %s fun(args: {%s})\n", action.Token, strings.Join(params, ", ")))
	}
}

func schemaTypeToLua(schemaType string) string {
	switch schemaType {
	case "bool":
		return "boolean"
	case "string":
		return "string"
	default:
		if strings.HasPrefix(schemaType, "enum:") {
			values := strings.Split(strings.TrimPrefix(schemaType, "enum:"), "|")
			quoted := make([]string, len(values))
			for i, v := range values {
				quoted[i] = fmt.Sprintf("%q", v)
			}
			return strings.Join(quoted, "|")
		}
		return "any"
	}
}
