package main

import (
	"bytes"
	_ "embed"
	"fmt"
	"github.com/goccy/go-json"
	"go/format"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"text/template"

	"github.com/lukaszraczylo/go-telegram/internal/spec"
)

// Discriminator-value extractors. The curly form ("always “X”") is
// authoritative because Telegram quotes wire literals with curly quotes
// throughout the docs; the bare form ("must be X") is the looser
// non-quoted variant used for BotCommandScope, InputMedia, etc.
var (
	discCurlyRE = regexp.MustCompile(`(?:must be|always)\s+“([^”]+)”`)
	discBareRE  = regexp.MustCompile(`must be\s+([A-Za-z0-9_]+)(?:[\s.,]|$)`)
)

//go:embed types.tmpl
var typesTmpl string

//go:embed methods.tmpl
var methodsTmpl string

//go:embed enums.tmpl
var enumsTmpl string

//go:embed tests.tmpl
var testsTmpl string

// runtimeTypes lists types that are intentionally hand-coded and must not be
// emitted by the code generator. Skipping them prevents collisions between
// generated and hand-coded definitions.
var runtimeTypes = map[string]bool{
	"InputFile":          true,
	"ResponseParameters": true,
	"ChatID":             true,
	"MessageOrBool":      true,
}

// fieldTypeOverrides maps "<TypeOrParamsName>.<FieldName>" → Go type expression.
// Used for fields whose values are restricted but whose enum the scraper
// can't detect (Telegram's curly-quoted emoji literals are routinely
// stripped by the scraper's regex due to byte-boundary issues with
// multi-byte sequences). The hand-curated typed-string enum lives in
// api/enums.go (manual file); this override just retypes the field so
// callers get IDE completion and compile-time checks. Generated fields
// stay typed even after `make regen`.
var fieldTypeOverrides = map[string]string{
	"ReactionTypeEmoji.Emoji": "ReactionEmoji",
	"SendDiceParams.Emoji":    "DiceEmoji",
}

// fieldTypeOverride returns the override type for a (parent, fieldName)
// pair, or "" if none. parent is the Go type name owning the field —
// either a struct type (e.g. "ReactionTypeEmoji") or a method-params
// type (e.g. "SendDiceParams").
func fieldTypeOverride(parent, fieldName string) string {
	if parent == "" {
		return ""
	}
	return fieldTypeOverrides[parent+"."+fieldName]
}

// discriminatorSpec describes how to decode a sealed-interface union by
// peeking at a single JSON field.
type discriminatorSpec struct {
	Field    string            // JSON field name to peek at
	Variants map[string]string // discriminator value → concrete Go type name
}

// knownDiscriminators maps parent union name → discriminator spec.
// Used by the template helpers hasDiscriminator / discriminatorField /
// discriminatorMap to emit UnmarshalXxx helpers.
var knownDiscriminators = map[string]discriminatorSpec{
	"ChatMember": {
		Field: "status",
		Variants: map[string]string{
			"creator":       "ChatMemberOwner",
			"administrator": "ChatMemberAdministrator",
			"member":        "ChatMemberMember",
			"restricted":    "ChatMemberRestricted",
			"left":          "ChatMemberLeft",
			"kicked":        "ChatMemberBanned",
		},
	},
	"MessageOrigin": {
		Field: "type",
		Variants: map[string]string{
			"user":        "MessageOriginUser",
			"hidden_user": "MessageOriginHiddenUser",
			"chat":        "MessageOriginChat",
			"channel":     "MessageOriginChannel",
		},
	},
	"ReactionType": {
		Field: "type",
		Variants: map[string]string{
			"emoji":        "ReactionTypeEmoji",
			"custom_emoji": "ReactionTypeCustomEmoji",
			"paid":         "ReactionTypePaid",
		},
	},
	"PaidMedia": {
		Field: "type",
		Variants: map[string]string{
			"preview": "PaidMediaPreview",
			"photo":   "PaidMediaPhoto",
			"video":   "PaidMediaVideo",
		},
	},
	"BackgroundType": {
		Field: "type",
		Variants: map[string]string{
			"fill":       "BackgroundTypeFill",
			"wallpaper":  "BackgroundTypeWallpaper",
			"pattern":    "BackgroundTypePattern",
			"chat_theme": "BackgroundTypeChatTheme",
		},
	},
	"BackgroundFill": {
		Field: "type",
		Variants: map[string]string{
			"solid":             "BackgroundFillSolid",
			"gradient":          "BackgroundFillGradient",
			"freeform_gradient": "BackgroundFillFreeformGradient",
		},
	},
	"ChatBoostSource": {
		Field: "source",
		Variants: map[string]string{
			"premium":   "ChatBoostSourcePremium",
			"gift_code": "ChatBoostSourceGiftCode",
			"giveaway":  "ChatBoostSourceGiveaway",
		},
	},
	"RevenueWithdrawalState": {
		Field: "type",
		Variants: map[string]string{
			"pending":   "RevenueWithdrawalStatePending",
			"succeeded": "RevenueWithdrawalStateSucceeded",
			"failed":    "RevenueWithdrawalStateFailed",
		},
	},
	"TransactionPartner": {
		Field: "type",
		Variants: map[string]string{
			"fragment":     "TransactionPartnerFragment",
			"user":         "TransactionPartnerUser",
			"telegram_ads": "TransactionPartnerTelegramAds",
			"telegram_api": "TransactionPartnerTelegramApi",
			"other":        "TransactionPartnerOther",
		},
	},
	"MenuButton": {
		Field: "type",
		Variants: map[string]string{
			"commands": "MenuButtonCommands",
			"web_app":  "MenuButtonWebApp",
			"default":  "MenuButtonDefault",
		},
	},
	"OwnedGift": {
		Field: "type",
		Variants: map[string]string{
			"regular": "OwnedGiftRegular",
			"unique":  "OwnedGiftUnique",
		},
	},
	"StoryAreaType": {
		Field: "type",
		Variants: map[string]string{
			"location":           "StoryAreaTypeLocation",
			"suggested_reaction": "StoryAreaTypeSuggestedReaction",
			"link":               "StoryAreaTypeLink",
			"weather":            "StoryAreaTypeWeather",
			"unique_gift":        "StoryAreaTypeUniqueGift",
		},
	},
	// MaybeInaccessibleMessage uses an integer discriminator (date field).
	// Variants is nil — the standard template block is skipped; a
	// hand-coded UnmarshalMaybeInaccessibleMessage is emitted instead.
	"MaybeInaccessibleMessage": {
		Field:    "",
		Variants: nil,
	},
}

// emitter renders Go source from a spec.API IR.
type emitter struct {
	api    *spec.API
	outDir string
	enums  *enumPlan
	// variantDiscs maps a concrete variant type name (e.g.
	// "BotCommandScopeAllPrivateChats") to its discriminator wire-field
	// + value. Populated once at construction; consulted by the types
	// template to emit per-variant MarshalJSON that hardcodes the
	// discriminator so callers don't have to set it by hand.
	variantDiscs map[string]variantDiscriminator
}

func newEmitter(api *spec.API, outDir string) *emitter {
	knownInterfaceTypes = buildUnionTypeSet(api)
	return &emitter{
		api:          api,
		outDir:       outDir,
		enums:        planEnums(api),
		variantDiscs: variantDiscriminators(api),
	}
}

// variantDiscriminator describes the JSON field+value that identifies a
// concrete variant of a sealed-interface union on the wire.
type variantDiscriminator struct {
	JSONField string // wire field name, e.g. "type" or "source"
	GoField   string // Go struct field name, e.g. "Type" or "Source"
	Value     string // the wire value, e.g. "all_private_chats"
}

// variantDiscriminators returns variantTypeName → discriminator for every
// concrete struct that participates in a sealed-interface union and has
// a string-typed first field whose doc fixes its value (the canonical
// "must be X" / "always “X”" patterns Telegram uses).
//
// Resolution order:
//
//  1. knownDiscriminators reverse-lookup (the 13 auto-decode unions).
//     This guarantees parity with UnmarshalXxx dispatch for the unions
//     that round-trip through the library.
//  2. Doc-string analysis of the variant's first field, for marker-only
//     unions (BotCommandScope, InputMedia, etc.) where the IR has no
//     explicit discriminator metadata.
//
// Variants whose first field has no discriminator hint (Message,
// InaccessibleMessage, the InputMessageContent family) are omitted —
// the caller writes the dispatching fields directly and Telegram
// identifies them structurally.
func variantDiscriminators(api *spec.API) map[string]variantDiscriminator {
	out := make(map[string]variantDiscriminator, 128)

	// Pass 1: reverse-lookup from knownDiscriminators.
	for _, ds := range knownDiscriminators {
		if ds.Field == "" {
			continue
		}
		for value, variant := range ds.Variants {
			out[variant] = variantDiscriminator{
				JSONField: ds.Field,
				Value:     value,
			}
		}
	}

	// Build the set of every variant type referenced by any OneOf so we
	// can scan only those (avoids matching free-text "must be" prose in
	// non-variant types like Message).
	variantSet := make(map[string]bool, 128)
	for _, t := range api.Types {
		for _, v := range t.OneOf {
			variantSet[v] = true
		}
	}

	// Pass 2: doc-parse for variants without a known discriminator.
	for _, t := range api.Types {
		if !variantSet[t.Name] {
			continue
		}
		if _, ok := out[t.Name]; ok {
			// Pass-1 already provided the wire value; we still need
			// the Go field name (mirrors the JSON field but with
			// proper case). Resolve from t.Fields by JSONName match.
			disc := out[t.Name]
			for _, f := range t.Fields {
				if f.JSONName == disc.JSONField {
					disc.GoField = f.Name
					out[t.Name] = disc
					break
				}
			}
			continue
		}
		disc, ok := extractVariantDiscriminator(t)
		if !ok {
			continue
		}
		out[t.Name] = disc
	}

	// Drop entries we couldn't resolve a Go field for (defensive — every
	// pass-1 hit should have matched, but better to skip than emit
	// broken code referencing an unknown field name).
	for name, d := range out {
		if d.GoField == "" {
			delete(out, name)
		}
	}
	return out
}

// extractVariantDiscriminator inspects the first field of a variant
// struct and returns its discriminator if the field is a required
// string whose doc nails the value via "must be X" or "always “X”".
// Returns (zero, false) when no clear discriminator is present.
func extractVariantDiscriminator(t spec.TypeDecl) (variantDiscriminator, bool) {
	if len(t.Fields) == 0 {
		return variantDiscriminator{}, false
	}
	f := t.Fields[0]
	if !f.Required || f.Type.Kind != spec.KindPrimitive || f.Type.Name != "string" {
		return variantDiscriminator{}, false
	}
	value := parseDiscriminatorDoc(f.Doc)
	if value == "" {
		return variantDiscriminator{}, false
	}
	return variantDiscriminator{
		JSONField: f.JSONName,
		GoField:   f.Name,
		Value:     value,
	}, true
}

// parseDiscriminatorDoc extracts the wire-level discriminator value
// from a field doc string. Handles both Telegram phrasings:
//
//   - "Scope type, must be all_private_chats"           (bare token)
//   - "Type of the message origin, always “user”"      (curly-quoted)
//
// Returns "" when no discriminator is present.
func parseDiscriminatorDoc(doc string) string {
	// Curly-quoted form takes priority: "must be “X”" or "always “X”".
	if m := discCurlyRE.FindStringSubmatch(doc); len(m) == 2 {
		return m[1]
	}
	// Bare-token form: "must be <ident>" terminated by end-of-string,
	// punctuation, or whitespace.
	if m := discBareRE.FindStringSubmatch(doc); len(m) == 2 {
		return m[1]
	}
	return ""
}

// knownInterfaceTypes is the full set of sealed-interface union type names
// (both auto-decoded ones in knownDiscriminators and marker-only ones from
// types with OneOf). Populated at emitter construction. goType and
// unionTypeFor consult this so optional fields of any union type stay
// bare interface, never *Interface (which is meaningless in Go and trips
// users at every call site).
var knownInterfaceTypes = map[string]bool{}

// emitTypes renders types.gen.go.
func (e *emitter) emitTypes() error {
	t, err := template.New("types").Funcs(funcsWithDiscs(e.enums, e.variantDiscs)).Parse(typesTmpl)
	if err != nil {
		return fmt.Errorf("parse types.tmpl: %w", err)
	}
	filtered := *e.api
	filtered.Types = nil
	for _, typ := range e.api.Types {
		if !runtimeTypes[typ.Name] {
			filtered.Types = append(filtered.Types, typ)
		}
	}
	var buf bytes.Buffer
	if execErr := t.Execute(&buf, &filtered); execErr != nil {
		return fmt.Errorf("execute types.tmpl: %w", execErr)
	}
	src, err := format.Source(buf.Bytes())
	if err != nil {
		// Surface the unformatted output so debugging is possible.
		return fmt.Errorf("gofmt types.gen.go: %w\n--- unformatted ---\n%s", err, buf.String())
	}
	return os.WriteFile(filepath.Join(e.outDir, "types.gen.go"), src, 0o600)
}

// loadAPI reads and decodes the IR JSON.
func loadAPI(path string) (*spec.API, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	var api spec.API
	if err := json.Unmarshal(data, &api); err != nil {
		return nil, err
	}
	return &api, nil
}

// funcsWithDiscs returns the shared FuncMap with the variant
// discriminator helpers bound to discs. types.tmpl uses
// variantDiscFor/variantHasDisc to emit per-variant MarshalJSON that
// hardcodes the wire discriminator value.
func funcsWithDiscs(plan *enumPlan, discs map[string]variantDiscriminator) template.FuncMap {
	fm := funcs(plan)
	fm["variantHasDisc"] = func(name string) bool {
		_, ok := discs[name]
		return ok
	}
	fm["variantDiscField"] = func(name string) string { return discs[name].JSONField }
	fm["variantDiscGoField"] = func(name string) string { return discs[name].GoField }
	fm["variantDiscValue"] = func(name string) string { return discs[name].Value }
	return fm
}

// funcs is the FuncMap shared across templates. plan is the resolved
// enum plan; pass nil only in unit tests that don't exercise enums.
func funcs(plan *enumPlan) template.FuncMap {
	return template.FuncMap{
		"goType": goType,
		"goField": func(parent string, f spec.Field) string {
			return goField(plan, parent, f)
		},
		"goFieldP": func(methodName string, f spec.Field) string {
			return goFieldX(plan, methodEnumParent(methodName), title(methodName)+"Params", f)
		},
		"docComment":  docComment,
		"isOptional":  func(f spec.Field) bool { return !f.Required },
		"not":         func(b bool) bool { return !b },
		"title":       title,
		"isFileField": isFileField,
		"fileCheck":   fileCheck,
		"multipartFieldEntry": func(parent string, f spec.Field) string {
			return multipartFieldEntry(plan, parent, f)
		},
		"multipartFieldEntryP": func(methodName string, f spec.Field) string {
			return multipartFieldEntryX(plan, methodEnumParent(methodName), title(methodName)+"Params", f)
		},
		"multipartFileEntry": multipartFileEntry,
		"returnGoType":       returnGoType,
		// enum helpers
		"enums": func() []enumDecl {
			if plan == nil {
				return nil
			}
			return plan.All()
		},
		"enumConstName": constName,
		// discriminator helpers for types.tmpl
		"hasDiscriminator": func(name string) bool { s, ok := knownDiscriminators[name]; return ok && len(s.Variants) > 0 },
		"isSealedUnionReturn": func(tr spec.TypeRef) bool {
			if tr.Kind != spec.KindNamed {
				return false
			}
			s, ok := knownDiscriminators[tr.Name]
			return ok && len(s.Variants) > 0
		},
		"isSealedUnionArrayReturn": func(tr spec.TypeRef) bool {
			if tr.Kind != spec.KindArray || tr.ElemType == nil || tr.ElemType.Kind != spec.KindNamed {
				return false
			}
			s, ok := knownDiscriminators[tr.ElemType.Name]
			return ok && len(s.Variants) > 0
		},
		"sealedUnionElemName": func(tr spec.TypeRef) string {
			if tr.Kind == spec.KindArray && tr.ElemType != nil {
				return tr.ElemType.Name
			}
			return ""
		},
		"isMaybeInaccessibleMessage": func(name string) bool { return name == "MaybeInaccessibleMessage" },
		"discriminatorField":         func(name string) string { return knownDiscriminators[name].Field },
		"discriminatorMap":           func(name string) map[string]string { return knownDiscriminators[name].Variants },
		// union-field helpers for per-struct UnmarshalJSON emission
		"unionFields":   unionFieldsOf,
		"isArrayUnion":  func(tr spec.TypeRef) bool { return hasUnionElem(tr) },
		"unionTypeName": func(tr spec.TypeRef) string { name, _ := unionTypeFor(tr); return name },
	}
}

// title upper-cases the first byte of s (ASCII only — all Telegram method names are ASCII).
func title(s string) string {
	if s == "" {
		return ""
	}
	r := s[0]
	if r >= 'a' && r <= 'z' {
		r = r - 'a' + 'A'
	}
	return string(r) + s[1:]
}

// isFileField reports whether the field carries an InputFile.
func isFileField(f spec.Field) bool {
	return mentionsInputFileTr(f.Type)
}

func mentionsInputFileTr(tr spec.TypeRef) bool {
	switch tr.Kind {
	case spec.KindNamed:
		return tr.Name == "InputFile"
	case spec.KindArray:
		if tr.ElemType != nil {
			return mentionsInputFileTr(*tr.ElemType)
		}
	case spec.KindOneOf:
		for _, v := range tr.Variants {
			if v == "InputFile" {
				return true
			}
		}
	}
	return false
}

// fileCheck returns the HasFile guard line for a file-carrying field.
// Both named InputFile and InputFile-or-String oneOf fields are now *InputFile,
// so no type assertion is needed in either case.
func fileCheck(f spec.Field) string {
	return fmt.Sprintf("\tif p.%s != nil && p.%s.IsLocalUpload() { return true }\n", f.Name, f.Name)
}

// multipartFileEntry returns the MultipartFiles append block for a file field.
// Both named InputFile and InputFile-or-String oneOf fields are now *InputFile,
// so the same code works for both cases.
func multipartFileEntry(f spec.Field) string {
	jsonName := f.JSONName
	return fmt.Sprintf(
		"\tif p.%s != nil && p.%s.IsLocalUpload() {\n\t\tname := p.%s.Filename\n\t\tif name == \"\" { name = %q }\n\t\tfiles = append(files, client.MultipartFile{FieldName: %q, Filename: name, Reader: p.%s.Reader})\n\t}\n",
		f.Name, f.Name, f.Name, jsonName, jsonName, f.Name)
}

// multipartFieldEntry generates the line that adds f to the multipart map.
// Required scalar fields go in unconditionally; optional ones go in only
// when non-zero/non-empty. Typed-string enum fields are cast to string
// before assignment because the multipart map is map[string]string.
func multipartFieldEntry(plan *enumPlan, parent string, f spec.Field) string {
	return multipartFieldEntryX(plan, parent, parent, f)
}

// multipartFieldEntryX mirrors goFieldX: enumParent keys the enum plan,
// overrideParent keys fieldTypeOverrides. They differ only for method
// params.
func multipartFieldEntryX(plan *enumPlan, enumParent, overrideParent string, f spec.Field) string {
	enumName := plan.FieldEnum(enumParent, f.Name)
	if enumName == "" {
		enumName = fieldTypeOverride(overrideParent, f.Name)
	}
	switch f.Type.Kind {
	case spec.KindPrimitive:
		switch f.Type.Name {
		case "int64":
			if f.Required {
				return fmt.Sprintf("\tout[%q] = strconv.FormatInt(p.%s, 10)\n", f.JSONName, f.Name)
			}
			return fmt.Sprintf("\tif p.%s != nil { out[%q] = strconv.FormatInt(*p.%s, 10) }\n", f.Name, f.JSONName, f.Name)
		case "string":
			if enumName != "" {
				if f.Required {
					return fmt.Sprintf("\tout[%q] = string(p.%s)\n", f.JSONName, f.Name)
				}
				return fmt.Sprintf("\tif p.%s != \"\" { out[%q] = string(p.%s) }\n", f.Name, f.JSONName, f.Name)
			}
			if f.Required {
				return fmt.Sprintf("\tout[%q] = p.%s\n", f.JSONName, f.Name)
			}
			return fmt.Sprintf("\tif p.%s != \"\" { out[%q] = p.%s }\n", f.Name, f.JSONName, f.Name)
		case "bool":
			if f.Required {
				return fmt.Sprintf("\tout[%q] = strconv.FormatBool(p.%s)\n", f.JSONName, f.Name)
			}
			return fmt.Sprintf("\tif p.%s != nil { out[%q] = strconv.FormatBool(*p.%s) }\n", f.Name, f.JSONName, f.Name)
		case "float64":
			if f.Required {
				return fmt.Sprintf("\tout[%q] = strconv.FormatFloat(p.%s, 'f', -1, 64)\n", f.JSONName, f.Name)
			}
			return fmt.Sprintf("\tif p.%s != nil { out[%q] = strconv.FormatFloat(*p.%s, 'f', -1, 64) }\n", f.Name, f.JSONName, f.Name)
		}
	case spec.KindOneOf:
		// Integer-or-String → ChatID: use .String() wire form.
		if matchesVariants(f.Type.Variants, "int64", "string") {
			if f.Required {
				return fmt.Sprintf("\tout[%q] = p.%s.String()\n", f.JSONName, f.Name)
			}
			return fmt.Sprintf("\tif !p.%s.IsZero() { out[%q] = p.%s.String() }\n", f.Name, f.JSONName, f.Name)
		}
		// InputFile-or-String → *InputFile: non-upload branch sends PathOrID.
		if matchesVariants(f.Type.Variants, "InputFile", "string") {
			return fmt.Sprintf("\tif p.%s != nil && !p.%s.IsLocalUpload() && p.%s.PathOrID != \"\" { out[%q] = p.%s.PathOrID }\n",
				f.Name, f.Name, f.Name, f.JSONName, f.Name)
		}
		// Sealed-interface unions — JSON-marshal.
		if f.Required {
			return fmt.Sprintf("\tif b, _ := json.Marshal(p.%s); len(b) > 0 && string(b) != \"null\" { out[%q] = string(b) }\n", f.Name, f.JSONName)
		}
		return fmt.Sprintf("\tif p.%s != nil { if b, _ := json.Marshal(p.%s); len(b) > 0 && string(b) != \"null\" { out[%q] = string(b) } }\n", f.Name, f.Name, f.JSONName)
	}
	// Named or array: fall back to JSON-marshal to JSON string.
	if f.Required {
		return fmt.Sprintf("\tif b, _ := json.Marshal(p.%s); len(b) > 0 { out[%q] = string(b) }\n", f.Name, f.JSONName)
	}
	return fmt.Sprintf("\tif p.%s != nil { if b, _ := json.Marshal(p.%s); len(b) > 0 { out[%q] = string(b) } }\n", f.Name, f.Name, f.JSONName)
}

func returnGoType(tr spec.TypeRef) string {
	switch tr.Kind {
	case spec.KindPrimitive:
		return tr.Name
	case spec.KindNamed:
		// Sealed-interface unions are returned by interface value, not pointer
		// (you can't take a pointer to an interface in any useful way; the
		// generated UnmarshalXxx returns the interface directly).
		if _, ok := knownDiscriminators[tr.Name]; ok {
			return tr.Name
		}
		// MessageOrBool is a hand-coded runtime wrapper — pointer return.
		return "*" + tr.Name
	case spec.KindArray:
		if tr.ElemType == nil {
			return "[]any"
		}
		return "[]" + returnGoElem(*tr.ElemType)
	case spec.KindOneOf:
		// Integer-or-String return (rare but possible).
		if matchesVariants(tr.Variants, "int64", "string") {
			return "ChatID"
		}
		return "any"
	}
	return "any"
}

func returnGoElem(tr spec.TypeRef) string {
	switch tr.Kind {
	case spec.KindPrimitive:
		return tr.Name
	case spec.KindNamed:
		return tr.Name
	case spec.KindArray:
		if tr.ElemType == nil {
			return "any"
		}
		return "[]" + returnGoElem(*tr.ElemType)
	}
	return "any"
}

// emitMethods renders methods.gen.go.
func (e *emitter) emitMethods() error {
	t, err := template.New("methods").Funcs(funcs(e.enums)).Parse(methodsTmpl)
	if err != nil {
		return fmt.Errorf("parse methods.tmpl: %w", err)
	}
	var buf bytes.Buffer
	if execErr := t.Execute(&buf, e.api); execErr != nil {
		return fmt.Errorf("execute methods.tmpl: %w", execErr)
	}
	src, err := format.Source(buf.Bytes())
	if err != nil {
		return fmt.Errorf("gofmt methods.gen.go: %w\n--- unformatted ---\n%s", err, buf.String())
	}
	return os.WriteFile(filepath.Join(e.outDir, "methods.gen.go"), src, 0o600)
}

// emitEnums renders enums.gen.go.
func (e *emitter) emitEnums() error {
	t, err := template.New("enums").Funcs(funcs(e.enums)).Parse(enumsTmpl)
	if err != nil {
		return fmt.Errorf("parse enums.tmpl: %w", err)
	}
	var buf bytes.Buffer
	if execErr := t.Execute(&buf, e.api); execErr != nil {
		return fmt.Errorf("execute enums.tmpl: %w", execErr)
	}
	src, err := format.Source(buf.Bytes())
	if err != nil {
		return fmt.Errorf("gofmt enums.gen.go: %w\n--- unformatted ---\n%s", err, buf.String())
	}
	return os.WriteFile(filepath.Join(e.outDir, "enums.gen.go"), src, 0o600)
}

// goType returns the Go type expression for a TypeRef.
// Optional fields use pointer types for primitives and named types,
// or rely on omitempty for slices and maps. parameter `optional` controls
// whether to wrap pointer-style.
func goType(tr spec.TypeRef, optional bool) string {
	switch tr.Kind {
	case spec.KindPrimitive:
		if optional && (tr.Name == "bool" || tr.Name == "int64" || tr.Name == "float64") {
			return "*" + tr.Name
		}
		return tr.Name
	case spec.KindNamed:
		// Named types are always pointer-optional when optional, except:
		// 1. Union (interface) types — they are naturally nil-able; pointer-to-interface is invalid.
		// 2. InputFile is always pointer-typed even when required: the
		//    multipart helpers (fileCheck, multipartFileEntry) call
		//    f.IsLocalUpload() and dereference Reader, both of which
		//    expect a pointer receiver.
		if knownInterfaceTypes[tr.Name] {
			// Interface type — never add *.
			return tr.Name
		}
		if optional || tr.Name == "InputFile" {
			return "*" + tr.Name
		}
		return tr.Name
	case spec.KindArray:
		if tr.ElemType == nil {
			return "[]any"
		}
		// Inside slices, the element shape is its own thing — never wrap
		// the element in a pointer just because the field is optional.
		return "[]" + goType(*tr.ElemType, false)
	case spec.KindOneOf:
		// Integer-or-String: typed ChatID wrapper.
		if matchesVariants(tr.Variants, "int64", "string") {
			if optional {
				return "*ChatID"
			}
			return "ChatID"
		}
		// InputFile-or-String: *InputFile runtime helper handles both.
		if matchesVariants(tr.Variants, "InputFile", "string") {
			return "*InputFile"
		}
		// All-named variants sealed interface: fall back to interface.
		return "any"
	}
	return "any"
}

// unionField pairs a struct field with the name of its union type.
type unionField struct {
	Field     spec.Field
	UnionName string // e.g. "ChatMember"
}

// unionFieldsOf returns the subset of t.Fields whose type is a known
// discriminated union (directly or as array element).
func unionFieldsOf(t spec.TypeDecl) []unionField {
	var out []unionField
	for _, f := range t.Fields {
		if u, ok := unionTypeFor(f.Type); ok {
			out = append(out, unionField{Field: f, UnionName: u})
		}
	}
	return out
}

// unionTypeFor inspects a TypeRef and reports whether it (or its array
// element) is a known discriminated union. Returns the union name and true.
func unionTypeFor(tr spec.TypeRef) (string, bool) {
	switch tr.Kind {
	case spec.KindNamed:
		if _, ok := knownDiscriminators[tr.Name]; ok {
			return tr.Name, true
		}
	case spec.KindArray:
		if tr.ElemType != nil {
			return unionTypeFor(*tr.ElemType)
		}
	case spec.KindOneOf:
		if u := unionNameByVariants(tr.Variants); u != "" {
			return u, true
		}
	}
	return "", false
}

// unionNameByVariants finds the parent union whose variant type names exactly
// match the given variant set (order-insensitive).
func unionNameByVariants(variants []string) string {
	for parentName, ds := range knownDiscriminators {
		wanted := make([]string, 0, len(ds.Variants))
		for _, vt := range ds.Variants {
			wanted = append(wanted, vt)
		}
		if matchesVariants(variants, wanted...) {
			return parentName
		}
	}
	return ""
}

// hasUnionElem reports whether tr is an array whose element type is a known union.
func hasUnionElem(tr spec.TypeRef) bool {
	if tr.Kind != spec.KindArray || tr.ElemType == nil {
		return false
	}
	_, ok := unionTypeFor(*tr.ElemType)
	return ok
}

// matchesVariants reports whether got equals want as a set (order-insensitive).
func matchesVariants(got []string, want ...string) bool {
	if len(got) != len(want) {
		return false
	}
	seen := make(map[string]int, len(got))
	for _, g := range got {
		seen[g]++
	}
	for _, w := range want {
		seen[w]--
	}
	for _, v := range seen {
		if v != 0 {
			return false
		}
	}
	return true
}

// goField returns the Go struct-field declaration for a Field.
// When the field carries scraper-detected enum values and the emitter
// has a planned enum name for (parent, field), the field's Go type is
// the enum identifier. Typed-string enums use the zero string ""
// behaviour for omitempty, so we do not pointer-wrap optional enum
// fields. Parent is "" for method parameters; pass the params type
// name (e.g. "SendDiceParams") via overrideParent when calling from
// the methods template so fieldTypeOverrides can resolve.
func goField(plan *enumPlan, parent string, f spec.Field) string {
	return goFieldX(plan, parent, parent, f)
}

// goFieldX is the underlying field-emitter. enumParent is used for the
// enum-plan lookup (which keys method params under ""); overrideParent
// is used for fieldTypeOverrides (which keys method params under the
// params type name). For struct types both are the same; for method
// params they differ.
func goFieldX(plan *enumPlan, enumParent, overrideParent string, f spec.Field) string {
	tag := fmt.Sprintf("`json:%q`", f.JSONName+omitempty(f))
	if name := plan.FieldEnum(enumParent, f.Name); name != "" {
		return fmt.Sprintf("%s %s %s", f.Name, name, tag)
	}
	if name := fieldTypeOverride(overrideParent, f.Name); name != "" {
		return fmt.Sprintf("%s %s %s", f.Name, name, tag)
	}
	// Pinned companion-enum retype: allowed_updates is an Array of String
	// in the upstream spec, but the Go API exposes a hand-curated
	// UpdateType (api/enums.go) since the values are not enumerated
	// inline by Telegram. Retype []string → []UpdateType wherever the
	// wire field is allowed_updates so callers can pass typed constants
	// (api.UpdateMessage, ...) without string casts. Wire format is
	// unchanged: UpdateType is a typed string, marshals identically.
	if f.JSONName == "allowed_updates" &&
		f.Type.Kind == spec.KindArray &&
		f.Type.ElemType != nil &&
		f.Type.ElemType.Kind == spec.KindPrimitive &&
		f.Type.ElemType.Name == "string" {
		return fmt.Sprintf("%s []UpdateType %s", f.Name, tag)
	}
	return fmt.Sprintf("%s %s %s", f.Name, goType(f.Type, !f.Required), tag)
}

func omitempty(f spec.Field) string {
	if f.Required {
		return ""
	}
	return ",omitempty"
}

// docComment converts a doc string into a Go-style block comment with
// a leading "// " on each line.
func docComment(s string) string {
	if s == "" {
		return ""
	}
	var buf bytes.Buffer
	for _, line := range splitLines(s) {
		buf.WriteString("// ")
		buf.WriteString(line)
		buf.WriteByte('\n')
	}
	return buf.String()
}

func splitLines(s string) []string {
	var out []string
	start := 0
	for i := 0; i < len(s); i++ {
		if s[i] == '\n' {
			out = append(out, s[start:i])
			start = i + 1
		}
	}
	if start < len(s) {
		out = append(out, s[start:])
	}
	return out
}

// hasVariants reports whether the variant list contains all of the named strings (order-insensitive).
func hasVariants(variants []string, names ...string) bool {
	return matchesVariants(variants, names...)
}

// buildUnionTypeSet returns the set of all type names that generate interface types
// (i.e., types with one_of). This includes knownDiscriminators and marker-interface
// unions not covered by the discriminator map.
func buildUnionTypeSet(api *spec.API) map[string]bool {
	s := make(map[string]bool, len(knownDiscriminators)+16)
	for name := range knownDiscriminators {
		s[name] = true
	}
	for _, t := range api.Types {
		if len(t.OneOf) > 0 {
			s[t.Name] = true
		}
	}
	return s
}

// makeSentinelValue returns a sentinelValue func that uses the given union type set.
// It returns a minimal valid Go expression for a spec.Field's type,
// used in generated test param literals. plan supplies typed-enum names
// so a method-param sentinel for a ParseMode field becomes a typed
// constant rather than a magic string.
func makeSentinelValue(unionTypes map[string]bool, plan *enumPlan) func(string, spec.Field) string {
	return func(methodName string, f spec.Field) string {
		return sentinelForField(methodName, f, unionTypes, plan)
	}
}

func sentinelForField(methodName string, f spec.Field, unionTypes map[string]bool, plan *enumPlan) string {
	if name := plan.FieldEnum(methodEnumParent(methodName), f.Name); name != "" && len(f.EnumValues) > 0 {
		return plan.ConstFor(name, f.EnumValues[0])
	}
	tr := f.Type
	switch tr.Kind {
	case spec.KindPrimitive:
		switch tr.Name {
		case "int64":
			return "42"
		case "string":
			return `"test_value"`
		case "bool":
			return "true"
		case "float64":
			return "1.0"
		}
	case spec.KindNamed:
		switch tr.Name {
		case "ChatID":
			return "ChatIDFromInt(123)"
		case "InputFile":
			return `&InputFile{PathOrID: "file_id_test"}`
		}
		// Interface (union) types are nil-able.
		if unionTypes[tr.Name] {
			return "nil"
		}
		// Required named struct types are value types in the generated struct.
		if f.Required {
			return tr.Name + "{}"
		}
		return "&" + tr.Name + "{}"
	case spec.KindArray:
		return "nil"
	case spec.KindOneOf:
		if hasVariants(tr.Variants, "int64", "string") {
			return "ChatIDFromInt(123)"
		}
		if hasVariants(tr.Variants, "InputFile", "string") {
			return `&InputFile{PathOrID: "file_id_test"}`
		}
		// Sealed named-union interface: use nil (any).
		return "nil"
	}
	return "nil"
}

// successResp returns a backtick Go string literal containing a minimal
// {"ok":true,"result":...} JSON body for the method's return type.
func successResp(m spec.MethodDecl) string {
	body := successBody(m.Returns)
	return "`{\"ok\":true,\"result\":" + body + "}`"
}

func successBody(tr spec.TypeRef) string {
	switch tr.Kind {
	case spec.KindPrimitive:
		switch tr.Name {
		case "bool":
			return "true"
		case "int64", "float64":
			return "0"
		case "string":
			return `""`
		}
	case spec.KindNamed:
		if tr.Name == "MessageOrBool" {
			return "true"
		}
		// Sealed-interface unions need a discriminator field so UnmarshalXxx can dispatch.
		// Pick the lexicographically first variant value for determinism (map
		// iteration order in Go is randomized — using `range` directly produces
		// non-deterministic regen output).
		if disc, ok := knownDiscriminators[tr.Name]; ok && disc.Field != "" {
			values := make([]string, 0, len(disc.Variants))
			for v := range disc.Variants {
				values = append(values, v)
			}
			sort.Strings(values)
			if len(values) > 0 {
				return fmt.Sprintf(`{"%s":"%s"}`, disc.Field, values[0])
			}
		}
		// MaybeInaccessibleMessage uses date==0 → InaccessibleMessage variant.
		if tr.Name == "MaybeInaccessibleMessage" {
			return `{"date":0,"chat":{"id":1,"type":"private"},"message_id":1}`
		}
		return "{}"
	case spec.KindArray:
		return "[]"
	case spec.KindOneOf:
		return "null"
	}
	return "null"
}

// emitTests renders methods_gen_test.go.
func (e *emitter) emitTests() error {
	unionTypes := buildUnionTypeSet(e.api)

	// Add test-specific helpers to the shared func map.
	fm := funcs(e.enums)
	fm["sentinelValue"] = makeSentinelValue(unionTypes, e.enums)
	fm["successResp"] = successResp

	t, err := template.New("tests").Funcs(fm).Parse(testsTmpl)
	if err != nil {
		return fmt.Errorf("parse tests.tmpl: %w", err)
	}
	var buf bytes.Buffer
	if execErr := t.Execute(&buf, e.api); execErr != nil {
		return fmt.Errorf("execute tests.tmpl: %w", execErr)
	}
	src, err := format.Source(buf.Bytes())
	if err != nil {
		return fmt.Errorf("gofmt methods_gen_test.go: %w\n--- unformatted ---\n%s", err, buf.String())
	}
	return os.WriteFile(filepath.Join(e.outDir, "methods_gen_test.go"), src, 0o600)
}
