// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

//go:build ignore

// This script generates protocol definitions in protocol.go from the MCP spec.
//
// Only the set of declarations configured by the [declarations] value are
// generated.

package main

import (
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"go/format"
	"io"
	"log"
	"net/http"
	"os"
	"reflect"
	"slices"
	"strings"

	"golang.org/x/tools/internal/mcp/internal/jsonschema"
	"golang.org/x/tools/internal/mcp/internal/util"
)

var schemaFile = flag.String("schema_file", "", "if set, use this file as the persistent schema file")

// A typeConfig defines a rewrite to perform to a (possibly nested) struct
// field. In some cases, we may want to use an external type for the nested
// struct field. In others, we may want to extract the type definition to a
// name.
type typeConfig struct {
	Name       string // declaration name for the type
	Substitute string // type definition to substitute
	Fields     config // individual field configuration, or nil
}

type config map[string]*typeConfig

// declarations configures the set of declarations to write.
//
// Top level declarations are only created if they are configured with a
// non-empty Name. Otherwise, they are discarded, though their fields may be
// extracted to types if they have a nested field configuration.
var declarations = config{
	"Annotations": {Name: "Annotations"},
	"CallToolRequest": {
		Fields: config{"Params": {Name: "CallToolParams"}},
	},
	"CallToolResult": {Name: "CallToolResult"},
	"CancelledNotification": {
		Fields: config{"Params": {Name: "CancelledParams"}},
	},
	"ClientCapabilities": {Name: "ClientCapabilities"},
	"GetPromptRequest": {
		Fields: config{"Params": {Name: "GetPromptParams"}},
	},
	"GetPromptResult": {Name: "GetPromptResult"},
	"Implementation":  {Name: "Implementation"},
	"InitializeRequest": {
		Fields: config{"Params": {Name: "InitializeParams"}},
	},
	"InitializeResult": {Name: "InitializeResult"},
	"InitializedNotification": {
		Fields: config{"Params": {Name: "InitializedParams"}},
	},
	"ListPromptsRequest": {
		Fields: config{"Params": {Name: "ListPromptsParams"}},
	},
	"ListPromptsResult": {Name: "ListPromptsResult"},
	"ListToolsRequest": {
		Fields: config{"Params": {Name: "ListToolsParams"}},
	},
	"ListToolsResult": {Name: "ListToolsResult"},
	"Prompt":          {Name: "Prompt"},
	"PromptMessage":   {Name: "PromptMessage"},
	"PromptArgument":  {Name: "PromptArgument"},
	"RequestId":       {Substitute: "any"}, // null|number|string
	"Role":            {Name: "Role"},
	"ServerCapabilities": {
		Name: "ServerCapabilities",
		Fields: config{
			"Prompts":   {Name: "PromptCapabilities"},
			"Resources": {Name: "ResourceCapabilities"},
			"Tools":     {Name: "ToolCapabilities"},
		},
	},
	"Tool": {
		Name:   "Tool",
		Fields: config{"InputSchema": {Substitute: "*jsonschema.Schema"}},
	},
	"ToolAnnotations": {Name: "ToolAnnotations"},
}

func main() {
	flag.Parse()

	// Load and unmarshal the schema.
	data, err := loadSchema(*schemaFile)
	if err != nil {
		log.Fatal(err)
	}
	schema := new(jsonschema.Schema)
	if err := json.Unmarshal(data, &schema); err != nil {
		log.Fatal(err)
	}

	// Collect named types. Since we may create new type definitions while
	// writing types, we collect definitions and concatenate them later. This
	// also allows us to sort.
	named := make(map[string]*bytes.Buffer)
	for name, def := range util.Sorted(schema.Definitions) {
		config := declarations[name]
		if config == nil {
			continue
		}
		if err := writeDecl(*config, def, named); err != nil {
			log.Fatal(err)
		}
	}

	buf := new(bytes.Buffer)
	fmt.Fprintf(buf, `
// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Code generated by generate.go. DO NOT EDIT.

package protocol

import (
	"encoding/json"

	"golang.org/x/tools/internal/mcp/internal/jsonschema"
)
`)

	// Write out types.
	for _, b := range util.Sorted(named) {
		fmt.Fprintln(buf)
		fmt.Fprint(buf, b.String())
	}

	formatted, err := format.Source(buf.Bytes())
	if err != nil {
		log.Println(buf.String())
		log.Fatalf("failed to format: %v", err)
	}
	if err := os.WriteFile("protocol.go", formatted, 0666); err != nil {
		log.Fatalf("failed to write protocol.go: %v", err)
	}
}

func loadSchema(schemaFile string) (data []byte, err error) {
	const schemaURL = "https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/heads/main/schema/2025-03-26/schema.json"

	if schemaFile != "" {
		data, err = os.ReadFile(schemaFile)
		if os.IsNotExist(err) {
			data = nil
		} else if err != nil {
			return nil, fmt.Errorf("reading schema file %q: %v", schemaFile, err)
		}
	}
	if data == nil {
		resp, err := http.Get(schemaURL)
		if err != nil {
			return nil, fmt.Errorf("downloading schema: %v", err)
		}
		defer resp.Body.Close()
		if resp.StatusCode != http.StatusOK {
			return nil, fmt.Errorf("downloading schema: %v", resp.Status)
		}
		data, err = io.ReadAll(resp.Body)
		if err != nil {
			return nil, fmt.Errorf("reading schema body: %v", err)
		}
		if schemaFile != "" {
			if err := os.WriteFile(schemaFile, data, 0666); err != nil {
				return nil, fmt.Errorf("persisting schema: %v", err)
			}
		}
	}
	return data, nil
}

func writeDecl(config typeConfig, def *jsonschema.Schema, named map[string]*bytes.Buffer) error {
	var w io.Writer = io.Discard
	if name := config.Name; name != "" {
		if _, ok := named[name]; ok {
			return nil
		}
		buf := new(bytes.Buffer)
		w = buf
		named[name] = buf
		if def.Description != "" {
			fmt.Fprintf(buf, "%s\n", toComment(def.Description))
		}
		fmt.Fprintf(buf, "type %s ", name)
	}
	if err := writeType(w, &config, def, named); err != nil {
		return err // Better error here?
	}
	fmt.Fprintf(w, "\n")
	return nil
}

// writeType writes the type definition to the given writer.
//
// If path is non-empty, it is the path to the field using this type, for the
// purpose of detecting field rewrites (see [fieldRewrite]).
//
// named is the in-progress collection of type definitions. New named types may
// be added during writeType, if they are extracted from inner fields.
func writeType(w io.Writer, config *typeConfig, def *jsonschema.Schema, named map[string]*bytes.Buffer) error {
	// Use type names for Named types.
	if name := strings.TrimPrefix(def.Ref, "#/definitions/"); name != "" {
		// TODO: this check is not quite right: we should really panic if the
		// definition is missing, *but only if w is not io.Discard*. That's not a
		// great API: see if we can do something more explicit than io.Discard.
		if cfg, ok := declarations[name]; ok {
			if cfg.Name == "" && cfg.Substitute == "" {
				panic(fmt.Sprintf("referenced type %q cannot be referred to (no name or substitution)", name))
			}
			if cfg.Substitute != "" {
				name = cfg.Substitute
			} else {
				name = cfg.Name
			}
		}
		w.Write([]byte(name))
		return nil
	}

	// For types that explicitly allow additional properties, we can either
	// unmarshal them into a map[string]any, or delay unmarshalling with
	// json.RawMessage. For now, use json.RawMessage as it defers the choice.
	if def.Type == "object" && canHaveAdditionalProperties(def) {
		w.Write([]byte("map[string]"))
		return writeType(w, nil, def.AdditionalProperties, named)
	}

	if def.Type == "" {
		// special case: recognize Content
		if slices.ContainsFunc(def.AnyOf, func(s *jsonschema.Schema) bool {
			return s.Ref == "#/definitions/TextContent"
		}) {
			fmt.Fprintf(w, "Content")
		} else {
			// E.g. union types.
			fmt.Fprintf(w, "json.RawMessage")
		}
	} else {
		switch def.Type {
		case "array":
			fmt.Fprintf(w, "[]")
			return writeType(w, nil, def.Items, named)

		case "boolean":
			fmt.Fprintf(w, "bool")

		case "integer":
			fmt.Fprintf(w, "int64")

		// not handled: "null"

		case "number":
			// We could use json.Number here; use float64 for simplicity.
			fmt.Fprintf(w, "float64")

		case "object":
			fmt.Fprintf(w, "struct {\n")
			for name, fieldDef := range util.Sorted(def.Properties) {
				if fieldDef.Description != "" {
					fmt.Fprintf(w, "%s\n", toComment(fieldDef.Description))
				}
				export := exportName(name)
				fmt.Fprintf(w, "\t%s ", export)

				required := slices.Contains(def.Required, name)

				// If the field is not required, and is a struct type, indirect with a
				// pointer so that it can be empty as defined by encoding/json.
				//
				// TODO: use omitzero when available.
				needPointer := !required &&
					(strings.HasPrefix(fieldDef.Ref, "#/definitions/") ||
						fieldDef.Type == "object" && !canHaveAdditionalProperties(fieldDef))

				if config != nil && config.Fields[export] != nil {
					r := config.Fields[export]
					if r.Substitute != "" {
						fmt.Fprintf(w, r.Substitute)
					} else {
						assert(r.Name != "", "missing ExtractTo")
						if err := writeDecl(*r, fieldDef, named); err != nil {
							return err
						}
						if needPointer {
							fmt.Fprintf(w, "*")
						}
						fmt.Fprintf(w, r.Name)
					}
				} else {
					if needPointer {
						fmt.Fprintf(w, "*")
					}
					if err := writeType(w, nil, fieldDef, named); err != nil {
						return fmt.Errorf("failed to write type for field %s: %v", export, err)
					}
				}
				fmt.Fprintf(w, " `json:\"%s", name)
				if !required {
					fmt.Fprint(w, ",omitempty")
				}
				fmt.Fprint(w, "\"`\n")
			}
			fmt.Fprintf(w, "}")

		case "string":
			fmt.Fprintf(w, "string")

		default:
			fmt.Fprintf(w, "any")
		}
	}
	return nil
}

// toComment converts a JSON schema description to a Go comment.
func toComment(description string) string {
	var (
		buf     strings.Builder
		lineBuf strings.Builder
	)
	const wrapAt = 80
	for line := range strings.SplitSeq(description, "\n") {
		// Start a new paragraph, if the current is nonempty.
		if len(line) == 0 && lineBuf.Len() > 0 {
			buf.WriteString(lineBuf.String())
			lineBuf.Reset()
			buf.WriteString("\n//\n")
			continue
		}
		// Otherwise, fill in the current paragraph.
		for field := range strings.FieldsSeq(line) {
			if lineBuf.Len() > 0 && lineBuf.Len()+len(" ")+len(field) > wrapAt {
				buf.WriteString(lineBuf.String())
				buf.WriteRune('\n')
				lineBuf.Reset()
			}
			if lineBuf.Len() == 0 {
				lineBuf.WriteString("//")
			}
			lineBuf.WriteString(" ")
			lineBuf.WriteString(field)
		}
	}
	if lineBuf.Len() > 0 {
		buf.WriteString(lineBuf.String())
	}
	return strings.TrimRight(buf.String(), "\n")
}

// The MCP spec improperly uses the absence of the additionalProperties keyword to
// mean that additional properties are not allowed. In fact, it means just the opposite
// (https://json-schema.org/draft-07/draft-handrews-json-schema-validation-01#rfc.section.6.5.6).
// If the MCP spec wants to allow additional properties, it will write "true" or
// an object explicitly.
func canHaveAdditionalProperties(s *jsonschema.Schema) bool {
	ap := s.AdditionalProperties
	return ap != nil && !reflect.DeepEqual(ap, &jsonschema.Schema{Not: &jsonschema.Schema{}})
}

// exportName returns an exported name for a Go symbol, based on the given name
// in the JSON schema, removing leading underscores and capitalizing.
func exportName(s string) string {
	if strings.HasPrefix(s, "_") {
		s = s[1:]
	}
	return strings.ToUpper(s[:1]) + s[1:]
}

func assert(cond bool, msg string) {
	if !cond {
		panic(msg)
	}
}
