// Package generate implements AI-powered service generation for go-micro.
// It uses an LLM to design service architecture and generate handler code
// with real business logic, then compiles and fixes errors iteratively.
package generate

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"go-micro.dev/v6/ai"

	_ "go-micro.dev/v6/ai/anthropic"
	_ "go-micro.dev/v6/ai/atlascloud"
	_ "go-micro.dev/v6/ai/gemini"
	_ "go-micro.dev/v6/ai/groq"
	_ "go-micro.dev/v6/ai/mistral"
	_ "go-micro.dev/v6/ai/openai"
	_ "go-micro.dev/v6/ai/together"
)

// goMicroVersion is the go-micro.dev/v6 release pinned into the go.mod of
// every scaffolded project. This is the single source of truth — bump it
// here when cutting a release so generated services and agents stay in
// sync with the framework.
const goMicroVersion = "v6.0.0"

const designPrompt = `You are a Go microservices architect using the go-micro framework.
Given a system description, design the services needed.

Return ONLY valid JSON:
{
  "services": [
    {
      "name": "service-name",
      "description": "What this service does",
      "fields": [
        {"name": "field_name", "type": "string", "description": "What this field is"}
      ],
      "endpoints": [
        {"name": "EndpointName", "description": "What this endpoint does", "example": "{\"key\": \"value\"}"}
      ]
    }
  ]
}

Rules:
- Service names are lowercase, hyphenated, WITHOUT a "-service" suffix (e.g. "task" not "task-service", "shipping" not "shipping-service")
- Each service MUST have CRUD endpoints: Create, Read, Update, Delete, List
- Add 1-3 custom endpoints for real business logic (e.g. PlaceOrder, CheckInventory)
- Field types: string, int64, bool, float64
- Every service needs id (string), created (int64), updated (int64) fields
- Endpoint names are PascalCase
- Examples should be realistic JSON
- 2-4 services max, focused on the domain
- Keep services small and focused — one concern per service, max 5-8 fields
- Services don't call each other; an AI agent orchestrates across them`

const designPromptWithExisting = `You are a Go microservices architect using the go-micro framework.
The user has an EXISTING system with services already running. They want to extend or modify it.

Existing services:
%s

Given the user's request, return the COMPLETE set of services (existing + new/modified).
For existing services the user hasn't asked to change, return them as-is.
For new or modified services, include the full specification.

Return ONLY valid JSON:
{
  "services": [
    {
      "name": "service-name",
      "description": "What this service does",
      "fields": [
        {"name": "field_name", "type": "string", "description": "What this field is"}
      ],
      "endpoints": [
        {"name": "EndpointName", "description": "What this endpoint does", "example": "{\"key\": \"value\"}"}
      ]
    }
  ]
}

Rules:
- Service names are lowercase, hyphenated, WITHOUT a "-service" suffix (e.g. "task" not "task-service", "shipping" not "shipping-service")
- Each service MUST have CRUD endpoints: Create, Read, Update, Delete, List
- Add custom endpoints for real business logic
- Field types: string, int64, bool, float64
- Every service needs id (string), created (int64), updated (int64) fields
- Endpoint names are PascalCase
- Examples should be realistic JSON
- Keep existing services unless the user explicitly asks to change them`

const handlerPrompt = `You are a Go developer writing a handler for a go-micro service.
Generate a COMPLETE, COMPILABLE Go handler file.

The handler must:
1. Use package "handler"
2. Import the proto package as: pb "%s/proto"
3. Import go-micro logger as: log "go-micro.dev/v6/logger"
4. Import "github.com/google/uuid" for ID generation
5. Use "go-micro.dev/v6/store" for persistent storage (NOT in-memory maps)
6. Include REAL business logic — not just CRUD store operations
7. Every exported method must have a doc comment explaining what it does
8. Every method must have an @example tag with realistic JSON input
9. Handle edge cases, validation, and return meaningful errors
10. Keep the file under 200 lines — be concise, no boilerplate

For storage, use the go-micro store package:
  import "go-micro.dev/v6/store"
  import "encoding/json"

  // In the struct:
  store store.Store

  // In the constructor:
  func New() *%s { return &%s{store: store.DefaultStore} }

  // Write a record:
  data, _ := json.Marshal(record)
  store.Write(&store.Record{Key: "prefix/" + id, Value: data})

  // Read a record:
  recs, err := store.Read("prefix/" + id)
  json.Unmarshal(recs[0].Value, &record)

  // List keys:
  keys, _ := store.List(store.ListPrefix("prefix/"))

  // Delete:
  store.Delete("prefix/" + id)

Do NOT use sync.Mutex or in-memory maps. Use store for all data.

The struct name is %s.
The constructor is func New() *%s.

Here is the proto definition:
%s

Here is what each endpoint should do:
%s

Return ONLY the Go code. No markdown, no explanation. Just the .go file content starting with "package handler".`

// ServiceDesign is the LLM's output.
type ServiceDesign struct {
	Services []ServiceSpec `json:"services"`
}

type ServiceSpec struct {
	Name        string         `json:"name"`
	Description string         `json:"description"`
	Fields      []FieldSpec    `json:"fields"`
	Endpoints   []EndpointSpec `json:"endpoints"`
}

type FieldSpec struct {
	Name        string `json:"name"`
	Type        string `json:"type"`
	Description string `json:"description"`
}

type EndpointSpec struct {
	Name        string `json:"name"`
	Description string `json:"description"`
	Example     string `json:"example"`
}

// Design calls an LLM to design services from a prompt.
// If baseDir contains existing services, they are included as context
// so the LLM extends the system rather than redesigning from scratch.
func Design(ctx context.Context, provider, apiKey, model, baseDir, prompt string) (*ServiceDesign, error) {
	m := newModel(provider, apiKey, model)
	if m == nil {
		return nil, fmt.Errorf("unknown provider: %s", provider)
	}

	existing := discoverExisting(baseDir)

	var sysPrompt, userPrompt string
	if len(existing) > 0 {
		sysPrompt = fmt.Sprintf(designPromptWithExisting, existing)
		userPrompt = fmt.Sprintf("Extend or modify the system: %s", prompt)
	} else {
		sysPrompt = designPrompt
		userPrompt = fmt.Sprintf("Design a microservices system for: %s", prompt)
	}

	sp := startSpinner("designing services...")
	designCtx, designCancel := context.WithTimeout(ctx, 60*time.Second)
	defer designCancel()
	resp, err := m.Generate(designCtx, &ai.Request{
		Prompt:       userPrompt,
		SystemPrompt: sysPrompt,
	})
	sp.Stop()
	if err != nil {
		return nil, fmt.Errorf("design failed: %w", err)
	}

	reply := firstNonEmpty(resp.Answer, resp.Reply)
	reply = extractJSON(reply)

	var design ServiceDesign
	if err := json.Unmarshal([]byte(reply), &design); err != nil {
		return nil, fmt.Errorf("failed to parse design: %w\nResponse: %s", err, reply)
	}
	if len(design.Services) == 0 {
		return nil, fmt.Errorf("no services designed")
	}
	return &design, nil
}

// discoverExisting scans a directory for existing go-micro services
// and returns a summary string for inclusion in the design prompt.
func discoverExisting(baseDir string) string {
	entries, err := os.ReadDir(baseDir)
	if err != nil {
		return ""
	}

	var summaries []string
	for _, e := range entries {
		if !e.IsDir() {
			continue
		}
		svcDir := filepath.Join(baseDir, e.Name())

		// Look for proto files as indicator of a go-micro service
		protoDir := filepath.Join(svcDir, "proto")
		protos, err := filepath.Glob(filepath.Join(protoDir, "*.proto"))
		if err != nil || len(protos) == 0 {
			continue
		}

		proto := readFile(protos[0])
		if proto == "" {
			continue
		}

		summaries = append(summaries, fmt.Sprintf("### %s\nProto:\n```\n%s\n```", e.Name(), proto))
	}

	return strings.Join(summaries, "\n\n")
}

// Generate creates go-micro service directories from a design.
// If a service directory already exists, it skips structure generation
// but regenerates the handler (allowing iterative improvement).
func Generate(ctx context.Context, baseDir string, design *ServiceDesign, provider, apiKey, model string) error {
	m := newModel(provider, apiKey, model)

	for i, svc := range design.Services {
		if ctx.Err() != nil {
			return ctx.Err()
		}
		svcDir := filepath.Join(baseDir, svc.Name)
		handlerFile := filepath.Join(svcDir, "handler", svc.Name+".go")
		protoFile := filepath.Join(svcDir, "proto", svc.Name+".proto")

		// Snapshot proto hash before structure generation
		protoBefore := fileHash(protoFile)

		fmt.Printf("    \033[2m[%d/%d]\033[0m generating \033[36m%s\033[0m...\n", i+1, len(design.Services), svc.Name)

		// Step 1: Generate proto (deterministic — from design spec)
		if err := generateStructure(svcDir, svc); err != nil {
			return fmt.Errorf("structure %s: %w", svc.Name, err)
		}

		protoAfter := fileHash(protoFile)
		protoChanged := protoBefore != protoAfter

		// If proto unchanged and handler unmodified, nothing to do
		if !protoChanged && protoBefore != "" && !handlerModified(svcDir, handlerFile) {
			fmt.Printf("    \033[32m✓\033[0m %s \033[2m(unchanged)\033[0m\n", svc.Name)
			continue
		}

		// Step 2: Run go mod tidy + make proto to get compiled proto
		_ = runIn(svcDir, "go", "mod", "tidy")
		_ = runIn(svcDir, "make", "proto")

		// Step 3: Generate handler with business logic (LLM)
		proto := readFile(protoFile)
		if err := generateHandler(ctx, m, svcDir, svc, proto); err != nil {
			return fmt.Errorf("handler %s: %w", svc.Name, err)
		}

		// Step 4: Compile-fix loop
		if err := compileFix(ctx, m, svcDir, svc.Name, 3); err != nil {
			fmt.Printf("    \033[33m⚠\033[0m %s has compile errors (may need manual fix)\n", svc.Name)
		} else {
			fmt.Printf("    \033[32m✓\033[0m %s\n", svc.Name)
		}

		// Record final handler hash (after any compile fixes)
		recordHandlerHash(svcDir, handlerFile)
	}

	// Generate an agent that manages all the services
	var svcNames []string
	for _, svc := range design.Services {
		svcNames = append(svcNames, svc.Name)
	}
	if err := generateAgent(baseDir, design, svcNames); err != nil {
		fmt.Printf("    \033[33m⚠\033[0m agent generation failed: %v\n", err)
	}

	return nil
}

// generateStructure creates the proto, main.go, go.mod, Makefile.
// If the directory already exists, only regenerates the proto
// (handler will be regenerated separately by the LLM).
func generateStructure(dir string, svc ServiceSpec) error {
	exists := false
	if _, err := os.Stat(dir); err == nil {
		exists = true
	}
	_ = os.MkdirAll(filepath.Join(dir, "handler"), 0755)
	_ = os.MkdirAll(filepath.Join(dir, "proto"), 0755)

	name := svc.Name
	titleName := toTitle(name)
	dehyphen := strings.ReplaceAll(name, "-", "")

	// Regenerate proto unless user has modified it
	protoPath := filepath.Join(dir, "proto", name+".proto")
	if !fileModified(dir, "proto_hash", protoPath) {
		writeFile(protoPath, buildProto(dehyphen, titleName, svc))
		recordFileHash(dir, "proto_hash", protoPath)
	} else {
		fmt.Printf("    \033[2mkeeping %s proto (modified)\033[0m\n", name)
	}

	// Only write structural files if directory is new
	if !exists {
		writeFile(filepath.Join(dir, "main.go"), buildMain(name, titleName))

		writeFile(filepath.Join(dir, "Makefile"),
			"GOPATH:=$(shell go env GOPATH)\n\n.PHONY: proto\nproto:\n\tprotoc --proto_path=. --micro_out=. --go_out=. proto/*.proto\n")

		writeFile(filepath.Join(dir, "go.mod"),
			fmt.Sprintf("module %s\n\ngo 1.24\n\nrequire go-micro.dev/v6 %s\n", name, goMicroVersion))

		writeFile(filepath.Join(dir, ".gitignore"),
			fmt.Sprintf("%s\n.micro\n", name))
	}

	// Placeholder handler so go mod tidy works (will be overwritten by LLM)
	handlerPath := filepath.Join(dir, "handler", name+".go")
	if _, err := os.Stat(handlerPath); os.IsNotExist(err) {
		writeFile(handlerPath,
			fmt.Sprintf("package handler\n\ntype %s struct{}\n\nfunc New() *%s { return &%s{} }\n", titleName, titleName, titleName))
		recordHandlerHash(dir, handlerPath)
	}

	return nil
}

// generateHandler asks the LLM to write the handler with business logic.
// If the handler exists and the user has modified it since generation,
// it is left untouched.
func generateHandler(ctx context.Context, m ai.Model, dir string, svc ServiceSpec, proto string) error {
	if m == nil {
		return nil // no LLM — keep the placeholder
	}

	handlerFile := filepath.Join(dir, "handler", svc.Name+".go")

	if handlerModified(dir, handlerFile) {
		fmt.Printf("    \033[2mkeeping %s handler (modified)\033[0m\n", svc.Name)
		return nil
	}

	titleName := toTitle(svc.Name)

	// Build endpoint descriptions
	var epDescs []string
	for _, ep := range svc.Endpoints {
		epDescs = append(epDescs, fmt.Sprintf("- %s: %s (example input: %s)", ep.Name, ep.Description, ep.Example))
	}

	prompt := fmt.Sprintf(handlerPrompt,
		svc.Name, titleName, titleName, titleName, titleName, proto, strings.Join(epDescs, "\n"))

	sp := startSpinner(fmt.Sprintf("writing %s handler...", svc.Name))
	genCtx, genCancel := context.WithTimeout(ctx, 90*time.Second)
	defer genCancel()
	resp, err := m.Generate(genCtx, &ai.Request{
		Prompt:       fmt.Sprintf("Generate the handler for the %s service with real business logic.", svc.Name),
		SystemPrompt: prompt,
	})
	sp.Stop()
	if err != nil {
		return err
	}

	code := firstNonEmpty(resp.Answer, resp.Reply)
	code = extractCode(code)

	if !strings.HasPrefix(strings.TrimSpace(code), "package") {
		return fmt.Errorf("LLM did not return valid Go code")
	}

	if isTruncated(code) {
		fmt.Printf("    \033[33m→\033[0m response truncated, retrying...\n")
		sp = startSpinner(fmt.Sprintf("rewriting %s handler...", svc.Name))
		retryCtx, retryCancel := context.WithTimeout(ctx, 90*time.Second)
		defer retryCancel()
		resp, err = m.Generate(retryCtx, &ai.Request{
			Prompt:       fmt.Sprintf("Generate the handler for the %s service with real business logic. Keep it concise — no more than 200 lines.", svc.Name),
			SystemPrompt: prompt,
		})
		sp.Stop()
		if err != nil {
			return err
		}
		code = firstNonEmpty(resp.Answer, resp.Reply)
		code = extractCode(code)
	}

	if !strings.HasPrefix(strings.TrimSpace(code), "package") {
		return fmt.Errorf("LLM did not return valid Go code")
	}

	writeFile(handlerFile, code)
	recordHandlerHash(dir, handlerFile)
	return nil
}

// compileFix tries to compile, and if it fails, sends the error to
// the LLM to fix. Up to maxAttempts iterations.
func compileFix(ctx context.Context, m ai.Model, dir, name string, maxAttempts int) error {
	for attempt := 0; attempt < maxAttempts; attempt++ {
		cmd := exec.Command("go", "build", "./...")
		cmd.Dir = dir
		out, err := cmd.CombinedOutput()
		if err == nil {
			return nil // compiles!
		}

		if m == nil {
			return fmt.Errorf("compile failed: %s", string(out))
		}

		// Read current handler
		handlerPath := filepath.Join(dir, "handler", name+".go")
		currentCode := readFile(handlerPath)

		sp := startSpinner(fmt.Sprintf("fixing compile errors (attempt %d/%d)...", attempt+1, maxAttempts))
		fixCtx, fixCancel := context.WithTimeout(ctx, 60*time.Second)
		resp, fixErr := m.Generate(fixCtx, &ai.Request{
			Prompt: fmt.Sprintf("This Go code has compile errors. Fix ALL of them and return the COMPLETE corrected file.\n\nErrors:\n%s\n\nCode:\n%s",
				string(out), currentCode),
			SystemPrompt: "You are a Go expert. Return ONLY the corrected Go code. No markdown, no explanation. Start with 'package handler'.",
		})
		fixCancel()
		sp.Stop()
		if fixErr != nil {
			return fmt.Errorf("fix attempt failed: %w", fixErr)
		}

		fixed := firstNonEmpty(resp.Answer, resp.Reply)
		fixed = extractCode(fixed)
		if strings.HasPrefix(strings.TrimSpace(fixed), "package") && !isTruncated(fixed) {
			writeFile(handlerPath, fixed)
		}
	}

	// Final check
	cmd := exec.Command("go", "build", "./...")
	cmd.Dir = dir
	if out, err := cmd.CombinedOutput(); err != nil {
		return fmt.Errorf("still fails after %d attempts: %s", maxAttempts, string(out))
	}
	return nil
}

func newModel(provider, apiKey, model string) ai.Model {
	if provider == "" {
		provider = ai.AutoDetectProvider("")
	}
	var opts []ai.Option
	opts = append(opts, ai.WithAPIKey(apiKey))
	if model != "" {
		opts = append(opts, ai.WithModel(model))
	}
	return ai.New(provider, opts...)
}

func buildProto(dehyphen, titleName string, svc ServiceSpec) string {
	var b strings.Builder
	fmt.Fprintf(&b, "syntax = \"proto3\";\n\npackage %s;\n\noption go_package = \"./proto;%s\";\n\n", dehyphen, dehyphen)

	fmt.Fprintf(&b, "service %s {\n", titleName)
	for _, ep := range svc.Endpoints {
		fmt.Fprintf(&b, "\trpc %s(%sRequest) returns (%sResponse) {}\n", ep.Name, ep.Name, ep.Name)
	}
	b.WriteString("}\n\n")

	// Record message
	fmt.Fprintf(&b, "message %sRecord {\n", titleName)
	for i, f := range svc.Fields {
		fmt.Fprintf(&b, "\t%s %s = %d; // %s\n", protoType(f.Type), f.Name, i+1, f.Description)
	}
	b.WriteString("}\n\n")

	// Request/response for each endpoint
	for _, ep := range svc.Endpoints {
		switch ep.Name {
		case "Create":
			b.WriteString("message CreateRequest {\n")
			n := 1
			for _, f := range svc.Fields {
				if f.Name == "id" || f.Name == "created" || f.Name == "updated" {
					continue
				}
				fmt.Fprintf(&b, "\t%s %s = %d;\n", protoType(f.Type), f.Name, n)
				n++
			}
			fmt.Fprintf(&b, "}\n\nmessage CreateResponse {\n\t%sRecord record = 1;\n}\n\n", titleName)
		case "Read":
			fmt.Fprintf(&b, "message ReadRequest {\n\tstring id = 1;\n}\n\nmessage ReadResponse {\n\t%sRecord record = 1;\n}\n\n", titleName)
		case "Update":
			b.WriteString("message UpdateRequest {\n\tstring id = 1;\n")
			n := 2
			for _, f := range svc.Fields {
				if f.Name == "id" || f.Name == "created" || f.Name == "updated" {
					continue
				}
				fmt.Fprintf(&b, "\t%s %s = %d;\n", protoType(f.Type), f.Name, n)
				n++
			}
			fmt.Fprintf(&b, "}\n\nmessage UpdateResponse {\n\t%sRecord record = 1;\n}\n\n", titleName)
		case "Delete":
			b.WriteString("message DeleteRequest {\n\tstring id = 1;\n}\n\nmessage DeleteResponse {\n\tbool deleted = 1;\n}\n\n")
		case "List":
			fmt.Fprintf(&b, "message ListRequest {\n\tint64 limit = 1;\n\tint64 offset = 2;\n\tstring query = 3;\n}\n\nmessage ListResponse {\n\trepeated %sRecord records = 1;\n\tint64 total = 2;\n}\n\n", titleName)
		default:
			// Custom endpoint — use all fields as input, record as output
			fmt.Fprintf(&b, "message %sRequest {\n", ep.Name)
			n := 1
			for _, f := range svc.Fields {
				if f.Name == "created" || f.Name == "updated" {
					continue
				}
				fmt.Fprintf(&b, "\t%s %s = %d;\n", protoType(f.Type), f.Name, n)
				n++
			}
			fmt.Fprintf(&b, "}\n\nmessage %sResponse {\n\t%sRecord record = 1;\n\tstring message = 2;\n\tbool success = 3;\n}\n\n", ep.Name, titleName)
		}
	}
	return b.String()
}

func buildMain(name, titleName string) string {
	svcName := strings.TrimSuffix(name, "-service")
	return fmt.Sprintf(`package main

import (
	"%s/handler"
	pb "%s/proto"

	"go-micro.dev/v6"
	"go-micro.dev/v6/gateway/mcp"
)

func main() {
	service := micro.NewService("%s",
		mcp.WithMCP(":0"),
	)
	service.Init()
	pb.Register%sHandler(service.Server(), handler.New())
	service.Run()
}
`, name, name, svcName, titleName)
}

func generateAgent(baseDir string, design *ServiceDesign, svcNames []string) error {
	agentName := "agent"
	agentDir := filepath.Join(baseDir, agentName)

	if _, err := os.Stat(agentDir); err == nil {
		return nil // already exists
	}

	_ = os.MkdirAll(agentDir, 0755)

	// Build a description of all services for the agent prompt
	var svcDescs []string
	for _, svc := range design.Services {
		var eps []string
		for _, ep := range svc.Endpoints {
			eps = append(eps, ep.Name)
		}
		svcDescs = append(svcDescs, fmt.Sprintf("- %s: %s (%s)", svc.Name, svc.Description, strings.Join(eps, ", ")))
	}

	prompt := fmt.Sprintf("You manage these services:\\n%s\\nUse the available tools to fulfill requests. Be helpful and concise.", strings.Join(svcDescs, "\\n"))
	quoted := strings.Join(svcNames, `", "`)

	writeFile(filepath.Join(agentDir, "main.go"), fmt.Sprintf(`package main

import (
	"os"

	"go-micro.dev/v6"
)

func main() {
	agent := micro.NewAgent("agent",
		micro.AgentServices("%s"),
		micro.AgentPrompt("%s"),
		micro.AgentProvider(os.Getenv("MICRO_AI_PROVIDER")),
		micro.AgentAPIKey(os.Getenv("MICRO_AI_API_KEY")),
	)
	agent.Init()
	agent.Run()
}
`, quoted, prompt))

	writeFile(filepath.Join(agentDir, "go.mod"),
		fmt.Sprintf("module %s\n\ngo 1.24\n\nrequire go-micro.dev/v6 %s\n", agentName, goMicroVersion))

	_ = runIn(agentDir, "go", "mod", "tidy")

	fmt.Printf("    \033[35m◆\033[0m agent \033[2m(manages: %s)\033[0m\n", strings.Join(svcNames, ", "))
	return nil
}

func extractJSON(s string) string {
	if i := strings.Index(s, "```json"); i >= 0 {
		s = s[i+7:]
		if j := strings.Index(s, "```"); j >= 0 {
			return strings.TrimSpace(s[:j])
		}
	}
	if i := strings.Index(s, "```"); i >= 0 {
		s = s[i+3:]
		if j := strings.Index(s, "```"); j >= 0 {
			return strings.TrimSpace(s[:j])
		}
	}
	if i := strings.Index(s, "{"); i >= 0 {
		depth := 0
		for j := i; j < len(s); j++ {
			switch s[j] {
			case '{':
				depth++
			case '}':
				depth--
				if depth == 0 {
					return s[i : j+1]
				}
			}
		}
	}
	return s
}

func extractCode(s string) string {
	if i := strings.Index(s, "```go"); i >= 0 {
		s = s[i+5:]
		if j := strings.Index(s, "```"); j >= 0 {
			return strings.TrimSpace(s[:j])
		}
	}
	if i := strings.Index(s, "```"); i >= 0 {
		s = s[i+3:]
		if j := strings.Index(s, "```"); j >= 0 {
			return strings.TrimSpace(s[:j])
		}
	}
	// Try to find raw package declaration
	if i := strings.Index(s, "package "); i >= 0 {
		return strings.TrimSpace(s[i:])
	}
	return strings.TrimSpace(s)
}

func isTruncated(code string) bool {
	trimmed := strings.TrimSpace(code)
	if len(trimmed) == 0 {
		return true
	}
	// Valid Go files end with a closing brace
	if trimmed[len(trimmed)-1] != '}' {
		return true
	}
	// Check balanced braces
	depth := 0
	for _, c := range trimmed {
		switch c {
		case '{':
			depth++
		case '}':
			depth--
		}
	}
	return depth != 0
}

func protoType(t string) string {
	switch t {
	case "int64":
		return "int64"
	case "int32":
		return "int32"
	case "bool":
		return "bool"
	case "float64":
		return "double"
	default:
		return "string"
	}
}

func toTitle(s string) string {
	words := strings.FieldsFunc(s, func(r rune) bool { return r == '-' || r == '_' || r == ' ' })
	for i, w := range words {
		if len(w) > 0 {
			words[i] = strings.ToUpper(w[:1]) + w[1:]
		}
	}
	return strings.Join(words, "")
}

func firstNonEmpty(vals ...string) string {
	for _, v := range vals {
		if v != "" {
			return v
		}
	}
	return ""
}

func readFile(path string) string {
	b, _ := os.ReadFile(path)
	return string(b)
}

func writeFile(path, content string) {
	_ = os.WriteFile(path, []byte(content), 0644)
}

func runIn(dir string, name string, args ...string) error {
	cmd := exec.Command(name, args...)
	cmd.Dir = dir
	cmd.Env = append(os.Environ(), "PATH="+os.Getenv("PATH")+":"+os.Getenv("GOPATH")+"/bin:"+os.Getenv("HOME")+"/go/bin")
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	return cmd.Run()
}

type spinner struct {
	msg  string
	stop chan struct{}
	done sync.WaitGroup
}

func isTTY() bool {
	fi, err := os.Stdout.Stat()
	if err != nil {
		return false
	}
	return fi.Mode()&os.ModeCharDevice != 0
}

func startSpinner(msg string) *spinner {
	s := &spinner{msg: msg, stop: make(chan struct{})}
	if !isTTY() {
		fmt.Printf("    %s\n", msg)
		return s
	}
	s.done.Add(1)
	go func() {
		defer s.done.Done()
		frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
		i := 0
		t := time.NewTicker(100 * time.Millisecond)
		defer t.Stop()
		for {
			select {
			case <-s.stop:
				fmt.Printf("\r\033[K")
				return
			case <-t.C:
				fmt.Printf("\r    %s %s", frames[i%len(frames)], msg)
				i++
			}
		}
	}()
	return s
}

func (s *spinner) Stop() {
	close(s.stop)
	s.done.Wait()
}

func fileHash(path string) string {
	b, err := os.ReadFile(path)
	if err != nil {
		return ""
	}
	h := sha256.Sum256(b)
	return hex.EncodeToString(h[:])
}

func metaPath(svcDir string) string {
	return filepath.Join(svcDir, ".micro")
}

func readMeta(svcDir string) map[string]string {
	m := make(map[string]string)
	b, err := os.ReadFile(metaPath(svcDir))
	if err != nil {
		return m
	}
	_ = json.Unmarshal(b, &m)
	return m
}

func writeMeta(svcDir string, m map[string]string) {
	b, _ := json.MarshalIndent(m, "", "  ")
	_ = os.WriteFile(metaPath(svcDir), b, 0644)
}

func fileModified(svcDir, key, path string) bool {
	meta := readMeta(svcDir)
	savedHash, ok := meta[key]
	if !ok {
		return false
	}
	return fileHash(path) != savedHash
}

func recordFileHash(svcDir, key, path string) {
	meta := readMeta(svcDir)
	meta[key] = fileHash(path)
	writeMeta(svcDir, meta)
}

func handlerModified(svcDir, handlerFile string) bool {
	return fileModified(svcDir, "handler_hash", handlerFile)
}

func recordHandlerHash(svcDir, handlerFile string) {
	recordFileHash(svcDir, "handler_hash", handlerFile)
}
