// Command openapi-gen generates openapi.yaml from Go source types and route metadata.
//
// Run via: go generate ./internal/api/
package main

import (
	"fmt"
	"os"
	"reflect"
	"sort"
	"strings"

	"github.com/VoiceBlender/voiceblender/internal/api"
	"gopkg.in/yaml.v3"
)

// ── YAML ordered-map helpers ────────────────────────────────────────────

// omap is an ordered map backed by yaml.Node (kind MappingNode).
type omap struct{ node yaml.Node }

func newMap() *omap {
	return &omap{node: yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}}
}

// setQuotedKey is like set but forces the key to be single-quoted (e.g. '200').
func (m *omap) setQuotedKey(key string, val interface{}) *omap {
	keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key, Tag: "!!str", Style: yaml.SingleQuotedStyle}
	m.setWithKeyNode(keyNode, val)
	return m
}

func (m *omap) set(key string, val interface{}) *omap {
	keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key, Tag: "!!str"}
	m.setWithKeyNode(keyNode, val)
	return m
}

func (m *omap) setWithKeyNode(keyNode *yaml.Node, val interface{}) {
	var valNode *yaml.Node
	switch v := val.(type) {
	case *omap:
		valNode = &v.node
	case *seq:
		valNode = &v.node
	case *yaml.Node:
		valNode = v
	case string:
		valNode = &yaml.Node{Kind: yaml.ScalarNode, Value: v, Tag: "!!str"}
	case int:
		valNode = &yaml.Node{Kind: yaml.ScalarNode, Value: fmt.Sprintf("%d", v), Tag: "!!int"}
	case bool:
		valNode = &yaml.Node{Kind: yaml.ScalarNode, Value: fmt.Sprintf("%t", v), Tag: "!!bool"}
	case float64:
		valNode = &yaml.Node{Kind: yaml.ScalarNode, Value: fmt.Sprintf("%g", v), Tag: "!!float"}
	default:
		// Marshal through yaml then decode to node.
		b, _ := yaml.Marshal(v)
		valNode = &yaml.Node{}
		_ = yaml.Unmarshal(b, valNode)
		// yaml.Unmarshal wraps in a document node; unwrap.
		if valNode.Kind == yaml.DocumentNode && len(valNode.Content) > 0 {
			valNode = valNode.Content[0]
		}
	}
	m.node.Content = append(m.node.Content, keyNode, valNode)
}

type seq struct{ node yaml.Node }

func newSeq() *seq {
	return &seq{node: yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}}
}

func (s *seq) add(val interface{}) *seq {
	switch v := val.(type) {
	case *omap:
		s.node.Content = append(s.node.Content, &v.node)
	case *seq:
		s.node.Content = append(s.node.Content, &v.node)
	case string:
		s.node.Content = append(s.node.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: v, Tag: "!!str"})
	default:
		b, _ := yaml.Marshal(v)
		n := &yaml.Node{}
		_ = yaml.Unmarshal(b, n)
		if n.Kind == yaml.DocumentNode && len(n.Content) > 0 {
			n = n.Content[0]
		}
		s.node.Content = append(s.node.Content, n)
	}
	return s
}

// ── Schema generation ───────────────────────────────────────────────────

// Package-level enrichment data loaded from the api package.
var (
	schemaEnrichments       map[string]api.FieldEnrichment
	webhookFieldDescs       map[string]string
	webhookNestedFieldDescs map[string]string
)

// schemaRegistry collects named schemas and deduplicates.
var schemaRegistry = map[string]*omap{}

func schemaRef(name string) *omap {
	return newMap().set("$ref", "#/components/schemas/"+schemaDisplayName(name))
}

func typeName(t reflect.Type) string {
	if t.Kind() == reflect.Ptr {
		t = t.Elem()
	}
	return t.Name()
}

func goTypeToSchema(t reflect.Type) *omap {
	if t.Kind() == reflect.Ptr {
		t = t.Elem()
	}
	switch t.Kind() {
	case reflect.String:
		return newMap().set("type", "string")
	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
		return newMap().set("type", "integer")
	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
		return newMap().set("type", "integer")
	case reflect.Float32, reflect.Float64:
		return newMap().set("type", "number")
	case reflect.Bool:
		return newMap().set("type", "boolean")
	case reflect.Slice:
		elem := t.Elem()
		if elem.Kind() == reflect.Struct && elem.Name() != "" {
			registerSchema(elem)
			return newMap().set("type", "array").set("items", schemaRef(elem.Name()))
		}
		return newMap().set("type", "array").set("items", goTypeToSchema(elem))
	case reflect.Map:
		if t.Key().Kind() == reflect.String {
			return newMap().set("type", "object").set("additionalProperties", goTypeToSchema(t.Elem()))
		}
		return newMap().set("type", "object")
	case reflect.Struct:
		if t.Name() != "" {
			registerSchema(t)
			return schemaRef(t.Name())
		}
		return structToSchema(t)
	}
	return newMap().set("type", "string")
}

// responseSchemaTypes tracks types that represent API responses (not requests).
// These get instance_id prepended.
var responseSchemaTypes = map[string]bool{
	"LegView":  true,
	"RoomView": true,
}

func structToSchema(t reflect.Type) *omap {
	parentName := t.Name()
	props := newMap()
	required := newSeq()
	hasRequired := false

	// Add instance_id as the first property for response types.
	if responseSchemaTypes[parentName] {
		props.set("instance_id", newMap().set("type", "string").set("description", "Instance identifier"))
	}

	for i := 0; i < t.NumField(); i++ {
		f := t.Field(i)
		if !f.IsExported() {
			continue
		}
		// Handle embedded structs by flattening their fields.
		if f.Anonymous {
			embT := f.Type
			if embT.Kind() == reflect.Ptr {
				embT = embT.Elem()
			}
			for j := 0; j < embT.NumField(); j++ {
				ef := embT.Field(j)
				if !ef.IsExported() {
					continue
				}
				jsonName, omit := parseJSONTag(ef)
				if jsonName == "-" {
					continue
				}
				props.set(jsonName, enrichedFieldSchema(parentName, jsonName, ef))
				if !omit {
					required.add(jsonName)
					hasRequired = true
				}
			}
			continue
		}
		jsonName, omit := parseJSONTag(f)
		if jsonName == "-" {
			continue
		}
		props.set(jsonName, enrichedFieldSchema(parentName, jsonName, f))
		if !omit {
			required.add(jsonName)
			hasRequired = true
		}
	}

	schema := newMap().set("type", "object").set("properties", props)
	if hasRequired {
		schema.set("required", required)
	}

	// Add oneOf constraint for PlaybackRequest.
	if parentName == "PlaybackRequest" {
		oneOf := newSeq()
		oneOf.add(newMap().set("required", newSeq().add("url").add("mime_type")))
		oneOf.add(newMap().set("required", newSeq().add("tone")))
		schema.set("oneOf", oneOf)
	}

	return schema
}

// enrichedFieldSchema generates the schema for a struct field, then applies
// any enrichment metadata (description, enum, format, constraints).
func enrichedFieldSchema(parentType, jsonName string, f reflect.StructField) *omap {
	schema := fieldSchema(f)

	// Look up enrichment.
	key := parentType + "." + jsonName
	enrich, ok := schemaEnrichments[key]
	if !ok {
		return schema
	}

	// If the schema is a $ref, wrap with description via allOf.
	if isRef(schema) {
		wrapper := newMap()
		if enrich.Description != "" {
			wrapper.set("description", enrich.Description)
		}
		wrapper.set("allOf", newSeq().add(schema))
		return wrapper
	}

	// If the schema uses nullable+allOf (pointer-to-struct), add description at top.
	if hasAllOf(schema) {
		if enrich.Description != "" {
			// Insert description before the allOf node.
			insertDescription(schema, enrich.Description)
		}
		return schema
	}

	if enrich.Description != "" {
		schema.set("description", enrich.Description)
	}
	if len(enrich.Enum) > 0 {
		enumSeq := newSeq()
		for _, v := range enrich.Enum {
			enumSeq.add(v)
		}
		schema.set("enum", enumSeq)
	}
	if enrich.Format != "" {
		schema.set("format", enrich.Format)
	}
	if enrich.Default != nil {
		schema.set("default", enrich.Default)
	}
	if enrich.Minimum != nil {
		schema.set("minimum", *enrich.Minimum)
	}
	if enrich.Maximum != nil {
		schema.set("maximum", *enrich.Maximum)
	}

	// Special case: codecs items enum.
	if parentType == "CreateLegRequest" && jsonName == "codecs" {
		addCodecsItemEnum(schema, api.CodecsItemEnum)
	}

	return schema
}

// isRef checks if a schema node is a $ref.
func isRef(m *omap) bool {
	for i := 0; i < len(m.node.Content)-1; i += 2 {
		if m.node.Content[i].Value == "$ref" {
			return true
		}
	}
	return false
}

// hasAllOf checks if a schema has an allOf key.
func hasAllOf(m *omap) bool {
	for i := 0; i < len(m.node.Content)-1; i += 2 {
		if m.node.Content[i].Value == "allOf" {
			return true
		}
	}
	return false
}

// insertDescription adds a description node right after any existing first node pairs.
func insertDescription(m *omap, desc string) {
	// Prepend description as the first key-value pair.
	descKey := &yaml.Node{Kind: yaml.ScalarNode, Value: "description", Tag: "!!str"}
	descVal := &yaml.Node{Kind: yaml.ScalarNode, Value: desc, Tag: "!!str"}
	m.node.Content = append([]*yaml.Node{descKey, descVal}, m.node.Content...)
}

// addCodecsItemEnum finds the "items" child of an array schema and adds an enum.
func addCodecsItemEnum(schema *omap, enumValues []string) {
	for i := 0; i < len(schema.node.Content)-1; i += 2 {
		if schema.node.Content[i].Value == "items" {
			items := schema.node.Content[i+1]
			enumSeq := newSeq()
			for _, v := range enumValues {
				enumSeq.add(v)
			}
			items.Content = append(items.Content,
				&yaml.Node{Kind: yaml.ScalarNode, Value: "enum", Tag: "!!str"},
				&enumSeq.node)
			return
		}
	}
}

func fieldSchema(f reflect.StructField) *omap {
	ft := f.Type
	isPtr := ft.Kind() == reflect.Ptr
	if isPtr {
		ft = ft.Elem()
	}
	if ft.Kind() == reflect.Struct && ft.Name() != "" {
		registerSchema(ft)
		s := schemaRef(ft.Name())
		if isPtr {
			return newMap().set("nullable", true).set("allOf", newSeq().add(s))
		}
		return s
	}
	return goTypeToSchema(ft)
}

func registerSchema(t reflect.Type) {
	if t.Kind() == reflect.Ptr {
		t = t.Elem()
	}
	name := t.Name()
	if name == "" || schemaRegistry[name] != nil {
		return
	}
	// Placeholder to prevent infinite recursion.
	schemaRegistry[name] = newMap()
	schema := structToSchema(t)
	schemaRegistry[name] = schema
}

func parseJSONTag(f reflect.StructField) (name string, omitempty bool) {
	tag := f.Tag.Get("json")
	if tag == "" || tag == "-" {
		return f.Name, false
	}
	parts := strings.Split(tag, ",")
	name = parts[0]
	if name == "" {
		name = f.Name
	}
	for _, p := range parts[1:] {
		if p == "omitempty" {
			omitempty = true
		}
	}
	return name, omitempty
}

// ── Config vars ─────────────────────────────────────────────────────────

type configVar struct {
	Name        string `yaml:"name"`
	Default     string `yaml:"default"`
	Description string `yaml:"description"`
}

func configVars() *seq {
	vars := []configVar{
		{Name: "INSTANCE_ID", Default: "(auto-generated UUID)", Description: "Instance identifier included in all API responses and webhook events"},
		{Name: "HTTP_ADDR", Default: ":8080", Description: "REST API listen address"},
		{Name: "ALLOWED_IPS", Default: "(empty = allow all)", Description: "Comma-separated allowlist of IPs and CIDR ranges (IPv4 and IPv6) gating every HTTP endpoint, including /v1/vsi, /v1/legs/websocket, /v1/legs/moq, /metrics, and pprof. Bare addresses become /32 (v4) or /128 (v6); malformed entries fail server startup. Only X-Forwarded-For is consulted as a proxy header (see TRUST_PROXY_HEADERS)."},
		{Name: "TRUST_PROXY_HEADERS", Default: "false", Description: "When true, the client IP used for the ALLOWED_IPS check is taken from the leftmost X-Forwarded-For entry (falling back to the socket peer if absent). Enable only behind a trusted reverse proxy that overwrites the header — otherwise it is client-spoofable."},
		{Name: "SIP_BIND_IP", Default: "127.0.0.1", Description: "IPv4 address advertised in SDP, Contact, and Via headers (and used as the listen address when SIP_LISTEN_IP is empty)"},
		{Name: "SIP_LISTEN_IP", Default: "(same as SIP_BIND_IP)", Description: "UDP socket bind IP. Accepts 127.0.0.1, 0.0.0.0, ::, or any literal v4/v6 address"},
		{Name: "SIP_BIND_IPV6", Default: "(empty = v4-only)", Description: "IPv6 address advertised in SDP/Contact/Via for IPv6 calls. Set this for IPv6-only or dual-stack deployments"},
		{Name: "SIP_LISTEN_IPV6", Default: "(same as SIP_BIND_IPV6)", Description: "Optional separate IPv6 socket bind address (used when running with both 0.0.0.0 and a specific v6 literal)"},
		{Name: "SIP_EXTERNAL_IP", Default: "", Description: "Public IPv4 address for NAT/Docker deployments. When set, used in SIP Contact headers and SDP media (c=) lines instead of the bind IP. IPv6 has no equivalent — set SIP_BIND_IPV6 to the address you want advertised."},
		{Name: "SIP_PORT", Default: "5060", Description: "SIP listen port"},
		{Name: "SIP_TLS_PORT", Default: "(disabled)", Description: "SIP-over-TLS listen port (typically 5061). When set, SIP_TLS_CERT and SIP_TLS_KEY must also be provided. Required for WhatsApp Business Calling integration."},
		{Name: "SIP_TLS_CERT", Default: "", Description: "Path to PEM-encoded TLS certificate (e.g. fullchain.pem). Meta rejects self-signed certs — use a CA-signed cert matching a public FQDN."},
		{Name: "SIP_TLS_KEY", Default: "", Description: "Path to PEM-encoded TLS private key (e.g. privkey.pem)."},
		{Name: "SIP_DEBUG", Default: "false", Description: "When true, log the full RFC 3261 wire form of every inbound and outbound SIP request and response. Very verbose — use only for troubleshooting."},
		{Name: "SIP_DOMAIN", Default: "(falls back to advertised IP)", Description: "FQDN advertised in From, Contact and Via on all outbound SIP signalling (classic trunks and WhatsApp). Should match the SAN on SIP_TLS_CERT and any allowlist your carrier or Meta keeps."},
		{Name: "SIP_HOST", Default: "voiceblender", Description: "SIP User-Agent name"},
		{Name: "SIP_CODECS", Default: "PCMU,PCMA", Description: "Comma-separated, preference-ordered list of codecs the SIP engine offers on outbound INVITEs and accepts on inbound INVITEs. Recognized names (case-insensitive): PCMU, PCMA, G722, opus, AMR-WB, AMR-NB (bare token AMR resolves to AMR-NB per RFC 4867 §8.1). Unknown names and duplicates are dropped silently."},
		{Name: "SIP_AUTO_RINGING", Default: "false", Description: "When true, the server sends 180 Ringing automatically after 100 Trying. Default sends only 100 Trying; the API caller drives ringing via /ring, /early-media, or /answer."},
		{Name: "SIP_USE_SOURCE_SOCKET", Default: "false", Description: "When true, route SIP responses and in-dialog requests (BYE, re-INVITE, UPDATE, INFO, NOTIFY, REFER) back to the request's source UDP socket instead of the peer's Contact / Via sent-by. Enable when peers advertise unroutable addresses (e.g. private IPs in Contact from behind NAT)."},
		{Name: "SIP_REGISTRATION_DEFAULT_EXPIRES_SECONDS", Default: "3600", Description: "Expiry used when an inbound REGISTER carries no Expires value."},
		{Name: "SIP_REGISTRATION_MAX_EXPIRES_SECONDS", Default: "7200", Description: "Upper clamp on the granted REGISTER expiry. Requests above this value are honored at this maximum."},
		{Name: "SIP_REGISTRATION_SWEEP_INTERVAL_MS", Default: "1000", Description: "Sweeper period (ms) for evicting expired AOR bindings."},
		{Name: "SIP_REGISTRATION_ALLOW_MULTIPLE_CONTACTS", Default: "true", Description: "When true, the same AOR may be bound from multiple Contacts simultaneously (and POST /v1/legs parallel-forks to every bound contact). When false, each REGISTER replaces any prior Contacts for the AOR."},
		{Name: "ICE_SERVERS", Default: "stun:stun.l.google.com:19302", Description: "STUN/TURN URLs for WebRTC ICE, comma-separated"},
		{Name: "WEBRTC_EXTERNAL_IPS", Default: "(empty)", Description: "Comma-separated public IPs advertised as host ICE candidates (pion SetNAT1To1IPs). Required when VB runs behind NAT/Docker so peers behind firewalls can reach it; supports IPv4 and IPv6 literals. The literal value \"auto\" triggers STUN-based public-IP discovery at startup using the configured ICE_SERVERS; failure is non-fatal."},
		{Name: "RTP_PORT_MIN", Default: "10000", Description: "Minimum UDP port for RTP/RTCP media"},
		{Name: "RTP_PORT_MAX", Default: "20000", Description: "Maximum UDP port for RTP/RTCP media"},
		{Name: "DEFAULT_SAMPLE_RATE", Default: "16000", Description: "Default mixer sample rate (Hz) for new rooms when sample_rate is not specified. Allowed: 8000, 16000, 48000."},
		{Name: "SPEECH_DETECTION_ENABLED", Default: "false", Description: "Emit speaking.started / speaking.stopped events for every connected leg by default. Per-call speech_detection on POST /v1/legs or POST /v1/legs/{id}/answer overrides this."},
		{Name: "VSI_EVENT_BUFFER_SIZE", Default: "256", Description: "Per-client buffer (in events) on the /v1/vsi WebSocket. When the client falls behind, new events are dropped and the next delivered event carries an events_dropped notification. Clamped to [16, 1000000]. Memory: ~1 KB × buffer per connection at the default."},
		{Name: "AMRWB_MODE", Default: "2", Description: "AMR-WB (G.722.2) encoder speech-mode ceiling 0..8: 0=6.60, 1=8.85, 2=12.65, 3=14.25, 4=15.85, 5=18.25, 6=19.85, 7=23.05, 8=23.85 kbit/s. The actual transmit mode is this ceiling clamped to the peer's negotiated mode-set. Default 2 matches GSMA IR.92 / VoLTE."},
		{Name: "AMRWB_OCTET_ALIGNED", Default: "true", Description: "Offer octet-aligned AMR-WB framing (RFC 4867) in outbound SDP. When false, offers bandwidth-efficient framing. On answers, VoiceBlender always echoes the framing the peer negotiated."},
		{Name: "AMRNB_MODE", Default: "7", Description: "AMR-NB (RFC 4867) encoder speech-mode ceiling 0..7: 0=4.75, 1=5.15, 2=5.90, 3=6.70, 4=7.40, 5=7.95, 6=10.2, 7=12.2 kbit/s. The actual transmit mode is this ceiling clamped to the peer's negotiated mode-set. Default 7 is GSM-EFR-equivalent 12.2 kbit/s."},
		{Name: "AMRNB_OCTET_ALIGNED", Default: "true", Description: "Offer octet-aligned AMR-NB framing (RFC 4867) in outbound SDP. When false, offers bandwidth-efficient framing. On answers, VoiceBlender always echoes the framing the peer negotiated."},
		{Name: "RECORDING_DIR", Default: "/tmp/recordings", Description: "Local directory for recording output files"},
		{Name: "LOG_LEVEL", Default: "info", Description: "Log verbosity: debug, info, warn, error"},
		{Name: "WEBHOOK_URL", Default: "", Description: "Global webhook URL for event delivery (fallback when no per-leg or per-room webhook is set)"},
		{Name: "WEBHOOK_SECRET", Default: "", Description: "HMAC-SHA256 signing secret for the global webhook"},
		{Name: "ELEVENLABS_API_KEY", Default: "", Description: "API key for ElevenLabs TTS, STT, and Agent provider"},
		{Name: "VAPI_API_KEY", Default: "", Description: "API key for VAPI Agent provider"},
		{Name: "DEEPGRAM_API_KEY", Default: "", Description: "API key for Deepgram STT and TTS"},
		{Name: "AZURE_SPEECH_KEY", Default: "", Description: "Subscription key for Azure Cognitive Speech Services (TTS and STT)"},
		{Name: "AZURE_SPEECH_REGION", Default: "eastus", Description: "Azure region for Speech Services (e.g. eastus, westeurope)"},
		{Name: "S3_BUCKET", Default: "", Description: "S3 bucket name for recording uploads"},
		{Name: "S3_REGION", Default: "us-east-1", Description: "AWS region for S3"},
		{Name: "S3_ENDPOINT", Default: "", Description: "Custom S3-compatible endpoint (e.g. MinIO)"},
		{Name: "S3_PREFIX", Default: "", Description: "Key prefix applied to all S3 objects"},
		{Name: "AWS_ACCESS_KEY_ID", Default: "", Description: "[SDK-resolved, not read by VoiceBlender] AWS access key for S3 uploads and AWS Polly TTS. Consumed by the AWS SDK default credential chain alongside AWS_SECRET_ACCESS_KEY and the optional AWS_SESSION_TOKEN."},
		{Name: "AWS_SECRET_ACCESS_KEY", Default: "", Description: "[SDK-resolved, not read by VoiceBlender] AWS secret key paired with AWS_ACCESS_KEY_ID."},
		{Name: "AWS_SESSION_TOKEN", Default: "", Description: "[SDK-resolved, not read by VoiceBlender] Optional temporary-credential session token (STS / SSO) used together with AWS_ACCESS_KEY_ID/SECRET."},
		{Name: "AWS_PROFILE", Default: "", Description: "[SDK-resolved, not read by VoiceBlender] Profile name in ~/.aws/credentials to use instead of static AWS_* env vars."},
		{Name: "AWS_REGION", Default: "", Description: "[SDK-resolved, not read by VoiceBlender] AWS region used by S3 and Polly when S3_REGION is empty."},
		{Name: "GOOGLE_APPLICATION_CREDENTIALS", Default: "", Description: "[SDK-resolved, not read by VoiceBlender] Path to a Google Cloud service-account JSON file used by Google Cloud TTS when no per-request api_key is supplied. Consumed by Google's Application Default Credentials chain."},
		{Name: "TTS_CACHE_ENABLED", Default: "false", Description: "Enable disk-backed TTS audio cache; cached audio persists across restarts"},
		{Name: "TTS_CACHE_DIR", Default: "/tmp/tts_cache", Description: "Directory for cached TTS audio files (used when TTS_CACHE_ENABLED=true)"},
		{Name: "TTS_CACHE_INCLUDE_API_KEY", Default: "false", Description: "Include API key in TTS cache key; set true if different keys map to different voice clones"},
		{Name: "SIP_JITTER_BUFFER_MS", Default: "0", Description: "SIP ingress jitter buffer target delay in ms (0 = disabled passthrough). Applies to every SIP leg."},
		{Name: "SIP_JITTER_BUFFER_MAX_MS", Default: "300", Description: "Maximum depth of the SIP ingress jitter buffer in ms. Frames beyond this are dropped oldest-first to catch up after a stall."},
		{Name: "SIP_REFER_AUTO_DIAL", Default: "false", Description: "When true, accept incoming SIP REFER requests and automatically originate the transferred call. Default-deny: stays off unless the SIP edge is locked down (IP allow-lists, digest auth) because auto-dialing arbitrary Refer-To URIs is a classic toll-fraud vector. Outbound transfers initiated via the REST API are unaffected by this flag."},
		{Name: "MOQ_ENABLED", Default: "false", Description: "Enable the experimental MoQ (Media over QUIC) inbound leg endpoint at CONNECT /v1/legs/moq over WebTransport/HTTP/3. PoC quality, tracks IETF draft-11. When enabled, both MOQ_TLS_CERT_FILE and MOQ_TLS_KEY_FILE must be set."},
		{Name: "MOQ_LISTEN_ADDR", Default: ":8443", Description: "UDP address for the HTTP/3 listener that backs the MoQ leg. Independent of HTTP_ADDR — TCP/:8080 and UDP/:8443 can run side-by-side."},
		{Name: "MOQ_TLS_CERT_FILE", Default: "", Description: "Path to the TLS certificate used by the HTTP/3 listener. Required when MOQ_ENABLED=true."},
		{Name: "MOQ_TLS_KEY_FILE", Default: "", Description: "Path to the TLS private key used by the HTTP/3 listener. Required when MOQ_ENABLED=true."},
		{Name: "MOQ_OPUS_BITRATE", Default: "24000", Description: "Target bitrate (bps) for the Opus encoder feeding the MoQ leg's mix track. Must be in 6000..510000."},
		{Name: "LIVEKIT_ENABLED", Default: "false", Description: "Enable the livekit_room leg type at POST /v1/legs (type=livekit_room). Lets VoiceBlender join a LiveKit room as a participant and bridge audio between SIP and LiveKit. Speaks the LiveKit signaling protocol directly via livekit/protocol protobufs over the existing pion stack — no LiveKit SDK is used."},
		{Name: "LIVEKIT_URL", Default: "", Description: "Default LiveKit server endpoint (wss://...). Required when LIVEKIT_ENABLED=true unless every request supplies livekit.url. Overridable per-request."},
		{Name: "LIVEKIT_OPUS_BITRATE", Default: "24000", Description: "Target bitrate (bps) for the Opus encoder publishing audio into LiveKit. Must be in 6000..510000. Overridable per-request via livekit.opus_bitrate."},
		{Name: "LIVEKIT_TOKEN_SIGNING_ENABLED", Default: "false", Description: "Opt-in: when true, callers may omit livekit.token and instead pass {room,identity,permissions}; VoiceBlender mints the JWT itself. Security caveat: enabling this stores the LiveKit API secret (a high-privilege credential) in VoiceBlender. Keep off in multi-tenant deployments."},
		{Name: "LIVEKIT_API_KEY", Default: "", Description: "LiveKit API key used to sign minted JWTs. Required only when LIVEKIT_TOKEN_SIGNING_ENABLED=true."},
		{Name: "LIVEKIT_API_SECRET", Default: "", Description: "LiveKit API secret used to sign minted JWTs. Required only when LIVEKIT_TOKEN_SIGNING_ENABLED=true. Treat as a high-value secret; redact in logs."},
		{Name: "LIVEKIT_DEFAULT_TOKEN_TTL", Default: "6h", Description: "Default TTL applied to minted JWTs when the request omits livekit.token_ttl. Go duration string. LiveKit recommends ≤ 6 hours."},
	}
	s := newSeq()
	for _, v := range vars {
		s.add(newMap().set("name", v.Name).set("default", v.Default).set("description", v.Description))
	}
	return s
}

// ── Path generation ─────────────────────────────────────────────────────

func buildPaths(routes []api.RouteMeta) *omap {
	paths := newMap()

	// Group routes by path to support multiple methods on the same path.
	type pathEntry struct {
		path    string
		methods []*api.RouteMeta
	}
	pathOrder := []string{}
	pathMap := map[string]*[]*api.RouteMeta{}
	for i := range routes {
		r := &routes[i]
		if pathMap[r.Path] == nil {
			pathOrder = append(pathOrder, r.Path)
			methods := []*api.RouteMeta{}
			pathMap[r.Path] = &methods
		}
		*pathMap[r.Path] = append(*pathMap[r.Path], r)
	}

	for _, path := range pathOrder {
		methods := *pathMap[path]
		pathItem := newMap()

		// Add shared path-level parameters.
		params := extractPathParams(path)
		if len(params) > 0 {
			paramSeq := newSeq()
			for _, p := range params {
				paramSeq.add(paramRefForPath(p, path))
			}
			pathItem.set("parameters", paramSeq)
		}

		for _, r := range methods {
			op := buildOperation(r)
			key, actual := openapiMethodKey(r.Method)
			if actual != "" {
				op.set("x-actual-method", actual)
			}
			pathItem.set(key, op)
		}

		paths.set(path, pathItem)
	}

	return paths
}

func paramRefForPath(name, path string) *omap {
	switch name {
	case "id":
		if strings.HasPrefix(path, "/rooms") {
			return newMap().set("$ref", "#/components/parameters/RoomId")
		}
		return newMap().set("$ref", "#/components/parameters/LegId")
	case "playbackID":
		return newMap().set("$ref", "#/components/parameters/PlaybackId")
	case "legID":
		return newMap().set("name", "legID").set("in", "path").set("required", true).
			set("schema", newMap().set("type", "string")).set("description", "Leg ID")
	}
	return newMap().set("name", name).set("in", "path").set("required", true).
		set("schema", newMap().set("type", "string"))
}

func extractPathParams(path string) []string {
	var params []string
	for _, seg := range strings.Split(path, "/") {
		if strings.HasPrefix(seg, "{") && strings.HasSuffix(seg, "}") {
			params = append(params, seg[1:len(seg)-1])
		}
	}
	return params
}

// openapiMethodKey maps a chi HTTP method to the path-item key emitted in
// openapi.yaml. OpenAPI 3.1 only defines get/put/post/delete/options/head/
// patch/trace. Non-standard methods (notably CONNECT, used by the MoQ
// WebTransport endpoint) are emitted under "post" with an x-actual-method
// extension so downstream tooling (the website docs generator,
// Swagger UI, Redoc) still picks them up. Returns (key, actualMethod) —
// actualMethod is non-empty only when a translation occurred.
func openapiMethodKey(method string) (string, string) {
	lower := strings.ToLower(method)
	switch lower {
	case "get", "put", "post", "delete", "options", "head", "patch", "trace":
		return lower, ""
	default:
		return "post", strings.ToUpper(method)
	}
}

func buildOperation(r *api.RouteMeta) *omap {
	op := newMap()
	op.set("operationId", r.OperationID)
	op.set("summary", r.Summary)
	if r.Description != "" {
		op.set("description", foldDescription(r.Description))
	}
	if len(r.Tags) > 0 {
		tags := newSeq()
		for _, t := range r.Tags {
			tags.add(t)
		}
		op.set("tags", tags)
	}

	// Request body.
	if r.RequestType != nil {
		rt := reflect.TypeOf(r.RequestType)
		if rt.Kind() == reflect.Ptr {
			rt = rt.Elem()
		}
		registerSchema(rt)
		reqBody := newMap()
		if !r.OptionalBody {
			reqBody.set("required", true)
		}
		reqBody.set("content", newMap().set("application/json",
			newMap().set("schema", schemaRef(rt.Name()))))
		op.set("requestBody", reqBody)
	}

	// Responses.
	responses := newMap()
	codes := sortedCodes(r.Responses)
	for _, code := range codes {
		resp := r.Responses[code]
		respObj := newMap().set("description", resp.Description)
		if resp.Type != nil {
			rt := reflect.TypeOf(resp.Type)
			var schemaNode *omap
			if rt.Kind() == reflect.Slice {
				elem := rt.Elem()
				if elem.Kind() == reflect.Struct && elem.Name() != "" {
					registerSchema(elem)
					schemaNode = newMap().set("type", "array").set("items", schemaRef(elem.Name()))
				}
			} else if rt.Kind() == reflect.Struct {
				registerSchema(rt)
				schemaNode = schemaRef(rt.Name())
			}
			if schemaNode != nil {
				respObj.set("content", newMap().set("application/json",
					newMap().set("schema", schemaNode)))
			}
		} else if resp.NoBody {
			// Caller explicitly opted out of a body (e.g. protocol upgrade).
		} else if code >= 400 {
			// Error responses.
			respObj.set("content", newMap().set("application/json",
				newMap().set("schema", schemaRef("Error"))))
		} else if code == 200 || code == 201 {
			// Status responses for endpoints without a typed response.
			respObj.set("content", newMap().set("application/json",
				newMap().set("schema", schemaRef("StatusResponse"))))
		}
		responses.setQuotedKey(fmt.Sprintf("%d", code), respObj)
	}
	op.set("responses", responses)

	return op
}

func foldDescription(s string) *yaml.Node {
	return &yaml.Node{
		Kind:  yaml.ScalarNode,
		Value: s,
		Tag:   "!!str",
		Style: yaml.FlowStyle,
	}
}

func sortedCodes(m map[int]api.ResponseMeta) []int {
	codes := make([]int, 0, len(m))
	for c := range m {
		codes = append(codes, c)
	}
	sort.Ints(codes)
	return codes
}

// ── Webhook generation ──────────────────────────────────────────────────

// allWebhookEvents is a thin alias for api.EventsMetadata() so the rest of
// this generator can keep its local types. The single source of truth for
// the event list lives in internal/api/vsi_meta.go and is shared with
// cmd/asyncapi-gen.
func allWebhookEvents() []api.EventMeta {
	return api.EventsMetadata()
}

func buildWebhookEventType() *omap {
	s := newMap().set("type", "string")
	enumSeq := newSeq()
	for _, wh := range allWebhookEvents() {
		enumSeq.add(string(wh.Type))
	}
	s.set("enum", enumSeq)
	return s
}

func buildWebhooks() *omap {
	webhooks := newMap()
	for _, wh := range allWebhookEvents() {
		evtType := string(wh.Type)
		// Build inline properties from the data struct (flattened, including embedded).
		inlineProps := newMap()
		flattenDataFields(wh.DataType, inlineProps, evtType)

		allOfSeq := newSeq()
		allOfSeq.add(schemaRef("WebhookEvent"))
		if len(inlineProps.node.Content) > 0 {
			allOfSeq.add(newMap().set("properties", inlineProps))
		}

		webhooks.set(evtType, newMap().set("post",
			newMap().set("summary", wh.Summary).
				set("requestBody", newMap().set("content",
					newMap().set("application/json",
						newMap().set("schema", newMap().set("allOf", allOfSeq)))))))
	}
	return webhooks
}

// flattenDataFields extracts all JSON-tagged fields from a struct type,
// recursing into embedded structs, and adds them to the omap as simple
// type definitions (for the inline webhook schema). evtType is the event
// type string (e.g. "leg.ringing") used to look up field descriptions.
func flattenDataFields(t reflect.Type, props *omap, evtType string) {
	if t.Kind() == reflect.Ptr {
		t = t.Elem()
	}
	for i := 0; i < t.NumField(); i++ {
		f := t.Field(i)
		if !f.IsExported() {
			continue
		}
		if f.Anonymous {
			flattenDataFields(f.Type, props, evtType)
			continue
		}
		jsonName, _ := parseJSONTag(f)
		if jsonName == "-" {
			continue
		}
		ft := f.Type
		if ft.Kind() == reflect.Ptr {
			ft = ft.Elem()
		}
		switch ft.Kind() {
		case reflect.Struct:
			// For nested structs in webhook data (like cdr, quality),
			// generate inline schema.
			schema := webhookNestedSchema(ft, f.Type.Kind() == reflect.Ptr, jsonName)
			// Add top-level description for special fields.
			if jsonName == "quality" {
				insertDescription(schema, api.QualityDescription)
			}
			props.set(jsonName, schema)
		default:
			schema := goTypeToSchema(ft)
			// Apply webhook field description if available.
			descKey := evtType + "." + jsonName
			if desc, ok := webhookFieldDescs[descKey]; ok {
				schema.set("description", desc)
			}
			props.set(jsonName, schema)
		}
	}
}

// webhookNestedSchema builds an inline object schema for nested structs
// in webhook event data (e.g. cdr, quality).
// parentFieldName is the JSON name of the parent field (e.g. "cdr").
func webhookNestedSchema(t reflect.Type, nullable bool, parentFieldName string) *omap {
	schema := newMap().set("type", "object")
	if nullable {
		schema.set("nullable", true)
	}
	requiredSeq := newSeq()
	hasRequired := false
	props := newMap()
	typeName := t.Name()
	for i := 0; i < t.NumField(); i++ {
		f := t.Field(i)
		if !f.IsExported() {
			continue
		}
		jsonName, omit := parseJSONTag(f)
		if jsonName == "-" {
			continue
		}
		fieldSchema := goTypeToSchema(f.Type)
		// Apply nested field description if available.
		descKey := typeName + "." + jsonName
		if desc, ok := webhookNestedFieldDescs[descKey]; ok {
			fieldSchema.set("description", desc)
		}
		// Apply disconnect reason enum.
		if typeName == "CallCDR" && jsonName == "reason" {
			enumSeq := newSeq()
			for _, v := range api.DisconnectReasonEnum {
				enumSeq.add(v)
			}
			fieldSchema.set("enum", enumSeq)
		}
		props.set(jsonName, fieldSchema)
		if !omit {
			requiredSeq.add(jsonName)
			hasRequired = true
		}
	}
	schema.set("properties", props)
	if hasRequired {
		schema.set("required", requiredSeq)
	}
	return schema
}

// ── Main ────────────────────────────────────────────────────────────────

func main() {
	// Load enrichment data from api package.
	schemaEnrichments = api.SchemaEnrichments()
	webhookFieldDescs = api.WebhookFieldDescriptions()
	webhookNestedFieldDescs = api.WebhookNestedFieldDescriptions()

	routes := api.RoutesMetadata()

	// Pre-register core schemas that aren't derived from route types.
	schemaRegistry["Error"] = newMap().set("type", "object").
		set("properties", newMap().
			set("instance_id", newMap().set("type", "string").set("description", "Instance identifier")).
			set("error", newMap().set("type", "string").set("description", "Error message"))).
		set("required", newSeq().add("error"))

	schemaRegistry["StatusResponse"] = newMap().set("type", "object").
		set("properties", newMap().
			set("instance_id", newMap().set("type", "string").set("description", "Instance identifier")).
			set("status", newMap().set("type", "string"))).
		set("required", newSeq().add("status"))

	schemaRegistry["WebhookEvent"] = newMap().set("type", "object").
		set("description", "Event envelope delivered via HTTP POST to registered webhook URLs. "+
			"Event-specific fields are flattened into the top-level object (no \"data\" wrapper). "+
			"Includes X-Signature-256 header when a secret is configured.").
		set("properties", newMap().
			set("type", schemaRef("WebhookEventType")).
			set("timestamp", newMap().set("type", "string").set("format", "date-time")).
			set("instance_id", newMap().set("type", "string").set("description", "Instance identifier"))).
		set("required", newSeq().add("type").add("timestamp"))

	schemaRegistry["WebhookEventType"] = buildWebhookEventType()

	schemaRegistry["ICECandidateInit"] = newMap().set("type", "object").
		set("properties", newMap().
			set("candidate", newMap().set("type", "string").set("description", "ICE candidate string")).
			set("sdpMid", newMap().set("type", "string").set("description", "Media stream identification tag")).
			set("sdpMLineIndex", newMap().set("type", "integer").set("description", "Index of the media description"))).
		set("required", newSeq().add("candidate"))

	// Build paths — this triggers schema registration for request/response types.
	pathsNode := buildPaths(routes)

	// Add observability paths (static, not driven by route metadata).
	addObservabilityPaths(pathsNode)

	// Build the top-level document.
	doc := newMap()
	doc.set("openapi", "3.1.0")

	// Info.
	info := newMap()
	info.set("title", "VoiceBlender API")
	info.set("description", "VoiceBlender bridges SIP and WebRTC voice calls with multi-party audio "+
		"mixing, real-time speech-to-text, text-to-speech, AI agent integration, "+
		"recording, and webhook-based event delivery.\n")
	info.set("x-config-vars", configVars())
	info.set("version", "1.0.0")
	info.set("license", newMap().set("name", "MIT"))
	doc.set("info", info)

	// Servers.
	servers := newSeq().add(newMap().set("url", "http://localhost:8080/v1").set("description", "Local development server"))
	doc.set("servers", servers)

	// Tags.
	tags := newSeq().
		add(newMap().set("name", "Legs").set("description", "Voice call legs (SIP or WebRTC)")).
		add(newMap().set("name", "Rooms").set("description", "Multi-party audio conference rooms")).
		add(newMap().set("name", "WebRTC").set("description", "WebRTC peer connection establishment")).
		add(newMap().set("name", "Observability").set("description", "Metrics and health endpoints"))
	doc.set("tags", tags)

	// Paths.
	doc.set("paths", pathsNode)

	// Components.
	components := newMap()
	components.set("parameters", buildParameters())
	components.set("responses", buildResponses())
	components.set("schemas", buildSchemas())
	doc.set("components", components)

	// Webhooks.
	doc.set("x-webhooks", buildWebhooks())

	// Write output.
	out, err := yaml.Marshal(&doc.node)
	if err != nil {
		fmt.Fprintf(os.Stderr, "yaml marshal: %v\n", err)
		os.Exit(1)
	}

	outPath := "openapi.yaml"
	// When run via go generate from internal/api/, write to repo root.
	if _, err := os.Stat("../../openapi.yaml"); err == nil {
		outPath = "../../openapi.yaml"
	} else if _, err := os.Stat("openapi.yaml"); err != nil {
		// Try repo root relative to where we are.
		outPath = "../../openapi.yaml"
	}

	if err := os.WriteFile(outPath, out, 0644); err != nil {
		fmt.Fprintf(os.Stderr, "write %s: %v\n", outPath, err)
		os.Exit(1)
	}
	fmt.Printf("Generated %s (%d bytes)\n", outPath, len(out))
}

func buildParameters() *omap {
	return newMap().
		set("LegId", newMap().set("name", "id").set("in", "path").set("required", true).
			set("schema", newMap().set("type", "string")).set("description", "Leg ID")).
		set("RoomId", newMap().set("name", "id").set("in", "path").set("required", true).
			set("schema", newMap().set("type", "string")).set("description", "Room ID")).
		set("PlaybackId", newMap().set("name", "playbackID").set("in", "path").set("required", true).
			set("schema", newMap().set("type", "string")).set("description", "Playback ID"))
}

func buildResponses() *omap {
	return newMap().
		set("LegNotFound", newMap().set("description", "Leg not found").
			set("content", newMap().set("application/json",
				newMap().set("schema", schemaRef("Error"))))).
		set("RoomNotFound", newMap().set("description", "Room not found").
			set("content", newMap().set("application/json",
				newMap().set("schema", schemaRef("Error")))))
}

func buildSchemas() *omap {
	schemas := newMap()

	// Emit schemas in a deterministic order.
	// Core resources first, then requests, then responses, then webhook types.
	order := []string{
		"LegView", "RoomView", "Error", "StatusResponse",
	}

	// Collect request types.
	requestTypes := []string{}
	responseTypes := []string{}
	webhookTypes := []string{"WebhookEvent", "WebhookEventType", "ICECandidateInit"}

	for name := range schemaRegistry {
		found := false
		for _, o := range order {
			if name == o {
				found = true
				break
			}
		}
		for _, o := range webhookTypes {
			if name == o {
				found = true
				break
			}
		}
		if found {
			continue
		}
		if strings.HasSuffix(name, "Request") {
			requestTypes = append(requestTypes, name)
		} else {
			responseTypes = append(responseTypes, name)
		}
	}
	sort.Strings(requestTypes)
	sort.Strings(responseTypes)

	order = append(order, requestTypes...)
	order = append(order, responseTypes...)
	order = append(order, webhookTypes...)

	for _, name := range order {
		schema, ok := schemaRegistry[name]
		if !ok {
			continue
		}

		// Rename schema names to match existing OpenAPI spec naming conventions.
		displayName := schemaDisplayName(name)
		schemas.set(displayName, schema)
	}

	return schemas
}

// schemaDisplayName maps Go type names to OpenAPI schema names matching
// the existing spec naming conventions.
func schemaDisplayName(goName string) string {
	nameMap := map[string]string{
		"LegView":                "Leg",
		"RoomView":               "Room",
		"CreateLegRequest":       "CreateLegRequest",
		"SIPAuth":                "SIPAuth",
		"CreateRoomRequest":      "RoomCreateRequest",
		"AddLegRequest":          "AddLegRequest",
		"PlaybackRequest":        "PlaybackRequest",
		"VolumeRequest":          "VolumeRequest",
		"DTMFRequest":            "DTMFRequest",
		"TTSRequest":             "TTSRequest",
		"STTRequest":             "STTRequest",
		"RecordRequest":          "RecordingRequest",
		"ElevenLabsAgentRequest": "ElevenLabsAgentRequest",
		"VAPIAgentRequest":       "VAPIAgentRequest",
		"PipecatAgentRequest":    "PipecatAgentRequest",
		"DeepgramAgentRequest":   "DeepgramAgentRequest",
		"AgentMessageRequest":    "AgentMessageRequest",
		"WebRTCOfferRequest":     "WebRTCOfferRequest",
	}
	if display, ok := nameMap[goName]; ok {
		return display
	}
	return goName
}

func addObservabilityPaths(paths *omap) {
	// /metrics (outside /v1 prefix)
	paths.set("/metrics", newMap().set("get",
		newMap().set("operationId", "getMetrics").
			set("summary", "Prometheus metrics").
			set("description", "Returns Prometheus-format metrics (text/plain exposition format). "+
				"Includes VoiceBlender-specific metrics and standard Go runtime metrics.\n").
			set("tags", newSeq().add("Observability")).
			set("responses", newMap().setQuotedKey("200",
				newMap().set("description", "Prometheus text exposition format").
					set("content", newMap().set("text/plain",
						newMap().set("schema", newMap().set("type", "string"))))))))

	// /debug/pprof/ endpoints
	paths.set("/debug/pprof/", newMap().set("get",
		newMap().set("operationId", "pprofIndex").
			set("summary", "pprof index").
			set("description", "Index of available Go runtime profiles. Only available when built with `-tags pprof` (e.g. `go build -tags pprof ./...`).\n").
			set("tags", newSeq().add("Observability")).
			set("responses", newMap().setQuotedKey("200",
				newMap().set("description", "HTML index page listing available profiles").
					set("content", newMap().set("text/html",
						newMap().set("schema", newMap().set("type", "string"))))))))

	paths.set("/debug/pprof/profile", newMap().set("get",
		newMap().set("operationId", "pprofCPU").
			set("summary", "CPU profile").
			set("description", "30-second CPU profile (duration configurable via ?seconds= query param). Only available when built with `-tags pprof`.\n").
			set("tags", newSeq().add("Observability")).
			set("parameters", newSeq().add(
				newMap().set("name", "seconds").set("in", "query").
					set("schema", newMap().set("type", "integer").set("default", 30)).
					set("description", "Profile duration in seconds"))).
			set("responses", newMap().setQuotedKey("200",
				newMap().set("description", "pprof binary profile").
					set("content", newMap().set("application/octet-stream",
						newMap().set("schema", newMap().set("type", "string").set("format", "binary"))))))))

	paths.set("/debug/pprof/heap", newMap().set("get",
		newMap().set("operationId", "pprofHeap").
			set("summary", "Heap memory profile").
			set("description", "Heap memory snapshot. Only available when built with `-tags pprof`.\n").
			set("tags", newSeq().add("Observability")).
			set("responses", newMap().setQuotedKey("200",
				newMap().set("description", "pprof binary profile").
					set("content", newMap().set("application/octet-stream",
						newMap().set("schema", newMap().set("type", "string").set("format", "binary"))))))))

	paths.set("/debug/pprof/goroutine", newMap().set("get",
		newMap().set("operationId", "pprofGoroutine").
			set("summary", "Goroutine stack traces").
			set("description", "All goroutine stack traces. Only available when built with `-tags pprof`.\n").
			set("tags", newSeq().add("Observability")).
			set("responses", newMap().setQuotedKey("200",
				newMap().set("description", "pprof binary profile").
					set("content", newMap().set("application/octet-stream",
						newMap().set("schema", newMap().set("type", "string").set("format", "binary"))))))))
}
