Ctrl K
ComunidadGuías…

internal/util/gemini_schema.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Package util provides utility functions for the CLI Proxy API server.
package util

import (
	"bytes"
	"encoding/json"
	"fmt"
	"reflect"
	"sort"
	"strconv"
	"strings"

	"github.com/MoeForge/moego/ojson"
)

var gjsonPathKeyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?")

const placeholderReasonDescription = "Brief explanation of why you are calling this tool"

// Pass a single JSON schema to the functions below — never a whole request document.
//
// Cleaning walks every node and rewrites keys by name, and schema keywords such as "title",
// "format", "default" and "const" are also ordinary data keys. Handing these functions a request
// silently rewrites tool-call arguments inside the conversation history: the guard that protects
// a key under ".properties" does not apply to argument values, so the keys are deleted outright
// and replacements such as "enum" and "type" are fabricated. That regression reached production
// once already; scope every call site to the schema itself.

type jsonSchemaCleanOptions struct {
	addPlaceholder       bool
	addMissingArrayItems bool
	antigravitySemantics bool
	removeToolTitle      bool
	removeGeminiMetadata bool
	flattenUnions        bool
	// flattenMultiBranchUnions forces multi-branch anyOf/oneOf unions down to a single
	// strongest branch instead of preserving them. It is used on routes whose upstream
	// rejects nested multi-branch unions even though they are valid JSON Schema.
	flattenMultiBranchUnions          bool
	forceEnumStringType               bool
	dropAllEnums                      bool
	dropBooleanEnums                  bool
	preserveAdditionalPropertiesFalse bool
	preserveAllAdditionalProperties   bool
	preserveStandardConstraints       bool
}

// CleanJSONSchemaForAntigravity transforms a tool schema to be compatible with Antigravity API.
// It handles unsupported keywords, type flattening, and schema simplification while preserving
// semantic information as description hints and adding placeholders required by VALIDATED mode.
func CleanJSONSchemaForAntigravity(jsonStr string) string {
	return CleanJSONSchemaForAntigravityTool(jsonStr, true)
}

// CleanJSONSchemaForAntigravityTool transforms an Antigravity function schema. The private
// backend accepts enum members only as strings, but the declared type still controls the JSON
// type of generated function arguments, so numeric and boolean types must not be rewritten.
// requirePlaceholder is used only for Claude VALIDATED mode.
func CleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder bool) string {
	return cleanJSONSchemaForAntigravityTool(jsonStr, requirePlaceholder, false)
}

// CleanJSONSchemaForAntigravityClaudeTool is the Antigravity tool cleaner for Claude
// models. It applies the same Antigravity schema cleanup, but flattens multi-branch
// anyOf/oneOf unions to a single strongest branch: the Antigravity Claude backend
// converts Gemini function declarations into Claude custom.input_schema and rejects
// nested multi-branch unions with "JSON schema is invalid", while the Gemini
// backends accept preserved unions (see Issue 3662 and its regression follow-up).
func CleanJSONSchemaForAntigravityClaudeTool(jsonStr string, requirePlaceholder bool) string {
	return cleanJSONSchemaForAntigravityTool(jsonStr, requirePlaceholder, true)
}

func cleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder, flattenMultiBranchUnions bool) string {
	return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{
		addPlaceholder:           requirePlaceholder,
		addMissingArrayItems:     true,
		antigravitySemantics:     true,
		removeToolTitle:          !requirePlaceholder,
		flattenUnions:            true,
		flattenMultiBranchUnions: flattenMultiBranchUnions,
		dropAllEnums:             true,
	})
}

// CleanJSONSchemaForAntigravityResponse transforms a response schema without applying tool-only
// compatibility rewrites that would alter the client's structured output contract.
//
// Sanitization policy:
//   - Passthrough: type, properties, items, required, description, enum, nullable, and
//     additionalProperties: false (which Antigravity natively enforces for response schemas).
//   - Description hints + deletion: unsupported or accepted-but-ignored constraints.
//   - Flattened: allOf merged into properties/required.
//   - Unions: multi-branch anyOf/oneOf unions are preserved; single-type nullable unions ([T, null]) project to T with nullable:true.
//   - Resolved: local $ref targets are inlined before $defs/definitions are removed.
//   - Dropped: unresolved $ref (after a hint), metadata, unsupported object-key constraints,
//     conditional keywords (after non-conflicting properties are retained), and x-* extensions.
func CleanJSONSchemaForAntigravityResponse(jsonStr string) string {
	return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{
		antigravitySemantics:              true,
		flattenUnions:                     true,
		dropBooleanEnums:                  true,
		preserveAdditionalPropertiesFalse: true,
	})
}

// CleanJSONSchemaForGemini transforms a JSON schema to be compatible with Gemini tool calling.
// It removes unsupported keywords and simplifies schemas, without adding empty-schema placeholders.
func CleanJSONSchemaForGemini(jsonStr string) string {
	return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{
		addMissingArrayItems: true,
		removeGeminiMetadata: true,
		flattenUnions:        true,
		forceEnumStringType:  true,
	})
}

// CleanJSONSchemaForGeminiJSONSchema transforms a JSON schema for Gemini tool calling
// when using the parametersJsonSchema carrier. It preserves standard JSON Schema constraints
// (such as pattern, minLength, maxLength) and additionalProperties (both boolean and schema-valued),
// while removing Gemini-incompatible metadata fields and cleaning required properties.
func CleanJSONSchemaForGeminiJSONSchema(jsonStr string) string {
	return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{
		addMissingArrayItems:            true,
		removeGeminiMetadata:            true,
		flattenUnions:                   true,
		forceEnumStringType:             true,
		preserveAllAdditionalProperties: true,
		preserveStandardConstraints:     true,
	})
}

// cleanJSONSchema performs the core cleaning operations on the JSON schema.
func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
	// Phase 0: Normalize malformed schemas (e.g. bare property maps and boolean required from MCP tools).
	// Array placeholders and array type inference are deferred to Phase 3 (sanitizeArrayItems) after
	// local references are inlined and allOf/union structures are resolved, so synthetic placeholders
	// do not clobber items supplied by $ref targets or allOf branches.
	jsonStr = normalizeMalformedSchemaObjects(jsonStr)
	jsonStr = NormalizeJSONSchemaNumericConstraints(jsonStr)

	// Phase 1: Convert and add hints. Every cleaner removes $defs/definitions later, so local
	// references must be expanded here for every route; otherwise nested fields are lost.
	jsonStr = inlineLocalRefsForClean(jsonStr)
	jsonStr = convertRefsToHints(jsonStr, options.antigravitySemantics)
	jsonStr = convertConstToEnum(jsonStr)
	jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType)
	jsonStr = addEnumHints(jsonStr)
	jsonStr = dropIgnoredEnumsToHints(jsonStr, options)
	if !options.preserveAdditionalPropertiesFalse && !options.preserveAllAdditionalProperties {
		jsonStr = addAdditionalPropertiesHints(jsonStr)
	}
	jsonStr = moveConstraintsToDescription(jsonStr, options)
	if options.antigravitySemantics {
		jsonStr = moveNotToDescription(jsonStr)
	}

	// Phase 2: Flatten complex structures. Once a JSON Schema contains a conjunction,
	// local shape assumptions are unsafe in ancestors, siblings, and descendants alike.
	preserveConjunctions := options.preserveStandardConstraints && hasSchemaConjunction(jsonStr)
	jsonStr = mergeConditionals(jsonStr)
	if !options.preserveStandardConstraints {
		// The JSON Schema carrier must keep conjunctions, including ref sibling conflicts.
		jsonStr = mergeAllOf(jsonStr)
	}
	if !preserveConjunctions {
		if options.flattenUnions {
			jsonStr = flattenAnyOfOneOf(jsonStr, options.preserveStandardConstraints, options.flattenMultiBranchUnions)
		}
		jsonStr = flattenTypeArrays(jsonStr, options.antigravitySemantics)
	}

	// Phase 3: Cleanup
	jsonStr = removeUnsupportedKeywords(jsonStr, options)
	if options.removeGeminiMetadata {
		// Gemini schema cleanup: remove nullable/title and placeholder-only fields.
		jsonStr = removeKeywords(jsonStr, []string{"nullable", "title"})
		jsonStr = removePlaceholderFields(jsonStr)
	} else if options.removeToolTitle {
		// Legacy non-VALIDATED Antigravity requests used the Gemini cleaner, which drops title.
		// Keep that harmless metadata policy without losing Antigravity's native nullable support.
		jsonStr = removeKeywords(jsonStr, []string{"title"})
	}
	if !preserveConjunctions {
		jsonStr = cleanupRequiredFields(jsonStr)
		jsonStr = sanitizeObjectProperties(jsonStr)
		jsonStr = sanitizeArrayItems(jsonStr, options.addMissingArrayItems)
	}
	// Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement)
	if options.addPlaceholder {
		jsonStr = addEmptySchemaPlaceholder(jsonStr)
	}

	return jsonStr
}

// sanitizeObjectProperties normalizes native Schema object declarations. The caller skips this
// pass for JSON Schema conjunctions, where local properties do not imply an object type.
func sanitizeObjectProperties(jsonStr string) string {
	paths := findPaths(jsonStr, "properties")
	sortByDepth(paths)
	doc := ojson.NewDocString(jsonStr)
	modified := false
	for _, p := range paths {
		parentPath := trimSuffix(p, ".properties")
		if isPropertyDefinition(parentPath) {
			continue
		}
		props := doc.Get(p)
		if !props.IsObject() {
			continue
		}
		typePath := joinPath(parentPath, "type")
		t := doc.Get(typePath).String()
		if !strings.EqualFold(t, "object") {
			if doc.Set(typePath, "object") == nil {
				modified = true
			}
		}
	}
	if modified {
		return doc.String()
	}
	return jsonStr
}

// sanitizeArrayItems normalizes native Schema arrays and supplies missing items placeholders.
// The caller skips this pass for JSON Schema conjunctions, where element and instance types
// may be constrained elsewhere and synthesizing local fields can make the intersection impossible.
func sanitizeArrayItems(jsonStr string, addMissing bool) string {
	paths := findPaths(jsonStr, "items")
	sortByDepth(paths)
	doc := ojson.NewDocString(jsonStr)
	modified := false
	for _, p := range paths {
		parentPath := trimSuffix(p, ".items")
		if isPropertyDefinition(parentPath) {
			continue
		}
		typePath := joinPath(parentPath, "type")
		t := doc.Get(typePath).String()
		if t == "" {
			if doc.Set(typePath, "array") == nil {
				modified = true
			}
		} else if !strings.EqualFold(t, "array") {
			if doc.Delete(p) == nil {
				modified = true
			}
		}
	}
	if modified {
		jsonStr = doc.String()
	}

	if !addMissing {
		return jsonStr
	}
	typePaths := findPaths(jsonStr, "type")
	sortByDepth(typePaths)
	doc = ojson.NewDocString(jsonStr)
	modified = false
	for _, p := range typePaths {
		parentPath := trimSuffix(p, ".type")
		if isPropertyDefinition(parentPath) || !isArrayDeclaredType(doc.Get(p).Value()) {
			continue
		}
		if doc.Get(joinPath(parentPath, "items")).Exists() || doc.Get(joinPath(parentPath, "$ref")).Exists() {
			continue
		}
		if doc.SetRaw(joinPath(parentPath, "items"), `{"type":"string"}`) == nil {
			modified = true
		}
	}
	if modified {
		return doc.String()
	}
	return jsonStr
}

// removeKeywords removes all occurrences of specified keywords from the JSON schema.
func removeKeywords(jsonStr string, keywords []string) string {
	deletePaths := make([]string, 0)
	pathsByField := findPathsByFields(jsonStr, keywords)
	for _, key := range keywords {
		for _, p := range pathsByField[key] {
			if isPropertyDefinition(trimSuffix(p, "."+key)) {
				continue
			}
			deletePaths = append(deletePaths, p)
		}
	}
	if len(deletePaths) == 0 {
		return jsonStr
	}
	sortByDepth(deletePaths)
	doc := ojson.NewDocString(jsonStr)
	for _, p := range deletePaths {
		_ = doc.Delete(p)
	}
	return doc.String()
}

// removePlaceholderFields removes placeholder-only properties ("_" and "reason") and their required entries.
func removePlaceholderFields(jsonStr string) string {
	// Remove "_" placeholder properties.
	paths := findPaths(jsonStr, "_")
	sortByDepth(paths)
	if len(paths) > 0 {
		doc := ojson.NewDocString(jsonStr)
		modified := false
		for _, p := range paths {
			if !strings.HasSuffix(p, ".properties._") {
				continue
			}
			if doc.Delete(p) == nil {
				modified = true
			}
			parentPath := trimSuffix(p, ".properties._")
			reqPath := joinPath(parentPath, "required")
			req := doc.Get(reqPath)
			if req.IsArray() {
				var filtered []string
				for _, r := range req.Array() {
					if r.String() != "_" {
						filtered = append(filtered, r.String())
					}
				}
				if len(filtered) == 0 {
					if doc.Delete(reqPath) == nil {
						modified = true
					}
				} else {
					if doc.Set(reqPath, filtered) == nil {
						modified = true
					}
				}
			}
		}
		if modified {
			jsonStr = doc.String()
		}
	}

	// Remove placeholder-only "reason" objects.
	reasonPaths := findPaths(jsonStr, "reason")
	sortByDepth(reasonPaths)
	if len(reasonPaths) > 0 {
		doc := ojson.NewDocString(jsonStr)
		modified := false
		for _, p := range reasonPaths {
			if !strings.HasSuffix(p, ".properties.reason") {
				continue
			}
			parentPath := trimSuffix(p, ".properties.reason")
			props := doc.Get(joinPath(parentPath, "properties"))
			if !props.IsObject() || len(props.Map()) != 1 {
				continue
			}
			desc := doc.Get(p + ".description").String()
			if desc != placeholderReasonDescription {
				continue
			}
			if doc.Delete(p) == nil {
				modified = true
			}
			reqPath := joinPath(parentPath, "required")
			req := doc.Get(reqPath)
			if req.IsArray() {
				var filtered []string
				for _, r := range req.Array() {
					if r.String() != "reason" {
						filtered = append(filtered, r.String())
					}
				}
				if len(filtered) == 0 {
					if doc.Delete(reqPath) == nil {
						modified = true
					}
				} else {
					if doc.Set(reqPath, filtered) == nil {
						modified = true
					}
				}
			}
		}
		if modified {
			jsonStr = doc.String()
		}
	}

	return jsonStr
}

// normalizeMalformedSchemaObjects normalizes malformed JSON schema nodes commonly produced by
// certain MCP tool definitions (e.g. Asana MCP server):
// 1. Bare property maps missing the "type": "object" and "properties": {...} wrappers are wrapped.
// 2. Boolean "required": true on property definitions are stripped and promoted to the parent's "required" array.
// 3. Boolean true subschemas are normalized to empty schema objects.
func normalizeMalformedSchemaObjects(jsonStr string) string {
	if jsonStr == "" {
		return jsonStr
	}

	decoder := json.NewDecoder(strings.NewReader(jsonStr))
	decoder.UseNumber()
	var root any
	if err := decoder.Decode(&root); err != nil {
		return jsonStr
	}

	if rootBool, ok := root.(bool); ok && rootBool {
		return "{}"
	}

	rootMap, ok := root.(map[string]any)
	if !ok || isAPIRequestDocument(rootMap) {
		return jsonStr
	}

	// If wrapped in single-key {"schema": ...} by cleanNestedSchema, unwrap, repair, and re-wrap.
	if len(rootMap) == 1 {
		if innerSchema, ok := rootMap["schema"].(map[string]any); ok {
			repairedInner, modified := repairSchemaNode(innerSchema)
			if !modified {
				return jsonStr
			}
			out, err := marshalJSONNoHTMLEscape(map[string]any{"schema": repairedInner})
			if err != nil {
				return jsonStr
			}
			return string(out)
		} else if innerBool, ok := rootMap["schema"].(bool); ok && innerBool {
			out, err := marshalJSONNoHTMLEscape(map[string]any{"schema": map[string]any{}})
			if err != nil {
				return jsonStr
			}
			return string(out)
		}
	}

	repaired, modified := repairSchemaNode(rootMap)
	if !modified {
		return jsonStr
	}

	out, err := marshalJSONNoHTMLEscape(repaired)
	if err != nil {
		return jsonStr
	}
	return string(out)
}

func marshalJSONNoHTMLEscape(v any) ([]byte, error) {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		return nil, err
	}
	b := buf.Bytes()
	if len(b) > 0 && b[len(b)-1] == '\n' {
		b = b[:len(b)-1]
	}
	return b, nil
}

func isKnownSchemaKeywordOrExtension(key string) bool {
	if strings.HasPrefix(key, "x-") {
		return true
	}
	switch key {
	case "properties", "patternProperties", "additionalProperties", "items", "prefixItems",
		"$defs", "definitions", "dependentSchemas", "dependentRequired", "dependencies",
		"if", "then", "else", "not", "contains", "propertyNames",
		"unevaluatedProperties", "unevaluatedItems", "contentSchema", "additionalItems",
		"default", "const", "example", "examples", "discriminator", "xml", "externalDocs",
		"enumDescriptions", "enumTitles":
		return true
	}
	return false
}

func isNonObjectDeclaredType(t any) bool {
	if s, ok := t.(string); ok {
		return s != "" && !strings.EqualFold(s, "object")
	}
	if arr, ok := t.([]any); ok {
		for _, item := range arr {
			if s, ok := item.(string); ok && strings.EqualFold(s, "object") {
				return false
			}
		}
		return len(arr) > 0
	}
	return false
}

func isArrayDeclaredType(t any) bool {
	switch typeValue := t.(type) {
	case string:
		return strings.EqualFold(typeValue, "array")
	case []any:
		for _, item := range typeValue {
			if itemType, ok := item.(string); ok && strings.EqualFold(itemType, "array") {
				return true
			}
		}
	}
	return false
}

func isAPIRequestDocument(m map[string]any) bool {
	if _, ok := m["tools"].([]any); ok {
		return true
	}
	if _, ok := m["contents"].([]any); ok {
		return true
	}
	if _, ok := m["messages"].([]any); ok {
		return true
	}
	if _, ok := m["functionDeclarations"].([]any); ok {
		return true
	}
	if _, ok := m["function_declarations"].([]any); ok {
		return true
	}
	if reqMap, ok := m["request"].(map[string]any); ok {
		if isAPIRequestDocument(reqMap) {
			return true
		}
	}
	return false
}

func repairSchemaNode(node map[string]any) (map[string]any, bool) {
	if node == nil {
		return nil, false
	}

	modified := false
	clone := make(map[string]any, len(node))
	for k, v := range node {
		clone[k] = v
	}

	// 1. If not declared as a primitive/array type, collect bare property definition maps
	if !isNonObjectDeclaredType(clone["type"]) {
		var bareProps map[string]any
		for k, v := range clone {
			if childMap, isMap := v.(map[string]any); isMap {
				if !isKnownSchemaKeywordOrExtension(k) {
					if bareProps == nil {
						bareProps = make(map[string]any)
					}
					bareProps[k] = childMap
				}
			}
		}

		if len(bareProps) > 0 {
			repairedProps, promotedReqs, _ := repairPropertyMap(bareProps)
			for k := range bareProps {
				delete(clone, k)
			}

			if existingProps, ok := clone["properties"].(map[string]any); ok {
				newProps := make(map[string]any, len(existingProps)+len(repairedProps))
				for k, v := range existingProps {
					newProps[k] = v
				}
				for k, v := range repairedProps {
					newProps[k] = v
				}
				clone["properties"] = newProps
			} else {
				clone["properties"] = repairedProps
				if _, hasType := clone["type"]; !hasType {
					clone["type"] = "object"
				}
			}

			if len(promotedReqs) > 0 {
				existingReqs := extractStringArray(clone["required"])
				merged := mergeStringSlices(existingReqs, promotedReqs)
				clone["required"] = merged
			}
			modified = true
		}
	}

	// 2. Repair declared properties without inferring a type before refs are expanded.
	// Final sanitization handles native object and array schemas once refs and conjunctions are resolved.
	if propsVal, ok := clone["properties"].(map[string]any); ok {
		repairedProps, promotedReqs, propsMod := repairPropertyMap(propsVal)
		if propsMod {
			clone["properties"] = repairedProps
			modified = true
		}
		if len(promotedReqs) > 0 {
			existingReqs := extractStringArray(clone["required"])
			merged := mergeStringSlices(existingReqs, promotedReqs)
			clone["required"] = merged
			modified = true
		}
	}

	// 3. Recurse into all other standard schema containers
	if itemsVal, ok := clone["items"].(map[string]any); ok {
		repairedItems, itemsMod := repairSchemaNode(itemsVal)
		if itemsMod {
			clone["items"] = repairedItems
			modified = true
		}
	} else if itemsList, ok := clone["items"].([]any); ok {
		repairedList, listMod := repairSchemaList(itemsList)
		if listMod {
			clone["items"] = repairedList
			modified = true
		}
	} else if itemsBool, ok := clone["items"].(bool); ok && itemsBool {
		clone["items"] = map[string]any{}
		modified = true
	}

	if addProps, ok := clone["additionalProperties"].(map[string]any); ok {
		repairedAddProps, addPropsMod := repairSchemaNode(addProps)
		if addPropsMod {
			clone["additionalProperties"] = repairedAddProps
			modified = true
		}
	}

	if patProps, ok := clone["patternProperties"].(map[string]any); ok {
		repairedPatProps, _, patMod := repairPropertyMap(patProps)
		if patMod {
			clone["patternProperties"] = repairedPatProps
			modified = true
		}
	}

	for _, key := range []string{"if", "then", "else", "not", "contains", "propertyNames", "unevaluatedProperties", "unevaluatedItems", "contentSchema", "additionalItems"} {
		if subVal, ok := clone[key].(map[string]any); ok {
			repairedSub, subMod := repairSchemaNode(subVal)
			if subMod {
				clone[key] = repairedSub
				modified = true
			}
		} else if subBool, ok := clone[key].(bool); ok && subBool {
			clone[key] = map[string]any{}
			modified = true
		}
	}

	for _, key := range []string{"anyOf", "oneOf", "allOf", "prefixItems"} {
		if listVal, ok := clone[key].([]any); ok {
			repairedList, listMod := repairSchemaList(listVal)
			if listMod {
				clone[key] = repairedList
				modified = true
			}
		}
	}

	for _, key := range []string{"$defs", "definitions", "dependentSchemas", "dependencies"} {
		if defsVal, ok := clone[key].(map[string]any); ok {
			repairedDefs := make(map[string]any, len(defsVal))
			defsModified := false
			for dk, dv := range defsVal {
				if defMap, ok := dv.(map[string]any); ok {
					repairedDef, defMod := repairSchemaNode(defMap)
					repairedDefs[dk] = repairedDef
					if defMod {
						defsModified = true
						modified = true
					}
				} else if defBool, ok := dv.(bool); ok && defBool {
					repairedDefs[dk] = map[string]any{}
					defsModified = true
					modified = true
				} else {
					repairedDefs[dk] = dv
				}
			}
			if defsModified {
				clone[key] = repairedDefs
			}
		}
	}

	return clone, modified
}

func repairSchemaList(list []any) ([]any, bool) {
	var repairedList []any
	listModified := false
	for _, item := range list {
		if itemMap, ok := item.(map[string]any); ok {
			repairedItem, itemMod := repairSchemaNode(itemMap)
			repairedList = append(repairedList, repairedItem)
			if itemMod {
				listModified = true
			}
		} else if itemBool, ok := item.(bool); ok && itemBool {
			repairedList = append(repairedList, map[string]any{})
			listModified = true
		} else {
			repairedList = append(repairedList, item)
		}
	}
	return repairedList, listModified
}

func repairPropertyMap(props map[string]any) (map[string]any, []string, bool) {
	out := make(map[string]any, len(props))
	var promotedReqs []string
	modified := false

	for k, v := range props {
		if b, ok := v.(bool); ok && b {
			out[k] = map[string]any{}
			modified = true
			continue
		}

		childMap, isMap := v.(map[string]any)
		if !isMap {
			out[k] = v
			continue
		}

		childClone := make(map[string]any, len(childMap))
		for ck, cv := range childMap {
			childClone[ck] = cv
		}

		if reqBool, isBool := childClone["required"].(bool); isBool {
			delete(childClone, "required")
			modified = true
			if reqBool {
				promotedReqs = append(promotedReqs, k)
			}
		}

		repairedChild, childMod := repairSchemaNode(childClone)
		if childMod {
			modified = true
		}
		out[k] = repairedChild
	}

	sort.Strings(promotedReqs)
	return out, promotedReqs, modified
}

func extractStringArray(val any) []string {
	if val == nil {
		return nil
	}
	arr, ok := val.([]any)
	if !ok {
		if strArr, ok := val.([]string); ok {
			return strArr
		}
		return nil
	}
	var res []string
	for _, item := range arr {
		if s, ok := item.(string); ok {
			res = append(res, s)
		}
	}
	return res
}

func mergeStringSlices(existing, promoted []string) []string {
	seen := make(map[string]bool)
	var res []string
	for _, s := range existing {
		if !seen[s] && s != "" {
			seen[s] = true
			res = append(res, s)
		}
	}
	for _, s := range promoted {
		if !seen[s] && s != "" {
			seen[s] = true
			res = append(res, s)
		}
	}
	return res
}

// InlineLocalRefs resolves JSON Pointer references against the original schema. Every reference
// receives its own copy as long as the expansion budget lasts, conflicting sibling assertions
// remain conjunctive, and cycles terminate as a typed hint instead of recursing forever.
func InlineLocalRefs(jsonStr string) string {
	return inlineLocalRefs(jsonStr)
}

// maxInlinedSchemaBytes bounds the total size of schema material copied into the output by local
// reference expansion. Shared definitions fan out once per reference, so a small input can expand
// exponentially; expansions past the budget degrade to a typed hint like cycles do.
const maxInlinedSchemaBytes = 256 * 1024

// maxFallbackDescriptionBytes bounds the target description copied into a degraded reference, so a
// large shared description cannot be repeated per reference and defeat the expansion budget.
const maxFallbackDescriptionBytes = 512

// inlineLocalRefs resolves JSON Pointer references against the original schema. Every reference
// receives its own copy as long as the expansion budget lasts, conflicting sibling assertions
// remain conjunctive, and cycles terminate as a typed hint instead of recursing forever.
func inlineLocalRefs(jsonStr string) string {
	return inlineLocalRefsRooted(jsonStr, nil, false)
}

// inlineLocalRefsForClean inlines local references for schema cleaning. Cleaning sometimes
// receives the single-key {"schema": ...} carrier used to nest a schema one level down (see Phase
// 0); references inside it must then resolve against the inner schema, not the carrier, or every
// lookup misses. The carrier shape and root keys are otherwise preserved byte-for-byte.
func inlineLocalRefsForClean(jsonStr string) string {
	if !strings.Contains(jsonStr, `"$ref"`) {
		return jsonStr
	}

	decoder := json.NewDecoder(strings.NewReader(jsonStr))
	decoder.UseNumber()
	var root any
	if err := decoder.Decode(&root); err != nil {
		return jsonStr
	}

	resolveRoot := root
	wrapped := false
	if rootMap, ok := root.(map[string]any); ok && len(rootMap) == 1 {
		if inner, okInner := rootMap["schema"].(map[string]any); okInner {
			resolveRoot = inner
			wrapped = true
		}
	}
	if !wrapped {
		return inlineLocalRefsRooted(jsonStr, root, true)
	}

	state := newRefResolveState()
	resolved := resolveSchemaRoot(resolveRoot, state, true)
	out, err := json.Marshal(map[string]any{"schema": resolved})
	if err != nil {
		return jsonStr
	}
	return string(out)
}

func inlineLocalRefsRooted(jsonStr string, parsedRoot any, forClean bool) string {
	if !strings.Contains(jsonStr, `"$ref"`) {
		return jsonStr
	}

	root := parsedRoot
	if root == nil {
		decoder := json.NewDecoder(strings.NewReader(jsonStr))
		decoder.UseNumber()
		if err := decoder.Decode(&root); err != nil {
			return jsonStr
		}
	}

	state := newRefResolveState()
	resolved := resolveSchemaRoot(root, state, forClean)
	out, err := json.Marshal(resolved)
	if err != nil {
		return jsonStr
	}
	return string(out)
}

// refResolveState carries the remaining expansion budget of one inlining pass. Every reference
// charges the size of the target it copies (nested references charge their own targets), so
// shared-definition fan-out stops once the budget is spent while ordinary reuse still expands.
type refResolveState struct {
	budget int
}

func newRefResolveState() *refResolveState {
	return &refResolveState{budget: maxInlinedSchemaBytes}
}

// sortedKeys returns map keys in a deterministic order, so budget allocation never depends on
// Go map iteration order.
func sortedKeys[V any](m map[string]V) []string {
	keys := make([]string, 0, len(m))
	for key := range m {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	return keys
}

// resolveSchemaRoot resolves references in the schema body before resolving definition
// containers. Definitions only matter through references, so walking them first would spend the
// expansion budget on copies the cleaners delete next (and whose map order is arbitrary), while
// the fields that are actually kept degraded to hints. For the public InlineLocalRefs the
// containers are kept, so they are still resolved afterwards with whatever budget remains; when
// cleaning they are dropped here altogether (references still resolve against the original
// root), so their bytes never reach the intermediate JSON at all.
func resolveSchemaRoot(root any, state *refResolveState, forClean bool) any {
	rootMap, ok := root.(map[string]any)
	if !ok {
		return resolveLocalRefs(root, root, make(map[string]bool), state, false, forClean)
	}

	out := make(map[string]any, len(rootMap))
	for _, key := range sortedKeys(rootMap) {
		item := rootMap[key]
		switch key {
		case "$defs", "definitions":
			if !forClean {
				out[key] = nil // placeholder, resolved after the schema body below
			}
		case "$ref":
			out[key] = item
		default:
			out[key] = resolveSchemaEntry(rootMap, key, item, make(map[string]bool), state, forClean)
		}
	}

	// A root-level reference and its already resolved siblings use the same conjunction rules
	// as nested references. Keep document declarations outside any generated allOf branches.
	if ref, hasRef := rootMap["$ref"].(string); hasRef && strings.HasPrefix(ref, "#/") {
		if target, okTarget := resolveJSONPointer(rootMap, ref); okTarget {
			if size := targetExpansionSize(target, forClean); size <= state.budget {
				state.budget -= size
				active := map[string]bool{ref: true}
				if targetMap, okMap := resolveLocalRefs(rootMap, target, active, state, false, forClean).(map[string]any); okMap {
					delete(out, "$ref")
					delete(out, "$defs")
					delete(out, "definitions")
					delete(out, "$schema")
					out = mergeInlinedRefSchemas(targetMap, out)
					if dialect, exists := rootMap["$schema"]; exists {
						out["$schema"] = dialect
					}
				} else {
					out = resolveRefFallback(rootMap, out, target, ref, make(map[string]bool), state, forClean)
				}
			} else {
				out = resolveRefFallback(rootMap, out, target, ref, make(map[string]bool), state, forClean)
			}
			delete(out, "$ref")
		}
	}

	if !forClean {
		for _, key := range []string{"$defs", "definitions"} {
			if item, exists := rootMap[key]; exists {
				out[key] = resolveSchemaEntry(rootMap, key, item, make(map[string]bool), state, false)
			}
		}
	}
	return out
}

// targetExpansionSize estimates what copying a reference target into the output costs. When
// cleaning, definitions nested inside the target are deleted later and never copied, so they are
// excluded; otherwise a small target carrying a large unused definitions block would be degraded
// purely because of material that is about to be thrown away. Name-map and literal context is
// threaded so properties named like a container keyword, and data inside literal values, still
// count in full.
func targetExpansionSize(value any, forClean bool) int {
	if !forClean {
		return jsonValueSize(value)
	}
	return cleanExpansionSize(value, false)
}

func cleanExpansionSize(value any, inNameMap bool) int {
	switch node := value.(type) {
	case nil:
		return 4
	case bool:
		return 5
	case json.Number:
		return len(node.String())
	case string:
		return jsonStringEncodedSize(node)
	case []any:
		size := 2
		for _, item := range node {
			size += cleanExpansionSize(item, false) + 1
			if size > maxInlinedSchemaBytes+1 {
				return maxInlinedSchemaBytes + 1
			}
		}
		return size
	case map[string]any:
		size := 2
		for key, item := range node {
			if !inNameMap && (key == "$defs" || key == "definitions") {
				continue
			}
			child := item
			childInNameMap := false
			if !inNameMap {
				if isLiteralValueKeyword(key) {
					size += jsonStringEncodedSize(key) + 1 + jsonValueSize(child) + 1
					if size > maxInlinedSchemaBytes+1 {
						return maxInlinedSchemaBytes + 1
					}
					continue
				}
				if _, isNameMap := schemaNameMapKeywords[key]; isNameMap {
					childInNameMap = true
				}
			}
			size += jsonStringEncodedSize(key) + 1 + cleanExpansionSize(child, childInNameMap) + 1
			if size > maxInlinedSchemaBytes+1 {
				return maxInlinedSchemaBytes + 1
			}
		}
		return size
	default:
		return 16
	}
}

// jsonValueSize estimates the encoded size of a decoded JSON value as encoding/json will emit
// it, including string escaping (notably the six-byte \u00XX forms for <, >, & and control
// characters). It is used to bound ref expansion, so it must not under-count what serialization
// will actually produce.
func jsonValueSize(value any) int {
	switch node := value.(type) {
	case nil:
		return 4
	case bool:
		return 5
	case json.Number:
		return len(node.String())
	case string:
		return jsonStringEncodedSize(node)
	case []any:
		size := 2
		for _, item := range node {
			size += jsonValueSize(item) + 1
		}
		return size
	case map[string]any:
		size := 2
		for key, item := range node {
			size += jsonStringEncodedSize(key) + 1 + jsonValueSize(item) + 1
		}
		return size
	default:
		return 16
	}
}

// jsonStringEncodedSize returns the encoded length of one JSON string including quotes,
// mirroring encoding/json's default HTML escaping.
func jsonStringEncodedSize(s string) int {
	size := 2
	for i := 0; i < len(s); i++ {
		c := s[i]
		switch {
		case c < 0x20:
			if c == '\n' || c == '\r' || c == '\t' {
				size += 2
			} else {
				size += 6
			}
		case c == '"' || c == '\\':
			size += 2
		case c == '<' || c == '>' || c == '&':
			size += 6
		case c < 0x80:
			size++
		case c == 0xe2 && i+2 < len(s) && s[i+1] == 0x80 && (s[i+2] == 0xa8 || s[i+2] == 0xa9):
			size += 6
			i += 2
		default:
			size++
		}
	}
	return size
}

// literalValueKeywords carry client data rather than subschemas. Cleaning and ref inlining must
// copy their values verbatim: keys inside them that happen to look like schema keywords are data.
var literalValueKeywords = map[string]struct{}{
	"default":  {},
	"example":  {},
	"examples": {},
	"const":    {},
	"enum":     {},
}

func isLiteralValueKeyword(key string) bool {
	_, ok := literalValueKeywords[key]
	return ok
}

func resolveLocalRefs(root, value any, active map[string]bool, state *refResolveState, inNameMap, forClean bool) any {
	switch node := value.(type) {
	case []any:
		out := make([]any, len(node))
		for i, item := range node {
			out[i] = resolveLocalRefs(root, item, active, state, false, forClean)
		}
		return out
	case map[string]any:
		if !inNameMap {
			ref, hasRef := node["$ref"].(string)
			if hasRef && strings.HasPrefix(ref, "#/") {
				if target, ok := resolveJSONPointer(root, ref); ok {
					if active[ref] {
						return resolveRefFallback(root, node, target, ref, active, state, forClean)
					}
					// Charge the stored target size and let nested references charge their own
					// costs during recursion. The total copied material stays bounded, while an
					// over-budget chain still expands its affordable outer levels and only the
					// deepest references degrade to hints.
					if size := targetExpansionSize(target, forClean); size > state.budget {
						return resolveRefFallback(root, node, target, ref, active, state, forClean)
					} else {
						state.budget -= size
					}
					active[ref] = true
					resolvedTarget := resolveLocalRefs(root, target, active, state, false, forClean)
					delete(active, ref)
					if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget {
						siblings := make(map[string]any, len(node))
						for _, key := range sortedKeys(node) {
							if key == "$ref" {
								continue
							}
							if forClean && (key == "$defs" || key == "definitions") {
								continue
							}
							siblings[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean)
						}
						return mergeInlinedRefSchemas(targetMap, siblings)
					}
				}
			}
		}

		out := make(map[string]any, len(node))
		for _, key := range sortedKeys(node) {
			if forClean && !inNameMap && (key == "$defs" || key == "definitions") {
				continue // dropped with every other definitions container when cleaning
			}
			out[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean)
		}
		return out
	default:
		return value
	}
}

// mergeInlinedRefSchemas keeps both complete schemas when assertions collide. Merging only
// the conflicting keyword would change the scope of properties/additionalProperties and other
// interacting keywords. Annotation siblings may still override the target's annotations.
func mergeInlinedRefSchemas(target, siblings map[string]any) map[string]any {
	if len(siblings) > 0 && (target["unevaluatedProperties"] != nil || target["unevaluatedItems"] != nil) {
		return conjoinInlinedRefSchemas(target, siblings)
	}
	// These assertions depend on adjacent keywords in their own schema. Even equal values
	// must not inherit property declarations, tuple prefixes, or match counts across a ref.
	for _, pair := range [][2]string{
		{"additionalProperties", "properties"},
		{"additionalProperties", "patternProperties"},
		{"additionalProperties", "additionalProperties"},
		{"items", "prefixItems"},
		{"contains", "contains"},
		{"contains", "minContains"},
		{"contains", "maxContains"},
	} {
		if (target[pair[0]] != nil && siblings[pair[1]] != nil) || (siblings[pair[0]] != nil && target[pair[1]] != nil) {
			return conjoinInlinedRefSchemas(target, siblings)
		}
	}
	out := make(map[string]any, len(target)+len(siblings))
	for key, value := range target {
		out[key] = value
	}
	for key, value := range siblings {
		switch key {
		case "title", "description", "default", "example", "examples", "$comment", "deprecated", "readOnly", "writeOnly":
			// Annotations do not relax validation constraints.
		default:
			if previous, exists := target[key]; exists && !reflect.DeepEqual(previous, value) {
				return conjoinInlinedRefSchemas(target, siblings)
			}
		}
		out[key] = value
	}
	return out
}

// conjoinInlinedRefSchemas replaces $ref with an allOf applicator without moving its siblings.
// In particular, sibling unevaluatedProperties/unevaluatedItems must still see annotations
// produced by the reference target, while the target's own evaluation scope stays isolated.
func conjoinInlinedRefSchemas(target, siblings map[string]any) map[string]any {
	out := make(map[string]any, len(siblings)+1)
	for key, value := range siblings {
		out[key] = value
	}
	branches := []any{target}
	if existing, ok := siblings["allOf"].([]any); ok {
		branches = append(branches, existing...)
	}
	out["allOf"] = branches
	return out
}

// resolveRefFallback degrades a reference that cannot be expanded (cycle or exhausted budget) to
// a typed hint, but still resolves the sibling subschemas on the same node so their own references
// are not left dangling behind the degraded one. Literal values stay verbatim through
// resolveSchemaEntry. The description assembled by cyclicRefFallback is kept afterwards so
// resolved siblings cannot clobber the hint.
func resolveRefFallback(root any, node map[string]any, target any, ref string, active map[string]bool, state *refResolveState, forClean bool) map[string]any {
	out := cyclicRefFallback(node, target, ref)
	// The target description copied into the fallback is expansion output like any other; charge
	// it to the budget. Once the budget is spent, fallbacks carry only the short hint, so a large
	// number of degraded references cannot multiply copied descriptions without bound. Cyclic
	// fallbacks omit over-long descriptions entirely, and omitted content is not charged.
	if targetMap, okMap := target.(map[string]any); okMap {
		if description, okDescription := targetMap["description"].(string); okDescription && description != "" && len(description) <= maxFallbackDescriptionBytes {
			if _, hasOwn := node["description"]; !hasOwn {
				cost := jsonStringEncodedSize(description)
				if cost > state.budget {
					out["description"] = "See: " + refName(ref)
				} else {
					state.budget -= cost
				}
			}
		}
	}
	for _, key := range sortedKeys(node) {
		if key == "$ref" || key == "description" {
			continue
		}
		if forClean && (key == "$defs" || key == "definitions") {
			delete(out, key)
			continue
		}
		out[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean)
	}
	return out
}

// resolveSchemaEntry resolves one map entry during ref inlining. A literal-value keyword's value
// is client data and is deep-copied without resolving refs inside it. Under a name map
// ("properties" and friends), however, a key spelled like a literal keyword is an author-chosen
// property name whose value is a subschema; name-map entries are therefore resolved directly
// here so such property names keep their schema semantics. Nested definition containers outside
// the root still resolve through this path; when cleaning, callers skip them entirely instead.
func resolveSchemaEntry(root any, key string, item any, active map[string]bool, state *refResolveState, forClean bool) any {
	if isLiteralValueKeyword(key) {
		return deepCopyJSONValue(item)
	}
	if _, isNameMap := schemaNameMapKeywords[key]; isNameMap {
		if nameMap, ok := item.(map[string]any); ok {
			out := make(map[string]any, len(nameMap))
			for _, name := range sortedKeys(nameMap) {
				out[name] = resolveLocalRefs(root, nameMap[name], active, state, false, forClean)
			}
			return out
		}
	}
	return resolveLocalRefs(root, item, active, state, false, forClean)
}

func deepCopyJSONValue(value any) any {
	switch node := value.(type) {
	case []any:
		out := make([]any, len(node))
		for i, item := range node {
			out[i] = deepCopyJSONValue(item)
		}
		return out
	case map[string]any:
		out := make(map[string]any, len(node))
		for key, item := range node {
			out[key] = deepCopyJSONValue(item)
		}
		return out
	default:
		return value
	}
}

func resolveJSONPointer(root any, ref string) (any, bool) {
	current := root
	for _, rawPart := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") {
		part := strings.ReplaceAll(strings.ReplaceAll(rawPart, "~1", "/"), "~0", "~")
		switch node := current.(type) {
		case map[string]any:
			var ok bool
			current, ok = node[part]
			if !ok {
				return nil, false
			}
		case []any:
			index, err := strconv.Atoi(part)
			if err != nil || index < 0 || index >= len(node) {
				return nil, false
			}
			current = node[index]
		default:
			return nil, false
		}
	}
	return current, true
}

func cyclicRefFallback(node map[string]any, target any, ref string) map[string]any {
	out := make(map[string]any, len(node)+2)
	if targetMap, ok := target.(map[string]any); ok {
		for _, key := range []string{"type", "nullable", "description"} {
			if value, exists := targetMap[key]; exists {
				if key == "description" {
					if description, okDescription := value.(string); okDescription && len(description) > maxFallbackDescriptionBytes {
						continue
					}
				}
				out[key] = value
			}
		}
	}
	for key, value := range node {
		if key != "$ref" {
			out[key] = value
		}
	}
	name := refName(ref)
	hint := "See: " + name
	if description, _ := out["description"].(string); description != "" {
		out["description"] = mergeHint(description, hint)
	} else {
		out["description"] = hint
	}
	return out
}

func refName(ref string) string {
	if index := strings.LastIndex(ref, "/"); index >= 0 && index+1 < len(ref) {
		return strings.ReplaceAll(strings.ReplaceAll(ref[index+1:], "~1", "/"), "~0", "~")
	}
	return ref
}

// convertRefsToHints retains sibling keywords and converts only unresolved or external references
// to descriptions. Local references have already been expanded by inlineLocalRefs.
func convertRefsToHints(jsonStr string, preserveSiblings bool) string {
	paths := findPaths(jsonStr, "$ref")
	if len(paths) == 0 {
		return jsonStr
	}
	sortByDepth(paths)

	doc := ojson.NewDocString(jsonStr)
	modified := false
	for _, p := range paths {
		parentPath := trimSuffix(p, ".$ref")
		if isPropertyDefinition(parentPath) {
			continue // a property literally named "$ref", not a reference keyword
		}
		refVal := doc.Get(p).String()
		defName := refName(refVal)
		hint := fmt.Sprintf("See: %s", defName)
		if !preserveSiblings {
			if existing := doc.Get(descriptionPath(parentPath)).String(); existing != "" {
				hint = fmt.Sprintf("%s (%s)", existing, hint)
			}
			replacementDoc := ojson.NewDocString(`{"type":"object","description":""}`)
			_ = replacementDoc.Set("description", hint)
			setRawAtDoc(doc, parentPath, replacementDoc.String())
			modified = true
			continue
		}
		_ = doc.Delete(p)
		appendHintDoc(doc, parentPath, hint)
		modified = true
	}
	if modified {
		return doc.String()
	}
	return jsonStr
}

func convertConstToEnum(jsonStr string) string {
	paths := findPaths(jsonStr, "const")
	if len(paths) == 0 {
		return jsonStr
	}
	doc := ojson.NewDocString(jsonStr)
	modified := false
	for _, p := range paths {
		val := doc.Get(p)
		if !val.Exists() {
			continue
		}
		enumPath := trimSuffix(p, ".const") + ".enum"
		if !doc.Get(enumPath).Exists() {
			if doc.Set(enumPath, []interface{}{val.Value()}) == nil {
				modified = true
			}
		}
	}
	if modified {
		return doc.String()
	}
	return jsonStr
}

// convertEnumValuesToStrings ensures all enum values use the string representation required by
// Gemini's proto schema. The declared type remains independent: Antigravity uses it to choose the
// emitted JSON type on both response and function-argument paths.
func convertEnumValuesToStrings(jsonStr string, forceStringType bool) string {
	paths := findPaths(jsonStr, "enum")
	if len(paths) == 0 {
		return jsonStr
	}
	doc := ojson.NewDocString(jsonStr)
	modified := false
	for _, p := range paths {
		arr := doc.Get(p)
		if !arr.IsArray() {
			continue
		}

		var stringVals []string
		for _, item := range arr.Array() {
			stringVals = append(stringVals, item.String())
		}

		if doc.Set(p, stringVals) == nil {
			modified = true
		}
		if forceStringType {
			parentPath := trimSuffix(p, ".enum")
			if doc.Set(joinPath(parentPath, "type"), "string") == nil {
				modified = true
			}
		}
	}
	if modified {
		return doc.String()
	}
	return jsonStr
}

func addEnumHints(jsonStr string) string {
	paths := findPaths(jsonStr, "enum")
	if len(paths) == 0 {
		return jsonStr
	}
	doc := ojson.NewDocString(jsonStr)
	modified := false
	for _, p := range paths {
		arr := doc.Get(p)
		if !arr.IsArray() {
			continue
		}
		items := arr.Array()
		if len(items) <= 1 || len(items) > 10 {
			continue
		}

		var vals []string
		for _, item := range items {
			vals = append(vals, item.String())
		}
		appendHintDoc(doc, trimSuffix(p, ".enum"), "Allowed: "+strings.Join(vals, ", "))
		modified = true
	}
	if modified {
		return doc.String()
	}
	return jsonStr
}

// Antigravity does not enforce enum on function arguments and ignores boolean response enums.
// Preserve the advisory values in description, but do not leave an unenforced constraint in the
// schema contract. Response enums for string, number, and integer remain native constraints.
func dropIgnoredEnumsToHints(jsonStr string, options jsonSchemaCleanOptions) string {
	paths := findPaths(jsonStr, "enum")
	if len(paths) == 0 {
		return jsonStr
	}
	doc := ojson.NewDocString(jsonStr)
	modified := false
	for _, path := range paths {
		parentPath := trimSuffix(path, ".enum")
		shouldDrop := options.dropAllEnums || (options.dropBooleanEnums && doc.Get(joinPath(parentPath, "type")).String() == "boolean")
		if !shouldDrop {
			continue
		}
		enum := doc.Get(path)
		if enum.IsArray() && len(enum.Array()) == 1 {
			appendHintDoc(doc, parentPath, "Allowed: "+enum.Array()[0].String())
		}
		_ = doc.Delete(path)
		modified = true
	}
	if modified {
		return doc.String()
	}
	return jsonStr
}

func addAdditionalPropertiesHints(jsonStr string) string {
	paths := findPaths(jsonStr, "additionalProperties")
	if len(paths) == 0 {
		return jsonStr
	}
	doc := ojson.NewDocString(jsonStr)
	modified := false
	for _, p := range paths {
		if doc.Get(p).Type == ojson.False {
			appendHintDoc(doc, trimSuffix(p, ".additionalProperties"), "No extra properties allowed")
			modified = true
		}
	}
	if modified {
		return doc.String()
	}
	return jsonStr
}

var numericSchemaConstraintKeywords = []string{
	"minLength", "maxLength",
	"minItems", "maxItems",
	"minProperties", "maxProperties",
}

type numericConstraintEdit struct {
	offset      int
	end         int
	replacement string
}

// NormalizeJSONSchemaNumericConstraints converts string-encoded integer values on JSON Schema
// numeric constraint keywords (minLength, maxLength, minItems, maxItems, minProperties,
// maxProperties) to JSON numbers. Google's Protobuf Schema format represents these int64
// fields as strings, whereas standard JSON Schema carriers (such as parametersJsonSchema)
// require numeric values.
func NormalizeJSONSchemaNumericConstraints(jsonStr string) string {
	if !hasNumericSchemaConstraintKeyword(jsonStr) && !strings.Contains(jsonStr, `\u`) && !strings.Contains(jsonStr, `\U`) {
		return jsonStr
	}
	root := ojson.Parse(jsonStr)
	if !root.IsObject() {
		return jsonStr
	}
	doc := root.Raw
	root.Index = 0
	var edits []numericConstraintEdit
	collectNumericConstraintEdits(doc, root, &edits)
	if len(edits) == 0 {
		return jsonStr
	}
	var sb strings.Builder
	sb.Grow(len(doc))
	cursor := 0
	for _, edit := range edits {
		if edit.offset < cursor || edit.end > len(doc) {
			return jsonStr
		}
		sb.WriteString(doc[cursor:edit.offset])
		sb.WriteString(edit.replacement)
		cursor = edit.end
	}
	sb.WriteString(doc[cursor:])
	return sb.String()
}

func collectNumericConstraintEdits(doc string, node ojson.Result, edits *[]numericConstraintEdit) {
	if !node.IsObject() {
		return
	}
	node.ForEach(func(key, val ojson.Result) bool {
		keyStr := key.String()
		switch keyStr {
		case "minLength", "maxLength", "minItems", "maxItems", "minProperties", "maxProperties":
			if val.Type == ojson.String {
				if n, ok := parseSchemaNonNegativeInt(val.String()); ok {
					end := val.Index + len(val.Raw)
					if val.Index >= 0 && end <= len(doc) && doc[val.Index:end] == val.Raw {
						*edits = append(*edits, numericConstraintEdit{
							offset:      val.Index,
							end:         end,
							replacement: strconv.FormatInt(n, 10),
						})
					}
				}
			}
		case "properties", "patternProperties", "dependentSchemas", "dependencies", "$defs", "definitions":
			if val.IsObject() {
				val.ForEach(func(_, propVal ojson.Result) bool {
					if propVal.IsObject() {
						collectNumericConstraintEdits(doc, propVal, edits)
					}
					return true
				})
			}
		case "items":
			if val.IsObject() {
				collectNumericConstraintEdits(doc, val, edits)
			} else if val.IsArray() {
				val.ForEach(func(_, item ojson.Result) bool {
					if item.IsObject() {
						collectNumericConstraintEdits(doc, item, edits)
					}
					return true
				})
			}
		case "additionalProperties", "additionalItems", "unevaluatedProperties", "unevaluatedItems",
			"if", "then", "else", "not", "contains", "propertyNames", "contentSchema":
			if val.IsObject() {
				collectNumericConstraintEdits(doc, val, edits)
			}
		case "prefixItems", "anyOf", "oneOf", "allOf":
			if val.IsArray() {
				val.ForEach(func(_, item ojson.Result) bool {
					if item.IsObject() {
						collectNumericConstraintEdits(doc, item, edits)
					}
					return true
				})
			}
		}
		return true
	})
}

func hasNumericSchemaConstraintKeyword(jsonStr string) bool {
	for _, key := range numericSchemaConstraintKeywords {
		if strings.Contains(jsonStr, key) {
			return true
		}
	}
	return false
}

func parseSchemaNonNegativeInt(raw string) (int64, bool) {
	if raw == "" {
		return 0, false
	}
	n, err := strconv.ParseInt(raw, 10, 64)
	if err != nil || n < 0 || strconv.FormatInt(n, 10) != raw {
		return 0, false
	}
	return n, true
}

var unsupportedConstraints = []string{
	"minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum",
	"pattern", "minItems", "maxItems", "uniqueItems", "contains", "format",
	"default", "examples", // Claude rejects these in VALIDATED mode
}

func constraintKeywords(options jsonSchemaCleanOptions) []string {
	if options.preserveStandardConstraints {
		return nil
	}
	keywords := append([]string(nil), unsupportedConstraints...)
	if options.antigravitySemantics {
		keywords = append(keywords, "minimum", "maximum", "multipleOf")
	}
	return keywords
}

func moveConstraintsToDescription(jsonStr string, options jsonSchemaCleanOptions) string {
	constraints := constraintKeywords(options)
	if len(constraints) == 0 {
		return jsonStr
	}
	pathsByField := findPathsByFields(jsonStr, constraints)
	doc := ojson.NewDocString(jsonStr)
	modified := false
	for _, key := range constraints {
		for _, p := range pathsByField[key] {
			val := doc.Get(p)
			if !val.Exists() {
				continue
			}
			parentPath := trimSuffix(p, "."+key)
			if isPropertyDefinition(parentPath) {
				continue
			}
			if val.IsObject() || val.IsArray() {
				appendHintDoc(doc, parentPath, fmt.Sprintf("%s: %s", key, val.Raw))
				modified = true
				continue
			}
			appendHintDoc(doc, parentPath, fmt.Sprintf("%s: %s", key, val.String()))
			modified = true
		}
	}
	if modified {
		return doc.String()
	}
	return jsonStr
}

func moveNotToDescription(jsonStr string) string {
	paths := findPaths(jsonStr, "not")
	if len(paths) == 0 {
		return jsonStr
	}
	doc := ojson.NewDocString(jsonStr)
	modified := false
	for _, path := range paths {
		value := doc.Get(path)
		if !value.Exists() || isPropertyDefinition(trimSuffix(path, ".not")) {
			continue
		}
		appendHintDoc(doc, trimSuffix(path, ".not"), "not: "+value.Raw)
		modified = true
	}
	if modified {
		return doc.String()
	}
	return jsonStr
}

func mergeConditionals(jsonStr string) string {
	pathsByField := findPathsByFields(jsonStr, []string{"then", "else"})
	var paths []string
	for _, key := range []string{"then", "else"} {
		for _, p := range pathsByField[key] {
			parentPath := trimSuffix(p, "."+key)
			if isPropertyDefinition(parentPath) {
				continue
			}
			paths = append(paths, p)
		}
	}
	if len(paths) == 0 {
		return jsonStr
	}
	sortByDepth(paths)

	doc := ojson.NewDocString(jsonStr)
	modified := false
	for _, p := range paths {
		props := doc.Get(joinPath(p, "properties"))
		if !props.IsObject() {
			continue
		}
		var parentPath string
		if strings.HasSuffix(p, ".then") {
			parentPath = trimSuffix(p, ".then")
		} else if strings.HasSuffix(p, ".else") {
			parentPath = trimSuffix(p, ".else")
		} else if p == "then" || p == "else" {
			parentPath = ""
		} else {
			continue
		}

		props.ForEach(func(key, value ojson.Result) bool {
			destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(key.String()))
			if !doc.Get(destPath).Exists() {
				if doc.SetRaw(destPath, value.Raw) == nil {
					modified = true
				}
			}
			return true
		})
	}
	if modified {
		return doc.String()
	}
	return jsonStr
}

func mergeAllOf(jsonStr string) string {
	paths := findPaths(jsonStr, "allOf")
	if len(paths) == 0 {
		return jsonStr
	}
	sortByDepth(paths)

	doc := ojson.NewDocString(jsonStr)
	modified := false
	for _, p := range paths {
		allOf := doc.Get(p)
		if !allOf.IsArray() {
			continue
		}
		parentPath := trimSuffix(p, ".allOf")

		for _, item := range allOf.Array() {
			if !item.IsObject() {
				continue
			}
			item.ForEach(func(key, value ojson.Result) bool {
				field := key.String()
				switch field {
				case "required":
					if !value.IsArray() {
						return true
					}
					reqPath := joinPath(parentPath, "required")
					current := getStringsDoc(doc, reqPath)
					for _, required := range value.Array() {
						if name := required.String(); !contains(current, name) {
							current = append(current, name)
						}
					}
					_ = doc.Set(reqPath, current)
				case "if", "then", "else", "allOf":
					// Conditional applicability cannot be represented by the upstream schema.
				default:
					destination := joinPath(parentPath, escapeGJSONPathKey(field))
					mergeMissingSchemaAtPathDoc(doc, destination, value)
				}
				return true
			})
		}
		_ = doc.Delete(p)
		modified = true
	}
	if modified {
		return doc.String()
	}
	return jsonStr
}

// mergeMissingSchemaAtPathDoc recursively fills absent fields without replacing any existing
// definition. A parent schema is the canonical definition; allOf and conditional branches may
// enrich gaps in it, but can never replace it with a narrower branch shell.
func mergeMissingSchemaAtPathDoc(doc *ojson.Doc, destination string, incoming ojson.Result) {
	existing := doc.Get(destination)
	if !existing.Exists() {
		_ = doc.SetRaw(destination, incoming.Raw)
		return
	}
	if !existing.IsObject() || !incoming.IsObject() {
		return
	}
	incoming.ForEach(func(key, value ojson.Result) bool {
		child := joinPath(destination, escapeGJSONPathKey(key.String()))
		mergeMissingSchemaAtPathDoc(doc, child, value)
		return true
	})
}

func isRequiredOnlyUnionBranch(item ojson.Result) bool {
	if !item.IsObject() || !item.Get("required").Exists() {
		return false
	}
	for _, key := range []string{"type", "properties", "items", "enum", "anyOf", "oneOf", "allOf"} {
		if item.Get(key).Exists() {
			return false
		}
	}
	return true
}

func materializeRequiredOnlyBranch(item, parentProps ojson.Result) (ojson.Result, bool) {
	if !parentProps.IsObject() {
		return ojson.Result{}, false
	}
	reqs := getStrings(item.Raw, "required")
	if len(reqs) == 0 {
		return ojson.Result{}, false
	}
	doc := ojson.NewDocString(item.Raw)
	for _, reqName := range reqs {
		escaped := escapeGJSONPathKey(reqName)
		propVal := parentProps.Get(escaped)
		if !propVal.Exists() {
			return ojson.Result{}, false
		}
		_ = doc.SetRaw("properties."+escaped, propVal.Raw)
	}
	_ = doc.Set("type", "object")
	return ojson.Parse(doc.String()), true
}

func isNullOnlyBranch(item ojson.Result) bool {
	if item.Get("properties").Exists() || item.Get("items").Exists() {
		return false
	}
	t := item.Get("type")
	if t.Type == ojson.String {
		return t.String() == "null"
	}
	if t.IsArray() && len(t.Array()) > 0 {
		for _, elem := range t.Array() {
			if elem.String() != "null" {
				return false
			}
		}
		return true
	}
	return false
}

func partitionUnionBranches(items []ojson.Result, parentProps ojson.Result) (nonNullItems []ojson.Result, hasNull, hasFilteredBranch bool) {
	for _, item := range items {
		if item.Type == ojson.True {
			nonNullItems = append(nonNullItems, ojson.Parse("{}"))
			hasFilteredBranch = true
			continue
		}
		if !item.IsObject() {
			hasFilteredBranch = true
			continue
		}
		if isNullOnlyBranch(item) {
			hasNull = true
			continue
		}
		if isRequiredOnlyUnionBranch(item) {
			if materialized, ok := materializeRequiredOnlyBranch(item, parentProps); ok {
				nonNullItems = append(nonNullItems, materialized)
				hasFilteredBranch = true
				continue
			}
			hasFilteredBranch = true
			continue
		}
		nonNullItems = append(nonNullItems, item)
	}
	return
}

func flattenAnyOfOneOf(jsonStr string, preserveOneOf, flattenMultiBranch bool) string {
	pathsByField := findPathsByFields(jsonStr, []string{"oneOf", "anyOf"})
	var paths []string
	for _, key := range []string{"oneOf", "anyOf"} {
		paths = append(paths, pathsByField[key]...)
	}
	if len(paths) == 0 {
		return jsonStr
	}
	sortByDepth(paths)

	doc := ojson.NewDocString(jsonStr)
	modified := false
	for _, p := range paths {
		key := "anyOf"
		if p == "oneOf" || strings.HasSuffix(p, ".oneOf") {
			key = "oneOf"
		}

		arr := doc.Get(p)
		if !arr.IsArray() || len(arr.Array()) == 0 {
			continue
		}

		parentPath := trimSuffix(p, "."+key)
		if isPropertyDefinition(parentPath) {
			continue
		}
		parent := doc.Get(parentPath)
		if parentPath == "" {
			if modified {
				jsonStr = doc.String()
				modified = false
			}
			parent = ojson.Parse(jsonStr)
		}

		// When a node declares both anyOf and oneOf on a native Schema carrier (!preserveOneOf),
		// protobuf Schema has only a single anyOf field and cannot represent a second union
		// conjunction structurally. Preserve anyOf as the primary union and move the sibling
		// oneOf constraint to a description hint instead of concatenating it into anyOf.
		// Routes that flatten multi-branch unions skip this shortcut: the primary union is
		// about to be flattened away too, and hinting the raw oneOf JSON into the
		// description would only add a giant, misleading hint.
		if key == "oneOf" && !flattenMultiBranch && !preserveOneOf && parent.Get("anyOf").IsArray() {
			appendHintDoc(doc, parentPath, "oneOf: "+arr.Raw)
			_ = doc.Delete(p)
			modified = true
			continue
		}

		items := arr.Array()
		parentProps := parent.Get("properties")
		nonNullItems, hasNull, hasFilteredBranch := partitionUnionBranches(items, parentProps)
		// An earlier union-preserving pass may already have stripped the null branch and
		// recorded it as nullable:true on this node (translator -> executor chain). That
		// marker is part of the schema contract, so the flattening below must inherit it.
		nullable := hasNull || parent.Get("nullable").Bool()

		// Preserve multi-branch unions (2+ standalone non-null branches) so non-first branches are not lost.
		// On native Gemini/Antigravity Schema carriers (!preserveOneOf), normalize oneOf to anyOf because
		// the protobuf Schema message supports anyOf but rejects oneOf.
		// Routes that reject multi-branch unions upstream (flattenMultiBranch) skip this
		// preservation and fall through to the single-strongest-branch flattening below.
		if len(nonNullItems) >= 2 && !flattenMultiBranch {
			targetKey := key
			if key == "oneOf" && !preserveOneOf {
				targetKey = "anyOf"
			}
			targetPath := joinPath(parentPath, targetKey)

			if targetKey != key || hasNull || hasFilteredBranch {
				rawBranches := make([]json.RawMessage, 0, len(nonNullItems))
				for _, item := range nonNullItems {
					rawBranches = append(rawBranches, json.RawMessage(item.Raw))
				}
				if doc.Set(targetPath, rawBranches) == nil {
					modified = true
				}
				if targetKey != key {
					_ = doc.Delete(p)
					modified = true
				}
			}
			if hasNull {
				_ = doc.Set(joinPath(parentPath, "nullable"), true)
				appendHintDoc(doc, parentPath, "(nullable)")
				modified = true
			}
			continue
		}

		// If the parent already defines properties (e.g. an object schema with unmaterializable
		// union constraints or a single-branch union), do not replace the parent with a single branch.
		// Instead, merge any branch properties into the parent and delete the union keyword.
		if parentProps.IsObject() {
			for _, item := range nonNullItems {
				if branchProps := item.Get("properties"); branchProps.IsObject() {
					branchProps.ForEach(func(propKey, propVal ojson.Result) bool {
						destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(propKey.String()))
						mergeMissingSchemaAtPathDoc(doc, destPath, propVal)
						return true
					})
				}
			}
			if nullable {
				_ = doc.Set(joinPath(parentPath, "nullable"), true)
			}
			if parent.Get("type").String() == "" {
				_ = doc.Set(joinPath(parentPath, "type"), "object")
			}
			_ = doc.Delete(p)
			modified = true
			continue
		}

		if len(nonNullItems) == 0 {
			if !hasNull {
				_ = doc.Delete(p)
				modified = true
				continue
			}
			selectedDoc := ojson.NewDocString(`{"type":"null"}`)
			if parentDesc := doc.Get(descriptionPath(parentPath)).String(); parentDesc != "" {
				mergeDescriptionDoc(selectedDoc, parentDesc)
			}
			setRawAtDoc(doc, parentPath, selectedDoc.String())
			modified = true
			continue
		}

		candidateItems := items
		if hasFilteredBranch || len(nonNullItems) >= 2 {
			candidateItems = nonNullItems
			if hasNull {
				candidateItems = append(append([]ojson.Result(nil), nonNullItems...), ojson.Parse(`{"type":"null"}`))
			}
		}

		parentDesc := doc.Get(descriptionPath(parentPath)).String()
		_, allTypes := selectBest(candidateItems)
		// Select the strongest non-null branch (ties keep the first one), matching the
		// pre-preservation flattening behavior. selectBest already treats union-bearing
		// branches as score-0, so a nested union never wins over a null-free scalar here.
		selected := nonNullItems[0].Raw
		if bestIdx, _ := selectBest(nonNullItems); bestIdx >= 0 && bestIdx < len(nonNullItems) {
			selected = nonNullItems[bestIdx].Raw
		}
		selectedDoc := ojson.NewDocString(selected)
		selectedModified := false
		if nullable {
			_ = selectedDoc.Set("nullable", true)
			selectedModified = true
		}

		if parentDesc != "" {
			if mergeDescriptionDoc(selectedDoc, parentDesc) {
				selectedModified = true
			}
		}

		if len(allTypes) > 1 {
			hint := "Accepts: " + strings.Join(allTypes, " | ")
			appendHintDoc(selectedDoc, "", hint)
			selectedModified = true
		} else if nullable {
			appendHintDoc(selectedDoc, "", "(nullable)")
			selectedModified = true
		}
		if selectedModified {
			selected = selectedDoc.String()
		}

		setRawAtDoc(doc, parentPath, selected)
		modified = true
	}
	if modified {
		return doc.String()
	}
	return jsonStr
}

func selectBest(items []ojson.Result) (bestIdx int, types []string) {
	bestScore := -1
	for i, item := range items {
		t := item.Get("type").String()
		score := 0

		switch {
		case t == "object" || item.Get("properties").Exists():
			score, t = 3, orDefault(t, "object")
		case t == "array" || item.Get("items").Exists():
			score, t = 2, orDefault(t, "array")
		case t != "" && t != "null":
			score = 1
		case t == "null":
			score, t = 0, "null"
		default:
			score, t = 0, ""
		}

		if t != "" {
			types = append(types, t)
		}
		if score > bestScore {
			bestScore, bestIdx = score, i
		}
	}
	return
}

func flattenTypeArrays(jsonStr string, preserveNativeNullable bool) string {
	paths := findPaths(jsonStr, "type")
	if len(paths) == 0 {
		return jsonStr
	}
	sortByDepth(paths)

	nullableFields := make(map[string][]string)
	doc := ojson.NewDocString(jsonStr)
	modified := false

	for _, p := range paths {
		res := doc.Get(p)
		if !res.IsArray() || len(res.Array()) == 0 {
			continue
		}

		hasNull := false
		var nonNullTypes []string
		for _, item := range res.Array() {
			s := item.String()
			if s == "null" {
				hasNull = true
			} else if s != "" {
				nonNullTypes = append(nonNullTypes, s)
			}
		}

		parentPath := trimSuffix(p, ".type")

		firstType := "string"
		if len(nonNullTypes) > 0 {
			if doc.Get(joinPath(parentPath, "items")).Exists() && contains(nonNullTypes, "array") {
				firstType = "array"
			} else if doc.Get(joinPath(parentPath, "properties")).Exists() && contains(nonNullTypes, "object") {
				firstType = "object"
			} else {
				firstType = nonNullTypes[0]
			}
		}

		_ = doc.Set(p, firstType)
		modified = true

		if firstType != "array" && doc.Get(joinPath(parentPath, "items")).Exists() {
			_ = doc.Delete(joinPath(parentPath, "items"))
		}
		if len(nonNullTypes) > 1 {
			hint := "Accepts: " + strings.Join(nonNullTypes, " | ")
			appendHintDoc(doc, parentPath, hint)
		}

		if hasNull {
			if preserveNativeNullable {
				_ = doc.Set(joinPath(parentPath, "nullable"), true)
				appendHintDoc(doc, parentPath, "(nullable)")
				continue
			}

			parts := splitGJSONPath(p)
			if len(parts) >= 3 && parts[len(parts)-3] == "properties" {
				fieldNameEscaped := parts[len(parts)-2]
				fieldName := unescapeGJSONPathKey(fieldNameEscaped)
				objectPath := strings.Join(parts[:len(parts)-3], ".")
				nullableFields[objectPath] = append(nullableFields[objectPath], fieldName)
				appendHintDoc(doc, joinPath(objectPath, "properties."+fieldNameEscaped), "(nullable)")
			}
		}
	}

	for objectPath, fields := range nullableFields {
		reqPath := joinPath(objectPath, "required")
		req := doc.Get(reqPath)
		if !req.IsArray() {
			continue
		}

		var filtered []string
		for _, required := range req.Array() {
			if !contains(fields, required.String()) {
				filtered = append(filtered, required.String())
			}
		}
		if len(filtered) == 0 {
			_ = doc.Delete(reqPath)
		} else {
			_ = doc.Set(reqPath, filtered)
		}
		modified = true
	}
	if modified {
		return doc.String()
	}
	return jsonStr
}

func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) string {
	keywords := append(constraintKeywords(options),
		"$schema", "$defs", "definitions", "const", "$ref", "$id", "id", "additionalProperties",
		"$anchor", "$vocabulary", "$dynamicRef", "$dynamicAnchor",
		"propertyNames", "patternProperties", // Gemini doesn't support these schema keywords
		"dependentRequired", "dependentSchemas", "dependencies",
		"if", "then", "else",
		"$comment", "enumDescriptions", "enumTitles", "prefill", "deprecated", "encrypted", "discriminator", "readOnly", "writeOnly", // Schema metadata fields unsupported by Gemini
		"additionalItems", "unevaluatedProperties", "unevaluatedItems", "contentSchema", "contentMediaType", "contentEncoding",
	)
	if options.antigravitySemantics {
		keywords = append(keywords, "not")
	}

	deletePaths := make([]string, 0)
	pathsByField := findPathsByFields(jsonStr, keywords)
	for _, key := range keywords {
		for _, p := range pathsByField[key] {
			if isPropertyDefinition(trimSuffix(p, "."+key)) {
				continue
			}
			if key == "additionalProperties" {
				if options.preserveAllAdditionalProperties {
					continue
				}
				if options.preserveAdditionalPropertiesFalse && ojson.Get(jsonStr, p).Type == ojson.False {
					continue
				}
			}
			deletePaths = append(deletePaths, p)
		}
	}
	if len(deletePaths) > 0 {
		sortByDepth(deletePaths)
		doc := ojson.NewDocString(jsonStr)
		for _, p := range deletePaths {
			_ = doc.Delete(p)
		}
		jsonStr = doc.String()
	}
	// Remove x-* extension fields (e.g., x-google-enum-descriptions) that are not supported by Gemini API
	jsonStr = removeExtensionFields(jsonStr)
	return jsonStr
}

// removeExtensionFields removes all x-* extension fields from the JSON schema.
// These are OpenAPI/JSON Schema extension fields that Google APIs don't recognize.
func removeExtensionFields(jsonStr string) string {
	var paths []string
	walkForExtensions(ojson.Parse(jsonStr), "", &paths)
	// walkForExtensions returns paths in a way that deeper paths are added before their ancestors
	// when they are not deleted wholesale, but since we skip children of deleted x-* nodes,
	// any collected path is safe to delete.
	if len(paths) == 0 {
		return jsonStr
	}
	doc := ojson.NewDocString(jsonStr)
	for _, p := range paths {
		_ = doc.Delete(p)
	}
	return doc.String()
}

func walkForExtensions(value ojson.Result, path string, paths *[]string) {
	if value.IsArray() {
		arr := value.Array()
		for i := len(arr) - 1; i >= 0; i-- {
			walkForExtensions(arr[i], joinPath(path, strconv.Itoa(i)), paths)
		}
		return
	}

	if value.IsObject() {
		value.ForEach(func(key, val ojson.Result) bool {
			keyStr := key.String()
			safeKey := escapeGJSONPathKey(keyStr)
			childPath := joinPath(path, safeKey)

			// If it's an extension field, we delete it and don't need to look at its children.
			if strings.HasPrefix(keyStr, "x-") && !isPropertyDefinition(path) {
				*paths = append(*paths, childPath)
				return true
			}

			// Literal values are client data; x-* keys inside them are not schema extensions.
			if isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) {
				return true
			}

			walkForExtensions(val, childPath, paths)
			return true
		})
	}
}

func hasSchemaConjunction(jsonStr string) bool {
	for _, path := range findPaths(jsonStr, "allOf") {
		if ojson.Get(jsonStr, path).IsArray() {
			return true
		}
	}
	return false
}

func cleanupRequiredFields(jsonStr string) string {
	paths := findPaths(jsonStr, "required")
	if len(paths) == 0 {
		return jsonStr
	}
	doc := ojson.NewDocString(jsonStr)
	modified := false
	for _, p := range paths {
		parentPath := trimSuffix(p, ".required")
		propsPath := joinPath(parentPath, "properties")

		req := doc.Get(p)
		props := doc.Get(propsPath)
		if !req.IsArray() {
			continue
		}
		if !props.IsObject() {
			_ = doc.Delete(p)
			modified = true
			continue
		}

		var valid []string
		for _, r := range req.Array() {
			key := r.String()
			if props.Get(escapeGJSONPathKey(key)).Exists() {
				valid = append(valid, key)
			}
		}

		if len(valid) != len(req.Array()) {
			if len(valid) == 0 {
				_ = doc.Delete(p)
			} else {
				_ = doc.Set(p, valid)
			}
			modified = true
		}
	}
	if modified {
		return doc.String()
	}
	return jsonStr
}

// addEmptySchemaPlaceholder adds a placeholder "reason" property to empty object schemas.
// Claude VALIDATED mode requires at least one required property in tool schemas.
func addEmptySchemaPlaceholder(jsonStr string) string {
	// Find all "type" fields
	paths := findPaths(jsonStr, "type")
	if len(paths) == 0 {
		return jsonStr
	}

	// Process from deepest to shallowest (to handle nested objects properly)
	sortByDepth(paths)

	doc := ojson.NewDocString(jsonStr)
	modified := false
	for _, p := range paths {
		typeVal := doc.Get(p)
		if typeVal.String() != "object" {
			continue
		}

		// Get the parent path (the object containing "type")
		parentPath := trimSuffix(p, ".type")

		// Check if properties exists and is empty or missing
		propsPath := joinPath(parentPath, "properties")
		propsVal := doc.Get(propsPath)
		reqPath := joinPath(parentPath, "required")
		reqVal := doc.Get(reqPath)
		hasRequiredProperties := reqVal.IsArray() && len(reqVal.Array()) > 0
		if doc.Get(joinPath(parentPath, "anyOf")).IsArray() || doc.Get(joinPath(parentPath, "oneOf")).IsArray() {
			continue
		}

		needsPlaceholder := false
		if !propsVal.Exists() {
			// No properties field at all
			needsPlaceholder = true
		} else if propsVal.IsObject() && len(propsVal.Map()) == 0 {
			// Empty properties object
			needsPlaceholder = true
		}

		if needsPlaceholder {
			// Add placeholder "reason" property
			reasonPath := joinPath(propsPath, "reason")
			_ = doc.Set(reasonPath+".type", "string")
			_ = doc.Set(reasonPath+".description", placeholderReasonDescription)
			_ = doc.Set(reqPath, []string{"reason"})
			modified = true
			continue
		}

		// If schema has properties but none are required, add a minimal placeholder.
		if propsVal.IsObject() && !hasRequiredProperties {
			// DO NOT add placeholder if it's a top-level schema (parentPath is empty)
			// or if we've already added a placeholder reason above.
			if parentPath == "" {
				continue
			}
			placeholderPath := joinPath(propsPath, "_")
			if !doc.Get(placeholderPath).Exists() {
				_ = doc.Set(placeholderPath+".type", "boolean")
			}
			_ = doc.Set(reqPath, []string{"_"})
			modified = true
		}
	}
	if modified {
		return doc.String()
	}
	return jsonStr
}

// --- Helpers ---

func findPaths(jsonStr, field string) []string {
	var paths []string
	WalkSchema(ojson.Parse(jsonStr), "", field, &paths)
	return paths
}

func findPathsByFields(jsonStr string, fields []string) map[string][]string {
	set := make(map[string]struct{}, len(fields))
	for _, field := range fields {
		set[field] = struct{}{}
	}
	paths := make(map[string][]string, len(set))
	walkForFields(ojson.Parse(jsonStr), "", set, paths)
	return paths
}

func walkForFields(value ojson.Result, path string, fields map[string]struct{}, paths map[string][]string) {
	switch value.Type {
	case ojson.JSON:
		value.ForEach(func(key, val ojson.Result) bool {
			keyStr := key.String()
			safeKey := escapeGJSONPathKey(keyStr)

			var childPath string
			if path == "" {
				childPath = safeKey
			} else {
				childPath = path + "." + safeKey
			}

			if _, ok := fields[keyStr]; ok {
				paths[keyStr] = append(paths[keyStr], childPath)
			}

			// Literal values are client data; schema-looking keys inside them are not keywords.
			// Under a name map the key is a property name instead, so keep descending there.
			if isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) {
				return true
			}

			walkForFields(val, childPath, fields, paths)
			return true
		})
	case ojson.String, ojson.Number, ojson.True, ojson.False, ojson.Null:
		// Terminal types - no further traversal needed
	}
}

func sortByDepth(paths []string) {
	sort.SliceStable(paths, func(i, j int) bool {
		return len(splitGJSONPath(paths[i])) > len(splitGJSONPath(paths[j]))
	})
}

func trimSuffix(path, suffix string) string {
	if path == strings.TrimPrefix(suffix, ".") {
		return ""
	}
	return strings.TrimSuffix(path, suffix)
}

func joinPath(base, suffix string) string {
	if base == "" {
		return suffix
	}
	return base + "." + suffix
}

func setRawAtDoc(doc *ojson.Doc, path, value string) {
	if path == "" {
		*doc = *ojson.NewDocString(value)
		return
	}
	_ = doc.SetRaw(path, value)
}

// schemaNameMapKeywords are the schema keywords whose value maps author-chosen names to
// subschemas. A key directly under one of them is a name, never a schema keyword. The Draft-07
// "dependencies" container qualifies for object (schema) values; array values are property-name
// lists and are never walked as maps by the checks that use this set.
var schemaNameMapKeywords = map[string]struct{}{
	"properties":        {},
	"patternProperties": {},
	"dependentSchemas":  {},
	"dependencies":      {},
	"$defs":             {},
	"definitions":       {},
}

// isPropertyDefinition reports whether path points at a map whose keys are names chosen by the
// tool author, so a key spelled like a schema keyword there must be preserved.
//
// A trailing ".properties" is not enough to tell: a tool may declare a property named
// "properties", and the schema for that property then sits at a path ending in ".properties" while
// being an ordinary schema node. Classifying it as a name map skipped every cleaning pass inside
// it, so unsupported keywords such as "propertyNames" reached the private Gemini backend, which
// rejects unknown fields with a 400.
//
// Each name-map keyword at the end of the path therefore flips the answer, because the node it
// names is a map only when its own parent is a schema: "properties" is a map,
// "properties.properties" the schema of a property named "properties", and
// "properties.properties.properties" that schema's own map. Only the trailing run matters, so any
// prefix the caller nests the schema under is ignored.
func isPropertyDefinition(path string) bool {
	segments := splitGJSONPath(path)
	trailing := 0
	for i := len(segments) - 1; i >= 0; i-- {
		if _, ok := schemaNameMapKeywords[unescapeGJSONPathKey(segments[i])]; !ok {
			break
		}
		trailing++
	}
	return trailing%2 == 1
}

func descriptionPath(parentPath string) string {
	if parentPath == "" || parentPath == "@this" {
		return "description"
	}
	return parentPath + ".description"
}

// mergeHint combines an existing description with a hint. Cleaning is not always a single pass:
// a schema may be cleaned by a translator and again by an executor, so an already-present hint is
// kept as-is instead of being appended a second time.
func mergeHint(existing, hint string) string {
	if existing == "" {
		return hint
	}
	// A hint added to an empty description is stored bare and later hints are appended after it, so
	// the bare form may sit alone, lead the description, or appear parenthesised further along.
	if existing == hint ||
		strings.HasPrefix(existing, hint+" (") ||
		strings.Contains(existing, fmt.Sprintf("(%s)", hint)) {
		return existing
	}
	return fmt.Sprintf("%s (%s)", existing, hint)
}

func appendHintDoc(doc *ojson.Doc, parentPath, hint string) {
	descPath := parentPath + ".description"
	if parentPath == "" || parentPath == "@this" {
		descPath = "description"
	}
	merged := mergeHint(doc.Get(descPath).String(), hint)
	_ = doc.Set(descPath, merged)
}

func getStrings(jsonStr, path string) []string {
	var result []string
	if arr := ojson.Get(jsonStr, path); arr.IsArray() {
		for _, r := range arr.Array() {
			result = append(result, r.String())
		}
	}
	return result
}

func getStringsDoc(doc *ojson.Doc, path string) []string {
	var result []string
	if arr := doc.Get(path); arr.IsArray() {
		for _, r := range arr.Array() {
			result = append(result, r.String())
		}
	}
	return result
}

func contains(slice []string, item string) bool {
	for _, s := range slice {
		if s == item {
			return true
		}
	}
	return false
}

func orDefault(val, def string) string {
	if val == "" {
		return def
	}
	return val
}

func escapeGJSONPathKey(key string) string {
	if strings.IndexAny(key, ".*?") == -1 {
		return key
	}
	return gjsonPathKeyReplacer.Replace(key)
}

func unescapeGJSONPathKey(key string) string {
	if !strings.Contains(key, "\\") {
		return key
	}
	var b strings.Builder
	b.Grow(len(key))
	for i := 0; i < len(key); i++ {
		if key[i] == '\\' && i+1 < len(key) {
			i++
			b.WriteByte(key[i])
			continue
		}
		b.WriteByte(key[i])
	}
	return b.String()
}

func splitGJSONPath(path string) []string {
	if path == "" {
		return nil
	}

	parts := make([]string, 0, strings.Count(path, ".")+1)
	var b strings.Builder
	b.Grow(len(path))

	for i := 0; i < len(path); i++ {
		c := path[i]
		if c == '\\' && i+1 < len(path) {
			b.WriteByte('\\')
			i++
			b.WriteByte(path[i])
			continue
		}
		if c == '.' {
			parts = append(parts, b.String())
			b.Reset()
			continue
		}
		b.WriteByte(c)
	}
	parts = append(parts, b.String())
	return parts
}

func mergeDescriptionDoc(doc *ojson.Doc, parentDesc string) bool {
	childDesc := doc.Get("description").String()
	switch {
	case childDesc == "":
		_ = doc.Set("description", parentDesc)
		return true
	case childDesc == parentDesc:
		return false
	default:
		combined := fmt.Sprintf("%s (%s)", parentDesc, childDesc)
		_ = doc.Set("description", combined)
		return true
	}
}