// Command reasonix-plugin-example is a reference Reasonix plugin: a minimal MCP stdio
// server speaking newline-delimited JSON-RPC 2.0 on stdin/stdout. It exists to
// document the contract end-to-end (the protocol the internal/plugin client
// drives) and to give users a working example to copy.
//
// Wire it up in reasonix.toml:
//
//	[[plugins]]
//	name    = "example"
//	command = "reasonix-plugin-example"
//
// Then reasonix surfaces its tools as "mcp__example__echo" / "mcp__example__wordcount",
// its prompt as the "/mcp__example__review" slash command, and its resource as
// the "@example:doc://style-guide" reference.
//
// Protocol, one JSON object per line:
//   - initialize                 → {protocolVersion, capabilities, serverInfo}
//   - notifications/initialized  (notification, no id) → ignored
//   - tools/list                 → {tools: [{name, description, inputSchema, annotations}]}
//   - tools/call {name, arguments} → {content: [{type:"text", text}], isError}
//   - prompts/list               → {prompts: [{name, description, arguments}]}
//   - prompts/get {name, arguments} → {messages: [{role, content:{type,text}}]}
//   - resources/list             → {resources: [{uri, name, description, mimeType}]}
//   - resources/read {uri}       → {contents: [{uri, mimeType, text}]}
//
// Logs go to stderr (reasonix forwards plugin stderr to the terminal); stdout is
// reserved for JSON-RPC so it must never carry stray prose.
package main

import (
	"bufio"
	"encoding/json"
	"fmt"
	"log"
	"os"
	"strings"
	"unicode/utf8"
)

// version is overridable via -ldflags "-X main.version=...". Reported in
// initialize's serverInfo so reasonix (and humans) can see which build is running.
var version = "dev"

func main() {
	log.SetPrefix("reasonix-plugin-example: ")
	log.SetFlags(0)
	if err := serve(os.Stdin, os.Stdout); err != nil {
		log.Fatal(err)
	}
}

// --- JSON-RPC framing ---

type request struct {
	JSONRPC string           `json:"jsonrpc"`
	ID      *json.RawMessage `json:"id"` // nil ⇒ notification (no reply); echoed back verbatim otherwise
	Method  string           `json:"method"`
	Params  json.RawMessage  `json:"params"`
}

type response struct {
	JSONRPC string          `json:"jsonrpc"`
	ID      json.RawMessage `json:"id"`
	Result  any             `json:"result,omitempty"`
	Error   *rpcError       `json:"error,omitempty"`
}

type rpcError struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
}

const (
	protocolVersion    = "2024-11-05"
	codeMethodNotFound = -32601
	codeInvalidParams  = -32602
)

// serve runs the read-dispatch-reply loop until stdin closes (reasonix closed the
// pipe / is shutting down). Each line is one JSON-RPC message.
func serve(in *os.File, out *os.File) error {
	r := bufio.NewReader(in)
	w := bufio.NewWriter(out)
	defer w.Flush()

	for {
		line, err := r.ReadBytes('\n')
		if len(line) > 0 {
			if rerr := handleLine(line, w); rerr != nil {
				return rerr
			}
			if ferr := w.Flush(); ferr != nil {
				return ferr
			}
		}
		if err != nil {
			return nil // EOF or pipe closed: clean shutdown
		}
	}
}

func handleLine(line []byte, w *bufio.Writer) error {
	line = trimSpace(line)
	if len(line) == 0 {
		return nil
	}
	var req request
	if err := json.Unmarshal(line, &req); err != nil {
		log.Printf("skipping unparseable line: %v", err)
		return nil
	}
	if req.ID == nil {
		return nil // notification (e.g. notifications/initialized): no reply
	}

	resp := response{JSONRPC: "2.0", ID: *req.ID}
	switch req.Method {
	case "initialize":
		resp.Result = map[string]any{
			"protocolVersion": protocolVersion,
			"capabilities": map[string]any{
				"tools":     map[string]any{},
				"prompts":   map[string]any{},
				"resources": map[string]any{},
			},
			"serverInfo": map[string]any{"name": "reasonix-plugin-example", "version": version},
		}
	case "tools/list":
		resp.Result = map[string]any{"tools": toolList()}
	case "tools/call":
		resp.Result, resp.Error = callTool(req.Params)
	case "prompts/list":
		resp.Result = map[string]any{"prompts": promptList()}
	case "prompts/get":
		resp.Result, resp.Error = getPrompt(req.Params)
	case "resources/list":
		resp.Result = map[string]any{"resources": resourceList()}
	case "resources/read":
		resp.Result, resp.Error = readResource(req.Params)
	default:
		resp.Error = &rpcError{Code: codeMethodNotFound, Message: "method not found: " + req.Method}
	}

	b, err := json.Marshal(resp)
	if err != nil {
		return fmt.Errorf("marshal response: %w", err)
	}
	if _, err := w.Write(append(b, '\n')); err != nil {
		return err
	}
	return nil
}

// --- tools ---

// toolDef is one exposed tool: its advertised metadata plus the handler. run
// returns the text result, or an error which becomes an isError tool result the
// model can read and adapt to.
type toolDef struct {
	name        string
	description string
	schema      map[string]any
	readOnly    bool
	run         func(args map[string]any) (string, error)
}

// tools is the registry. Both demo tools are read-only and declare it via the
// readOnlyHint annotation, so reasonix runs them in parallel batches and (with the
// permission layer) auto-allows them without prompting.
var tools = []toolDef{
	{
		name:        "echo",
		description: "Echo the given text back. The simplest possible proof the plugin round-trip works.",
		readOnly:    true,
		schema: map[string]any{
			"type": "object",
			"properties": map[string]any{
				"text": map[string]any{"type": "string", "description": "Text to echo back"},
			},
			"required": []string{"text"},
		},
		run: func(args map[string]any) (string, error) {
			text, ok := args["text"].(string)
			if !ok {
				return "", fmt.Errorf("argument 'text' must be a string")
			}
			return text, nil
		},
	},
	{
		name:        "wordcount",
		description: "Count the lines, words, and bytes of the given text (like wc).",
		readOnly:    true,
		schema: map[string]any{
			"type": "object",
			"properties": map[string]any{
				"text": map[string]any{"type": "string", "description": "Text to measure"},
			},
			"required": []string{"text"},
		},
		run: func(args map[string]any) (string, error) {
			text, ok := args["text"].(string)
			if !ok {
				return "", fmt.Errorf("argument 'text' must be a string")
			}
			return fmt.Sprintf("lines: %d, words: %d, bytes: %d, runes: %d",
				countLines(text), len(strings.Fields(text)), len(text), utf8.RuneCountInString(text)), nil
		},
	},
}

func toolList() []map[string]any {
	out := make([]map[string]any, 0, len(tools))
	for _, t := range tools {
		out = append(out, map[string]any{
			"name":        t.name,
			"description": t.description,
			"inputSchema": t.schema,
			"annotations": map[string]any{
				"readOnlyHint": t.readOnly,
				"title":        t.name,
			},
		})
	}
	return out
}

// callTool dispatches a tools/call. A handler error is reported as an isError
// content result (in-band, so the model sees it) rather than a JSON-RPC error;
// JSON-RPC errors are reserved for protocol-level faults (bad params, unknown
// tool).
func callTool(params json.RawMessage) (any, *rpcError) {
	var p struct {
		Name      string         `json:"name"`
		Arguments map[string]any `json:"arguments"`
	}
	if err := json.Unmarshal(params, &p); err != nil {
		return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
	}
	for _, t := range tools {
		if t.name != p.Name {
			continue
		}
		text, err := t.run(p.Arguments)
		if err != nil {
			return textResult(err.Error(), true), nil
		}
		return textResult(text, false), nil
	}
	return nil, &rpcError{Code: codeInvalidParams, Message: "unknown tool: " + p.Name}
}

func textResult(text string, isError bool) map[string]any {
	return map[string]any{
		"content": []map[string]any{{"type": "text", "text": text}},
		"isError": isError,
	}
}

// --- prompts ---

// promptList advertises the server's prompts. They surface in reasonix as
// /mcp__example__<name> slash commands.
func promptList() []map[string]any {
	return []map[string]any{{
		"name":        "review",
		"description": "Draft a focused code-review request for a file.",
		"arguments": []map[string]any{
			{"name": "path", "description": "File to review", "required": true},
		},
	}}
}

// getPrompt renders a prompt into MCP messages. The returned text becomes the
// next user turn in reasonix, so it's phrased as an instruction to the model.
func getPrompt(params json.RawMessage) (any, *rpcError) {
	var p struct {
		Name      string            `json:"name"`
		Arguments map[string]string `json:"arguments"`
	}
	if err := json.Unmarshal(params, &p); err != nil {
		return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
	}
	if p.Name != "review" {
		return nil, &rpcError{Code: codeInvalidParams, Message: "unknown prompt: " + p.Name}
	}
	path := p.Arguments["path"]
	if path == "" {
		path = "the current file"
	}
	text := fmt.Sprintf("Please review %s. Read it, then list any correctness bugs and risky patterns with file:line references, most important first.", path)
	return map[string]any{
		"description": "Code review request",
		"messages": []map[string]any{
			{"role": "user", "content": map[string]any{"type": "text", "text": text}},
		},
	}, nil
}

// --- resources ---

// resourceContents is the demo resource store, keyed by uri.
var resourceContents = map[string]string{
	"doc://style-guide": "Project style: tabs for indentation; comments explain why, not what; keep functions short.",
}

func resourceList() []map[string]any {
	return []map[string]any{{
		"uri":         "doc://style-guide",
		"name":        "Style guide",
		"description": "The project's coding style notes.",
		"mimeType":    "text/plain",
	}}
}

func readResource(params json.RawMessage) (any, *rpcError) {
	var p struct {
		URI string `json:"uri"`
	}
	if err := json.Unmarshal(params, &p); err != nil {
		return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()}
	}
	text, ok := resourceContents[p.URI]
	if !ok {
		return nil, &rpcError{Code: codeInvalidParams, Message: "unknown resource: " + p.URI}
	}
	return map[string]any{
		"contents": []map[string]any{
			{"uri": p.URI, "mimeType": "text/plain", "text": text},
		},
	}, nil
}

// countLines counts newline-separated lines, counting a final unterminated line.
func countLines(s string) int {
	if s == "" {
		return 0
	}
	n := strings.Count(s, "\n")
	if !strings.HasSuffix(s, "\n") {
		n++
	}
	return n
}

// trimSpace trims leading/trailing ASCII whitespace without pulling in bytes.
func trimSpace(b []byte) []byte {
	return []byte(strings.TrimSpace(string(b)))
}
