package main

import (
	"bytes"
	"context"
	_ "embed"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/prometheus/client_golang/prometheus/promhttp"

	"chat-switchboard/auth"
	"chat-switchboard/compaction"
	"chat-switchboard/config"
	"chat-switchboard/crypto"
	"chat-switchboard/database"
	"chat-switchboard/events"
	"chat-switchboard/extraction"
	"chat-switchboard/filters"
	"chat-switchboard/sandbox"
	"chat-switchboard/handlers"
	"chat-switchboard/health"
	"chat-switchboard/logging"
	"chat-switchboard/metrics"
	"chat-switchboard/knowledge"
	"chat-switchboard/memory"
	"chat-switchboard/middleware"
	"chat-switchboard/notifications"
	"chat-switchboard/pages"
	"chat-switchboard/providers"
	"chat-switchboard/retention"
	"chat-switchboard/roles"
	"chat-switchboard/routing"
	"chat-switchboard/scheduler"
	"chat-switchboard/storage"
	"chat-switchboard/store"
	postgres "chat-switchboard/store/postgres"
	sqliteStore "chat-switchboard/store/sqlite"
	"chat-switchboard/tools"
	"chat-switchboard/tools/search"
	"chat-switchboard/treepath"
	"chat-switchboard/workspace"
)

// v0.33.0: Embedded OpenAPI spec and Swagger UI for /api/docs.
//
//go:embed static/openapi.yaml
var openapiSpec []byte

//go:embed static/swagger.html
var swaggerHTML []byte

func main() {
	// ── Subcommand dispatch ──────────────────
	// Usage: switchboard vault rekey
	if len(os.Args) > 2 && os.Args[1] == "vault" {
		runVaultCommand(os.Args[2])
		return
	}
	if len(os.Args) > 1 && os.Args[1] == "version" {
		fmt.Println("switchboard", Version)
		return
	}

	// ── Server startup ──────────────────────
	cfg := config.Load()

	// v0.33.0: Structured logging — must be first so all subsequent
	// log output goes through slog.
	logging.Init(cfg.LogFormat, cfg.LogLevel)

	// Register LLM providers
	providers.Init()

	// v0.29.1: Config-file provider types (Ollama, LiteLLM, vLLM, etc.)
	if cfg.ProviderTypesFile != "" {
		n, err := providers.LoadCustomTypes(cfg.ProviderTypesFile)
		if err != nil {
			log.Printf("⚠️ Failed to load custom provider types from %s: %v", cfg.ProviderTypesFile, err)
		} else if n > 0 {
			log.Printf("  📦 Loaded %d custom provider type(s) from %s", n, cfg.ProviderTypesFile)
		}
	}

	var stores store.Stores
	uekCache := crypto.NewUEKCache()
	var keyResolver *crypto.KeyResolver
	var objStore storage.ObjectStore
	var healthAccum *health.Accumulator
	var healthStore health.Store

	if err := database.Connect(cfg); err != nil {
		log.Printf("⚠ Database unavailable: %v", err)
		log.Println("  Running in unmanaged mode (no persistence)")
	} else {
		// Schema check: init if fresh, upgrade if behind, proceed if current
		if err := database.Migrate(); err != nil {
			log.Fatalf("❌ Schema migration failed: %v", err)
		}

		// Vault: enforce encryption key + backfill plaintext keys
		if err := crypto.EnforceEncryptionKey(database.DB, cfg.EncryptionKey); err != nil {
			log.Fatalf("❌ Vault check failed: %v", err)
		}
		if cfg.EncryptionKey != "" {
			if err := crypto.BackfillEncryptedKeys(database.DB, cfg.EncryptionKey); err != nil {
				log.Fatalf("❌ Vault backfill failed: %v", err)
			}
		}

		// Derive env key for key resolver (nil if not set)
		var envKey []byte
		if cfg.EncryptionKey != "" {
			var err error
			envKey, err = crypto.DeriveKeyFromEnv(cfg.EncryptionKey)
			if err != nil {
				log.Fatalf("❌ Failed to derive encryption key: %v", err)
			}
			log.Println("  🔐 API key encryption active")
		}
		keyResolver = crypto.NewKeyResolver(envKey, uekCache)

		// Initialize store layer
		if database.IsSQLite() {
			stores = sqliteStore.NewStores(database.DB)
		} else {
			stores = postgres.NewStores(database.DB)
		}

		// v0.29.0: Wire store layer into treepath for backward compat.
		// New code should call stores.Messages.* directly.
		treepath.Stores = &stores

		// Provider health accumulator (v0.22.0)
		if database.IsSQLite() {
			healthStore = sqliteStore.NewHealthStore()
		} else {
			healthStore = postgres.NewHealthStore(database.DB)
		}
		healthAccum = health.NewAccumulator(healthStore)
		defer healthAccum.Stop()

		// v0.33.0: Start Prometheus DB pool collector
		metrics.StartDBCollector(database.DB, 15*time.Second)

		// Auto-disable: deactivate providers that are "down" for N consecutive
		// hourly windows. Configured via PROVIDER_AUTO_DISABLE_THRESHOLD env var.
		// Default: 3 (3 consecutive "down" hours triggers deactivation). Set to 0 to disable.
		autoDisableThreshold := 3
		if v := cfg.ProviderAutoDisableThreshold; v >= 0 {
			autoDisableThreshold = v
		}
		if autoDisableThreshold > 0 {
			if ad, ok := healthStore.(health.AutoDisabler); ok {
				healthAccum.SetAutoDisable(ad, autoDisableThreshold)
				log.Printf("  🛡️ Provider auto-disable: %d consecutive down windows", autoDisableThreshold)
			}
		}

		// Background health cleanup: prune windows older than 7 days
		go func() {
			ticker := time.NewTicker(6 * time.Hour)
			defer ticker.Stop()
			for range ticker.C {
				ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
				if n, err := healthStore.Prune(ctx, time.Now().UTC().Add(-health.PruneAge)); err != nil {
					log.Printf("⚠ health prune failed: %v", err)
				} else if n > 0 {
					log.Printf("🧹 health: pruned %d old windows", n)
				}
				cancel()
			}
		}()

		// Background session cleanup (v0.26.0): remove expired anonymous sessions
		if cfg.SessionExpiryDays > 0 {
			go func() {
				ticker := time.NewTicker(6 * time.Hour)
				defer ticker.Stop()
				for {
					ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
					cutoff := time.Now().UTC().AddDate(0, 0, -cfg.SessionExpiryDays)
					if n, err := stores.Sessions.DeleteExpired(ctx, cutoff); err != nil {
						log.Printf("⚠ session cleanup failed: %v", err)
					} else if n > 0 {
						log.Printf("🧹 sessions: cleaned up %d expired sessions", n)
					}
					cancel()
					<-ticker.C
				}
			}()
		}

		// Background workflow staleness sweep (v0.26.2): mark idle instances as stale
		// + v0.27.0: retention enforcement — archive/delete completed instances per workflow policy
		if cfg.WorkflowStaleHours > 0 {
			go func() {
				ticker := time.NewTicker(1 * time.Hour)
				defer ticker.Stop()
				for {
					ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)

					// Staleness: mark idle active instances as stale
					cutoff := time.Now().UTC().Add(-time.Duration(cfg.WorkflowStaleHours) * time.Hour)
					n, err := stores.Channels.MarkStaleWorkflows(ctx, cutoff)
					if err != nil {
						log.Printf("⚠ workflow staleness sweep failed: %v", err)
					} else if n > 0 {
						log.Printf("🧹 workflows: marked %d instances as stale", n)
					}

					// v0.27.0: Retention enforcement — delete completed workflow channels
					// where the parent workflow has retention.mode="delete" and
					// retention.delete_after_days has elapsed since completion.
					n2, err := stores.Channels.EnforceWorkflowRetention(ctx)
					if err != nil {
						// SQLite doesn't support JSON operators — skip retention on SQLite
						if !database.IsSQLite() {
							log.Printf("⚠ workflow retention enforcement failed: %v", err)
						}
					} else if n2 > 0 {
						log.Printf("🧹 workflows: deleted %d expired instances (retention policy)", n2)
					}

					cancel()
					<-ticker.C
				}
			}()
		}

		// v0.37.14: Channel retention scanner — purges archived channels past their TTL
		retScanner := retention.NewScanner(stores, objStore, retention.ScannerConfig{})
		retScanner.Start()
		defer retScanner.Stop()

		// v0.27.2: Task scheduler startup deferred to after hub/notification init — see below.

		// Bootstrap admin from env (K8s secret) — upserts on every restart
		handlers.BootstrapAdmin(cfg, stores, uekCache)

		// Seed additional users from env (dev/test only, skipped in production)
		handlers.SeedUsers(cfg, stores, uekCache)

		// Seed providers from env (dev/test only, skipped in production)
		handlers.SeedProviders(cfg, stores, keyResolver)

		// Seed builtin extensions from disk (idempotent, version-aware)
		handlers.SeedBuiltinPackages(stores, "extensions/builtin")

		// Load search provider config from DB (defaults to DuckDuckGo if not set)
		loadSearchConfig(stores)
	}
	defer database.Close()

	// ── File Storage ─────────────────────────
	// Auto-detects PVC if STORAGE_PATH is writable. Explicit STORAGE_BACKEND
	// overrides auto-detection. nil objStore = storage features disabled.
	var s3Cfg *storage.S3Config
	if cfg.S3Bucket != "" {
		s3Cfg = &storage.S3Config{
			Endpoint:       cfg.S3Endpoint,
			Bucket:         cfg.S3Bucket,
			Region:         cfg.S3Region,
			AccessKey:      cfg.S3AccessKey,
			SecretKey:      cfg.S3SecretKey,
			Prefix:         cfg.S3Prefix,
			ForcePathStyle: cfg.S3ForcePathStyle,
		}
	}
	if s, err := storage.Init(cfg.StorageBackend, cfg.StoragePath, s3Cfg); err != nil {
		if cfg.StorageBackend != "" {
			// Explicit backend requested but failed — fatal
			log.Fatalf("❌ Storage init failed: %v", err)
		}
		log.Printf("⚠ Storage init failed: %v", err)
	} else {
		objStore = s
	}
	handlers.SetStorageConfigured(objStore != nil)

	// ── Extraction Queue ────────────────────
	// Filesystem-based queue for document text extraction (PDF, DOCX, etc.)
	// Nil if storage is disabled.
	var extQueue *extraction.Queue
	if objStore != nil && cfg.StoragePath != "" {
		q, err := extraction.NewQueue(cfg.StoragePath, cfg.ExtractionConcurrency)
		if err != nil {
			log.Printf("⚠ Extraction queue init failed: %v", err)
		} else {
			extQueue = q
			// Recover items stuck in "processing" from previous crash
			if recovered, err := extQueue.RecoverStale(30 * time.Minute); err != nil {
				log.Printf("⚠ Extraction recovery failed: %v", err)
			} else if recovered > 0 {
				log.Printf("  📋 Recovered %d stale extraction items", recovered)
			}
		}
	}

	// ── EventBus (created early — needed by role resolver) ──
	bus := events.NewBus()

	// Wire Postgres LISTEN/NOTIFY for cross-pod event fan-out (multi-replica).
	// No-op when running SQLite — in-process Bus is sufficient for single-pod.
	events.StartPGBroadcast(bus)

	// ── Workspace FS (v0.21.0) ──────────────
	// Provides file operations for workspace storage primitive.
	// Nil-safe: handler checks wfs != nil before operating.
	var wfs *workspace.FS
	if cfg.StoragePath != "" {
		wfs = workspace.NewFS(cfg.StoragePath+"/workspaces", stores.Workspaces)
		if err := wfs.Init(); err != nil {
			log.Printf("⚠ Workspace FS init failed: %v", err)
			wfs = nil
		} else {
			log.Printf("  📁 Workspace FS initialized at %s/workspaces", cfg.StoragePath)
		}
	}

	// Register workspace tools (late registration — needs stores + wfs)
	if wfs != nil {
		tools.RegisterWorkspaceTools(stores, wfs)
	}

	// Role resolver for model role dispatch (needs stores + vault + bus)
	roleResolver := roles.NewResolver(stores, keyResolver).WithBus(bus)

	// ── Knowledge Base Pipeline ─────────────
	// Embedder + ingester for RAG document processing (v0.14.0).
	// Nil-safe: handler checks embedder.IsConfigured() before accepting uploads.
	kbEmbedder := knowledge.NewEmbedder(roleResolver).WithStores(stores)
	kbIngester := knowledge.NewIngester(stores, kbEmbedder, objStore, knowledge.DefaultConcurrency)
	defer kbIngester.Wait() // drain in-flight ingestions on shutdown

	// Register kb_search tool (late registration — needs stores + embedder)
	tools.RegisterKBSearch(stores, kbEmbedder)

	// Register note tools (late registration — needs stores + embedder for semantic search)
	tools.RegisterNoteTools(stores, kbEmbedder)

	// ── Workspace Indexer (v0.21.2) ───────────
	// Background indexing pipeline for workspace files.
	// Shares the embedder with KB ingestion; uses its own concurrency semaphore.
	var wsIndexer *workspace.Indexer
	if wfs != nil {
		idxSem := make(chan struct{}, cfg.WorkspaceIndexConcurrency)
		wsIndexer = workspace.NewIndexer(stores, kbEmbedder, wfs, idxSem, cfg.WorkspaceIndexingEnabled)
		wfs.SetIndexer(wsIndexer) // hook into write path
		defer wsIndexer.Wait()
		if cfg.WorkspaceIndexingEnabled {
			log.Printf("  📑 Workspace indexer enabled (concurrency=%d)", cfg.WorkspaceIndexConcurrency)
		} else {
			log.Printf("  📑 Workspace indexer disabled (WORKSPACE_INDEXING_ENABLED=false)")
		}
	}

	// Register workspace_search tool (needs embedder for query embedding)
	tools.RegisterWorkspaceSearchTool(stores, kbEmbedder)

	// ── Git Integration (v0.21.4) ────────────
	// GitOps wraps exec-based git operations with vault credential injection.
	var gitOps *workspace.GitOps
	if wfs != nil {
		gitOps = workspace.NewGitOps(wfs, stores, keyResolver, wsIndexer)
		tools.RegisterGitTools(gitOps)
		log.Println("  🔀 Git integration enabled")
	}

	// Register context recall tools (v0.15.1)
	tools.RegisterFileRecall(stores, objStore)
	tools.RegisterConversationSearch(stores)

	// Memory tools + extraction scanner (v0.18.0)
	tools.RegisterMemoryTools(stores, kbEmbedder)

	// Workflow tools (v0.26.4 — AI-triggered stage advancement)
	tools.RegisterWorkflowTools(stores)

	// v0.27.3: task_create tool (AI spawns sub-tasks)
	tools.RegisterTaskTools(stores)
	memExtractor := memory.NewExtractor(stores, roleResolver, kbEmbedder)
	memCompactor := memory.NewCompactor(stores, roleResolver)
	memScanner := memory.NewScanner(memExtractor, stores, memory.ScannerConfig{})
	memScanner.Start()
	defer memScanner.Stop()

	// ── Pre-completion filter chain (v0.29.0) ──
	// Built-in filters register here. Starlark extension filters will
	// register at package install time (CS3).
	filterChain := filters.NewChain()
	filterChain.Register(filters.NewKBInjectFilter(stores))

	// ── Starlark Runner (v0.29.0 CS3) ──
	// Sandboxed interpreter for extension scripts. Runner assembles
	// modules based on granted permissions. Notifier attached below
	// after notification service init.
	starlarkRunner := sandbox.NewRunner(
		sandbox.New(sandbox.DefaultConfig()),
		stores,
	)
	// v0.29.1: provider module adapter — bridges sandbox.ProviderResolver to handlers.ResolveProviderConfig
	starlarkRunner.SetProviderResolver(handlers.NewProviderResolverAdapter(stores, keyResolver))
	// v0.29.2: db module — extension namespaced table access
	starlarkRunner.SetDB(database.DB, database.IsPostgres())

	// Discover and register active Starlark pre-completion filters
	filters.DiscoverStarlarkFilters(context.Background(), filterChain, stores, starlarkRunner)

	log.Printf("  🔗 Pre-completion filter chain: %d filters", filterChain.Len())

	r := gin.New()
	r.Use(middleware.RequestID())
	r.Use(middleware.Prometheus())
	r.Use(middleware.Logger())
	r.Use(middleware.Recovery())
	userCache := middleware.NewUserStatusCache()
	r.Use(middleware.CORS(cfg))

	// ── Base path group ──────────────────────
	base := r.Group(cfg.BasePath)

	// ── WebSocket Hub ─────────────────────────
	hub := events.NewHub(bus, middleware.GetAllowedOrigins(cfg))

	// ── WebSocket Ticket Store (v0.32.0: PG-backed for cross-pod) ─────
	ticketAdapter := &events.TicketValidatorAdapter{Store: stores.Tickets}

	// ── Notification Service (v0.20.0) ───────
	var notifSvc *notifications.Service
	if stores.Notifications != nil {
		notifSvc = notifications.NewService(stores.Notifications, hub).
			WithPrefs(stores.NotifPrefs).
			WithUsers(stores.Users)

		// Load SMTP config from platform settings for email transport (Phase 3)
		if stores.GlobalConfig != nil {
			if smtpCfg, err := notifications.LoadSMTPConfig(stores.GlobalConfig, keyResolver); err == nil && smtpCfg != nil {
				transport := notifications.NewEmailTransport(*smtpCfg)
				instanceName := "Chat Switchboard"
				if brandCfg, err := stores.GlobalConfig.Get(context.Background(), "branding"); err == nil {
					if name, ok := brandCfg["instance_name"].(string); ok && name != "" {
						instanceName = name
					}
				}
				notifSvc.WithEmail(transport, instanceName)
				log.Println("[notifications] email transport enabled")
			}
		}
		notifSvc.StartCleanup()
		notifications.SetDefault(notifSvc)
		starlarkRunner.SetNotifier(notifSvc)

		// Subscribe to role.fallback events → generate notifications for admins
		bus.Subscribe("role.fallback", notifications.RoleFallbackHandler(notifSvc, stores))
		defer notifSvc.StopCleanup()
	}

	// v0.27.2: Task scheduler with executor — needs hub + notification service
	if stores.Tasks != nil {
		// v0.28.6: Register built-in system task functions
		scheduler.RegisterBuiltins()

		exec := scheduler.NewExecutor(stores, keyResolver, hub, healthAccum)
		exec.SetRunner(starlarkRunner)
		taskSched := scheduler.New(stores, exec)
		go taskSched.Run()
		log.Println("  ⏰ Task scheduler started (with executor)")
	}

	// Health check (k8s probes hit this directly)
	base.GET("/health", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"status":         "ok",
			"version":        Version,
			"database":       database.IsConnected(),
			"database_name":  database.Name(),
			"schema_version": database.SchemaVersion(),
		})
	})

	// v0.32.0: Kubernetes probe endpoints
	// Liveness: process is alive and serving (no dependency checks).
	base.GET("/healthz/live", func(c *gin.Context) {
		c.JSON(200, gin.H{"status": "ok"})
	})
	// Readiness: process can serve traffic (PG reachable).
	// Failing readiness pulls the pod from the Service — new requests
	// route to healthy replicas.
	base.GET("/healthz/ready", func(c *gin.Context) {
		if database.DB == nil {
			c.JSON(503, gin.H{"error": "database not initialized"})
			return
		}
		ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
		defer cancel()
		if err := database.DB.PingContext(ctx); err != nil {
			c.JSON(503, gin.H{"error": "database unavailable"})
			return
		}
		c.JSON(200, gin.H{"status": "ok"})
	})

	// v0.33.0: Prometheus metrics endpoint (no auth — Prometheus scrapes directly)
	base.GET("/metrics", gin.WrapH(promhttp.Handler()))

	// v0.33.0: OpenAPI spec + Swagger UI (no auth — documentation)
	base.GET("/api/docs", func(c *gin.Context) {
		c.Data(http.StatusOK, "text/html; charset=utf-8", swaggerHTML)
	})
	base.GET("/api/docs/openapi.yaml", func(c *gin.Context) {
		// Replace ${VERSION} placeholder with actual version from VERSION file
		patched := bytes.Replace(openapiSpec, []byte("${VERSION}"), []byte(Version), 1)
		c.Data(http.StatusOK, "application/yaml", patched)
	})

	// WebSocket endpoint
	base.GET("/ws", middleware.WsAuth(cfg, stores.Users, userCache, ticketAdapter), hub.HandleWebSocket)

	// ── Auth routes (rate limited) ──────────────
	authMode, err := auth.ParseMode(cfg.AuthMode)
	if err != nil {
		log.Fatalf("❌ Invalid AUTH_MODE=%q: %v", cfg.AuthMode, err)
	}
	var authProvider auth.Provider
	switch authMode {
	case auth.ModeBuiltin:
		authProvider = auth.NewBuiltinProvider()
	case auth.ModeMTLS:
		authProvider = auth.NewMTLSProvider(auth.MTLSConfig{
			HeaderDN:          cfg.MTLSHeaderDN,
			HeaderVerify:      cfg.MTLSHeaderVerify,
			HeaderFingerprint: cfg.MTLSHeaderFingerprint,
			AutoActivate:      cfg.MTLSAutoActivate,
			DefaultTeam:       cfg.MTLSDefaultTeam,
			DefaultRole:       cfg.MTLSDefaultRole,
		})
	case auth.ModeOIDC:
		var err error
		authProvider, err = auth.NewOIDCProvider(auth.OIDCConfig{
			IssuerURL:					cfg.OIDCIssuerURL,
			ExternalIssuerURL:  cfg.OIDCExternalIssuerURL,
			ClientID:           cfg.OIDCClientID,
			ClientSecret:       cfg.OIDCClientSecret,
			RedirectURL:        cfg.OIDCRedirectURL,
			AutoActivate:       cfg.OIDCAutoActivate,
			DefaultTeam:        cfg.OIDCDefaultTeam,
			DefaultRole:        cfg.OIDCDefaultRole,
			RolesClaim:         cfg.OIDCRolesClaim,
			GroupsClaim:        cfg.OIDCGroupsClaim,
			AdminRole:          cfg.OIDCAdminRole,
		})
		if err != nil {
			log.Fatalf("❌ OIDC provider init failed: %v", err)
		}
	}
	log.Printf("  🔑 Auth mode: %s", authMode)

	authH := handlers.NewAuthHandler(cfg, stores, uekCache, authProvider)
	authLimiter := middleware.NewRateLimiter(stores.RateLimits, 5, 8)

	api := base.Group("/api/v1")
	{
		// Health (routable through ingress)
		api.GET("/health", func(c *gin.Context) {
			info := gin.H{
				"status":         "ok",
				"version":        Version,
				"schema_version": database.SchemaVersion(),
				"database":       database.IsConnected(),
				"database_name":  database.Name(),
				"providers":      providers.List(),
			}
			if database.IsConnected() {
				info["registration_enabled"] = handlers.IsRegistrationEnabled(stores)
			}
			c.JSON(200, info)
		})

		authGroup := api.Group("/auth")
		authGroup.Use(authLimiter.Limit())
		{
			authGroup.POST("/register", authH.Register)
			authGroup.POST("/login", authH.Login)
			authGroup.POST("/refresh", authH.Refresh)
			authGroup.POST("/logout", authH.Logout)

			// OIDC routes (only active when AUTH_MODE=oidc)
			authGroup.GET("/oidc/login", authH.OIDCLogin)
			authGroup.GET("/oidc/callback", authH.OIDCCallback)
		}

		// ── Public extension assets ────────────────
		// Script tags can't send Authorization headers, so asset serving must be public.
		// Extension JS is the same for all users — no user-specific data.
		extH := handlers.NewExtensionHandler(stores)
		api.GET("/extensions/:id/assets/*path", extH.ServeExtensionAsset)

		// ── Protected routes ────────────────────
		protected := api.Group("")
		protected.Use(middleware.Auth(cfg, stores.Users, userCache))
		protected.Use(middleware.ValidatePathParams())
		{
			// ── WebSocket Ticket (v0.28.8) ───────────
			// Issue a single-use ticket for WebSocket auth.
			// Client fetches this, then connects with ?ticket=<opaque>.
			protected.POST("/ws/ticket", func(c *gin.Context) {
				userID := c.GetString("user_id")
				ticket, err := stores.Tickets.Issue(c.Request.Context(), userID)
				if err != nil {
					c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to issue ticket"})
					return
				}
				c.JSON(http.StatusOK, gin.H{"ticket": ticket})
			})

			// Channels
			channels := handlers.NewChannelHandler(stores)
			protected.GET("/channels", channels.ListChannels)
			protected.POST("/channels", middleware.RequirePermission(auth.PermChannelCreate, stores), channels.CreateChannel)
			protected.GET("/channels/:id", channels.GetChannel)
			protected.PUT("/channels/:id", channels.UpdateChannel)
			protected.DELETE("/channels/:id", channels.DeleteChannel)
			protected.POST("/channels/:id/mark-read", channels.MarkRead)

			// Typing indicator broadcast (v0.23.2)
			protected.POST("/channels/:id/typing", func(c *gin.Context) {
				userID := c.GetString("user_id")
				channelID := c.Param("id")
				// Resolve display name
				var displayName string
				user, err := stores.Users.GetByID(c.Request.Context(), userID)
				if err == nil && user != nil {
					displayName = user.DisplayName
					if displayName == "" {
						displayName = user.Username
					}
				}
				if displayName == "" {
					displayName = userID[:8]
				}
				// Broadcast to other user participants
				pids, err := stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, userID)
				if err == nil {
					payload, _ := json.Marshal(map[string]any{
						"channel_id":   channelID,
						"user_id":      userID,
						"display_name": displayName,
					})
					evt := events.Event{
						Label:   "typing.user",
						Payload: payload,
						Ts:      time.Now().UnixMilli(),
					}
					for _, pid := range pids {
						hub.PublishToUser(pid, evt)
					}
				}
				c.JSON(200, gin.H{"ok": true})
			})

			// Chat Folders (v0.23.1)
			folders := handlers.NewFolderHandler(stores)
			protected.GET("/folders", folders.List)
			protected.POST("/folders", folders.Create)
			protected.PUT("/folders/:id", folders.Update)
			protected.DELETE("/folders/:id", folders.Delete)

			// Presence (v0.23.1)
			presence := handlers.NewPresenceHandler(stores)
			protected.POST("/presence/heartbeat", presence.Heartbeat)
			protected.GET("/presence", presence.Query)

			// User search (v0.23.2 — DM user picker)
			protected.GET("/users/search", presence.SearchUsers)

			// Persona groups (v0.23.2 — roster templates for group chats)
			pgH := handlers.NewPersonaGroupHandler(stores)
			protected.GET("/persona-groups", pgH.List)
			protected.POST("/persona-groups", pgH.Create)
			protected.GET("/persona-groups/:id", pgH.Get)
			protected.PUT("/persona-groups/:id", pgH.Update)
			protected.DELETE("/persona-groups/:id", pgH.Delete)
			protected.POST("/persona-groups/:id/members", pgH.AddMember)
			protected.DELETE("/persona-groups/:id/members/:memberId", pgH.RemoveMember)

			// Workflows (v0.26.1 — team-owned staged processes)
			wfH := handlers.NewWorkflowHandler(stores)
			protected.GET("/workflows", wfH.List)
			protected.POST("/workflows", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Create)
			protected.GET("/workflows/:id", wfH.Get)
			protected.PATCH("/workflows/:id", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Update)
			protected.DELETE("/workflows/:id", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Delete)
			protected.GET("/workflows/:id/stages", wfH.ListStages)
			protected.POST("/workflows/:id/stages", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.CreateStage)
			protected.PUT("/workflows/:id/stages/:sid", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.UpdateStage)
			protected.DELETE("/workflows/:id/stages/:sid", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.DeleteStage)
			protected.PATCH("/workflows/:id/stages/reorder", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.ReorderStages)
			protected.POST("/workflows/:id/publish", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Publish)
			protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)

			// Workflow instances (v0.26.2 — runtime lifecycle)
			wfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc, starlarkRunner)
			protected.POST("/workflows/:id/start", wfInstH.Start)
			protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
			protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
			protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
			protected.POST("/channels/:id/workflow/cancel", wfInstH.CancelInstance) // v0.37.15

			// Workflow assignments (v0.26.4 — team assignment queue)
			wfAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub)
			protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
			protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
			protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
			protected.GET("/workflow-assignments/:id", wfAssignH.GetAssignment)
			protected.POST("/workflow-assignments/:id/comment", wfAssignH.CommentOnAssignment)
			protected.POST("/workflow-assignments/:id/unclaim", wfAssignH.Unclaim)       // v0.37.15
			protected.POST("/workflow-assignments/:id/reassign", wfAssignH.Reassign)     // v0.37.15
			protected.POST("/workflow-assignments/:id/cancel", wfAssignH.CancelAssignment) // v0.37.15

			// Tasks (v0.27.1, permissions v0.27.2)
			taskH := handlers.NewTaskHandler(stores)
			protected.GET("/tasks", taskH.ListMine)
			protected.POST("/tasks", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.Create)
			protected.GET("/tasks/:id", taskH.Get)
			protected.PUT("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.Update)
			protected.DELETE("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.Delete)
			protected.GET("/tasks/:id/runs", taskH.ListRuns)
			protected.POST("/tasks/:id/run", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.RunNow)
			protected.POST("/tasks/:id/kill", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.KillRun)

			// Channel models (v0.20.0 — multi-model @mention routing)
			chModelH := handlers.NewChannelModelHandler(stores)
			protected.GET("/channels/:id/models", chModelH.List)
			protected.POST("/channels/:id/models", chModelH.Add)
			protected.PATCH("/channels/:id/models/:modelId", chModelH.Update)
			protected.DELETE("/channels/:id/models/:modelId", chModelH.Delete)

			// Channel participants (v0.23.0 — ICD §3.7)
			partH := handlers.NewParticipantHandler(stores)
			protected.GET("/channels/:id/participants", partH.List)
			protected.POST("/channels/:id/participants", middleware.RequirePermission(auth.PermChannelInvite, stores), partH.Add)
			protected.PATCH("/channels/:id/participants/:participantId", partH.Update)
			protected.DELETE("/channels/:id/participants/:participantId", partH.Remove)

			// Messages
			msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
			protected.GET("/channels/:id/messages", msgs.ListMessages)
			protected.POST("/channels/:id/messages", msgs.CreateMessage)

			// Message tree (forking)
			protected.GET("/channels/:id/path", msgs.GetActivePath)
			protected.PUT("/channels/:id/cursor", msgs.UpdateCursor)
			protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
			protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate)
			protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
			protected.DELETE("/channels/:id/messages/:msgId", msgs.DeleteMessage)

			// Chat Completions
			comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder)
			if healthAccum != nil {
				comp.SetHealthRecorder(healthAccum)
				comp.SetHealthStore(healthStore)
			}
			comp.SetRoutingEvaluator(routing.NewEvaluator())
			comp.SetFilterChain(filterChain)
			comp.SetRunner(starlarkRunner) // v0.29.2: extension tool dispatch
			protected.POST("/chat/completions", middleware.RequirePermission(auth.PermModelUse, stores), comp.Complete)
			protected.GET("/tools", comp.ListTools)

			// Surface discovery (v0.25.0, v0.28.7: unified packages)
			pkgH := handlers.NewPackageHandler(stores)
			protected.GET("/surfaces", pkgH.ListEnabledSurfaces)

			// User package management (v0.30.0)
			userPkgDir := ""
			if cfg.StoragePath != "" {
				userPkgDir = cfg.StoragePath + "/packages"
			}
			userPkgH := handlers.NewUserPackageHandler(stores, userPkgDir)
			protected.GET("/packages", userPkgH.ListVisiblePackages)
			protected.POST("/packages/install", userPkgH.InstallPersonalPackage)
			protected.DELETE("/packages/:id", userPkgH.DeletePersonalPackage)

			// Summarize & Continue (backed by compaction service)
			compactionSvc := compaction.NewService(stores, roleResolver)
			summarize := handlers.NewSummarizeHandler(stores, compactionSvc)
			protected.POST("/channels/:id/summarize", middleware.RequirePermission(auth.PermModelUse, stores), summarize.Summarize)

			// Auto-title generation (utility role)
			titleH := handlers.NewTitleHandler(stores, roleResolver)
			protected.POST("/channels/:id/generate-title", middleware.RequirePermission(auth.PermModelUse, stores), titleH.GenerateTitle)

			// Provider Configs (user-facing — replaces /api-configs)
			provCfg := handlers.NewProviderConfigHandler(stores, keyResolver)
			protected.GET("/api-configs", provCfg.ListConfigs)       // backward compat
			protected.POST("/api-configs", provCfg.CreateConfig)
			protected.GET("/api-configs/:id", provCfg.GetConfig)
			protected.PUT("/api-configs/:id", provCfg.UpdateConfig)
			protected.DELETE("/api-configs/:id", provCfg.DeleteConfig)
			protected.GET("/api-configs/:id/models", provCfg.ListModels)
			protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)

			// Models (unified resolver — replaces scattered endpoints)
			modelH := handlers.NewModelHandler(stores)
			if healthStore != nil {
				modelH.SetHealthStore(healthStore)
			}
			protected.GET("/models/enabled", modelH.ListEnabledModels)

			// Model Preferences
			modelPrefs := handlers.NewModelPrefsHandler(stores)
			protected.GET("/models/preferences", modelPrefs.GetPreferences)
			protected.PUT("/models/preferences", modelPrefs.SetPreference)
			protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)

			// User Settings & Profile
			settings := handlers.NewSettingsHandler(stores, uekCache)
			protected.GET("/profile", settings.GetProfile)
			protected.PUT("/profile", settings.UpdateProfile)
			protected.POST("/profile/password", settings.ChangePassword)
			protected.POST("/profile/avatar", settings.UploadAvatar)
			protected.DELETE("/profile/avatar", settings.DeleteAvatar)
			protected.GET("/settings", settings.GetSettings)
			protected.PUT("/settings", settings.UpdateSettings)

			// Permission bootstrap (v0.37.1) — self-service resolved permissions
			permH := handlers.NewProfilePermissionsHandler(stores)
			protected.GET("/profile/permissions", permH.GetMyPermissions)

			// Boot payload (v0.37.15) — single-call SDK bootstrap
			bootH := handlers.NewProfileBootstrapHandler(stores)
			protected.GET("/profile/bootstrap", bootH.GetBootstrap)

			// Usage (personal)
			usage := handlers.NewUsageHandler(stores)
			protected.GET("/usage", usage.PersonalUsage)

			// Personas
			personas := handlers.NewPersonaHandler(stores)
			protected.GET("/personas", personas.ListUserPersonas)
			protected.POST("/personas", middleware.RequirePermission(auth.PermPersonaCreate, stores), personas.CreateUserPersona)
			protected.PUT("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.UpdateUserPersona)
			protected.DELETE("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.DeleteUserPersona)
			protected.POST("/personas/:id/avatar", personas.UploadUserPersonaAvatar)
			protected.DELETE("/personas/:id/avatar", personas.DeleteUserPersonaAvatar)
			protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs)      // v0.17.0
			protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs)      // v0.17.0
			protected.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants)    // v0.25.0
			protected.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants)    // v0.25.0

			// Notes
			notes := handlers.NewNoteHandler(stores)
			protected.GET("/notes", notes.List)
			protected.POST("/notes", notes.Create)
			protected.GET("/notes/search", notes.Search)
			protected.GET("/notes/search-titles", notes.SearchTitles)
			protected.GET("/notes/folders", notes.ListFolders)
			protected.GET("/notes/graph", notes.Graph)
			protected.POST("/notes/bulk-delete", notes.BulkDelete)
			protected.GET("/notes/:id", notes.Get)
			protected.PUT("/notes/:id", notes.Update)
			protected.DELETE("/notes/:id", notes.Delete)
			protected.GET("/notes/:id/backlinks", notes.Backlinks)

			// Projects (v0.19.0)
			projectH := handlers.NewProjectHandler(stores)
			protected.GET("/projects", projectH.List)
			protected.POST("/projects", projectH.Create)
			protected.GET("/projects/:id", projectH.Get)
			protected.PUT("/projects/:id", projectH.Update)
			protected.DELETE("/projects/:id", projectH.Delete)
			protected.POST("/projects/:id/channels", projectH.AddChannel)
			protected.DELETE("/projects/:id/channels/:channelId", projectH.RemoveChannel)
			protected.GET("/projects/:id/channels", projectH.ListChannels)
			protected.PUT("/projects/:id/channels/reorder", projectH.ReorderChannels)
			protected.POST("/projects/:id/knowledge-bases", projectH.AddKB)
			protected.DELETE("/projects/:id/knowledge-bases/:kbId", projectH.RemoveKB)
			protected.GET("/projects/:id/knowledge-bases", projectH.ListKBs)
			protected.POST("/projects/:id/notes", projectH.AddNote)
			protected.DELETE("/projects/:id/notes/:noteId", projectH.RemoveNote)
			protected.GET("/projects/:id/notes", projectH.ListNotes)

			// Project files (v0.22.4, reworked v0.37.17 — workspace-backed)
			projFileH := handlers.NewFileHandler(stores, objStore, extQueue)
			if wfs != nil {
				projFileH.SetWorkspaceFS(wfs)
			}
			protected.POST("/projects/:id/files", projFileH.UploadToProject)
			protected.GET("/projects/:id/files", projFileH.ListByProject)
			protected.GET("/projects/:id/files/download", projFileH.DownloadProjectFile)
			protected.DELETE("/projects/:id/files", projFileH.DeleteProjectFile)
			protected.POST("/projects/:id/files/mkdir", projFileH.MkdirProject)
			protected.POST("/projects/:id/archive/upload", projFileH.UploadProjectArchive)
			protected.GET("/projects/:id/archive/download", projFileH.DownloadProjectArchive)

			// Notifications (v0.20.0)
			notifH := handlers.NewNotificationHandler(stores, hub)
			protected.GET("/notifications", notifH.List)
			protected.GET("/notifications/unread-count", notifH.UnreadCount)
			protected.PATCH("/notifications/:id/read", notifH.MarkRead)
			protected.POST("/notifications/mark-all-read", notifH.MarkAllRead)
			protected.DELETE("/notifications/:id", notifH.Delete)

			// Notification preferences (v0.20.0 Phase 3)
			protected.GET("/notifications/preferences", notifH.ListPreferences)
			protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
			protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference)

			// Workspaces (v0.21.0)
			if wfs != nil {
				wsH := handlers.NewWorkspaceHandler(stores, wfs)
				protected.POST("/workspaces", wsH.Create)
				protected.GET("/workspaces", wsH.List)
				protected.GET("/workspaces/:id", wsH.Get)
				protected.PATCH("/workspaces/:id", wsH.Update)
				protected.DELETE("/workspaces/:id", wsH.Delete)
				protected.GET("/workspaces/:id/files", wsH.ListFiles)
				protected.GET("/workspaces/:id/files/read", wsH.ReadFile)
				protected.PUT("/workspaces/:id/files/write", wsH.WriteFile)
				protected.DELETE("/workspaces/:id/files/delete", wsH.DeleteFileHandler)
				protected.POST("/workspaces/:id/files/mkdir", wsH.Mkdir)
				protected.POST("/workspaces/:id/archive/upload", wsH.UploadArchive)
				protected.GET("/workspaces/:id/archive/download", wsH.DownloadArchive)
				protected.POST("/workspaces/:id/reconcile", wsH.Reconcile)
				protected.GET("/workspaces/:id/stats", wsH.Stats)
				protected.GET("/workspaces/:id/index-status", wsH.IndexStatus)

				// Git operations (v0.21.4)
				if gitOps != nil {
					gitH := handlers.NewGitHandler(stores, gitOps)
					protected.POST("/workspaces/:id/git/clone", gitH.Clone)
					protected.POST("/workspaces/:id/git/pull", gitH.Pull)
					protected.POST("/workspaces/:id/git/push", gitH.Push)
					protected.GET("/workspaces/:id/git/status", gitH.Status)
					protected.GET("/workspaces/:id/git/diff", gitH.Diff)
					protected.POST("/workspaces/:id/git/commit", gitH.Commit)
					protected.GET("/workspaces/:id/git/log", gitH.Log)
					protected.GET("/workspaces/:id/git/branches", gitH.Branches)
					protected.POST("/workspaces/:id/git/checkout", gitH.Checkout)
				}
			}

			// Git credentials (v0.21.4) — user-scoped, independent of workspace
			gitCredH := handlers.NewGitCredentialHandler(stores, keyResolver)
			protected.POST("/git-credentials", gitCredH.Create)
			protected.POST("/git-credentials/generate", gitCredH.Generate)
			protected.GET("/git-credentials", gitCredH.List)
			protected.GET("/git-credentials/:id/public-key", gitCredH.GetPublicKey)
			protected.DELETE("/git-credentials/:id", gitCredH.Delete)

			// Files (upload/download)
			fileH := handlers.NewFileHandler(stores, objStore, extQueue)
			protected.POST("/channels/:id/files", fileH.Upload)
			protected.GET("/channels/:id/files", fileH.ListByChannel)
			protected.GET("/files", fileH.ListByUser)
			protected.GET("/files/:id", fileH.GetMetadata)
			protected.GET("/files/:id/download", fileH.Download)
			protected.DELETE("/files/:id", fileH.Delete)
			protected.GET("/messages/:id/files", fileH.ListByMessage)

			// Export (v0.22.4) — pandoc-based markdown → PDF/DOCX conversion
			exportH := handlers.NewExportHandler()
			protected.POST("/export", exportH.Convert)

			// Data portability (v0.34.0) — user data export/import
			dataExport := handlers.NewDataExportHandler(stores, objStore)
			protected.GET("/export/me", dataExport.ExportMyData)
			dataImport := handlers.NewDataImportHandler(stores, objStore)
			protected.POST("/import/me", dataImport.ImportMyData)
			protected.POST("/import/chatgpt", dataImport.ImportChatGPT)
			gdprH := handlers.NewGDPRHandler(stores)
			protected.DELETE("/me", gdprH.DeleteMyAccount)

			// Hook: clean up storage files when channels are deleted
			handlers.SetChannelDeleteHook(fileH.CleanupChannelStorage)

			// Knowledge Bases (RAG — v0.14.0)
			kbH := handlers.NewKnowledgeBaseHandler(stores, objStore, kbIngester, kbEmbedder)
			protected.POST("/knowledge-bases", middleware.RequirePermission(auth.PermKBCreate, stores), kbH.CreateKB)
			protected.GET("/knowledge-bases", kbH.ListKBs)
			protected.GET("/knowledge-bases/:id", kbH.GetKB)
			protected.PUT("/knowledge-bases/:id", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.UpdateKB)
			protected.DELETE("/knowledge-bases/:id", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.DeleteKB)
			protected.POST("/knowledge-bases/:id/documents", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.UploadDocument)
			protected.GET("/knowledge-bases/:id/documents", kbH.ListDocuments)
			protected.GET("/knowledge-bases/:id/documents/:docId/status", kbH.GetDocumentStatus)
			protected.DELETE("/knowledge-bases/:id/documents/:docId", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.DeleteDocument)
			protected.POST("/knowledge-bases/:id/search", middleware.RequirePermission(auth.PermKBRead, stores), kbH.SearchKB)
			protected.POST("/knowledge-bases/:id/rebuild", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.RebuildKB)
			protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs)
			protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs)
			protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs)           // v0.17.0
			protected.PUT("/knowledge-bases/:id/discoverable", kbH.SetDiscoverable)           // v0.17.0 (admin only enforced in handler)

			// Memory management (v0.18.0)
			memH := handlers.NewMemoryHandler(stores)
			memH.SetCompactor(memCompactor)
			protected.GET("/memories", memH.ListMyMemories)
			protected.PUT("/memories/:id", memH.UpdateMemory)
			protected.DELETE("/memories/:id", memH.DeleteMemory)
			protected.POST("/memories/:id/approve", memH.ApproveMemory)
			protected.POST("/memories/:id/reject", memH.RejectMemory)
			protected.GET("/memories/count", memH.MemoryCount)
			protected.POST("/memories/compact", memH.CompactMemories)

			// Teams (user: my teams)
			teams := handlers.NewTeamHandler(stores, keyResolver)
			protected.GET("/teams/mine", teams.MyTeams)

			// Groups (user: my groups — v0.16.0)
			groupH := handlers.NewGroupHandler(stores)
			protected.GET("/groups/mine", groupH.MyGroups)

			// Team admin self-service
			teamScoped := protected.Group("/teams/:teamId")
			teamScoped.Use(middleware.RequireTeamAdmin(stores.Teams))
			{
				teamScoped.GET("/members", teams.ListMembers)
				teamScoped.POST("/members", teams.AddMember)
				teamScoped.PUT("/members/:memberId", teams.UpdateMember)
				teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
				teamScoped.GET("/models", teams.ListAvailableModels)

				// Team groups (team admins manage team-scoped groups)
				teamScoped.GET("/groups", groupH.ListTeamGroups)

				// Team providers
				teamScoped.GET("/providers", teams.ListTeamProviders)
				teamScoped.POST("/providers", teams.CreateTeamProvider)
				teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider)
				teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
				teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)

				// Team package management (v0.30.0)
				teamPkgH := handlers.NewUserPackageHandler(stores, userPkgDir)
				teamScoped.POST("/packages/install", teamPkgH.InstallTeamPackage)
				teamScoped.DELETE("/packages/:id", teamPkgH.DeleteTeamPackage)

			// Team audit log (team admins only — RequireTeamAdmin on group)
			teamScoped.GET("/audit", teams.ListTeamAuditLog)
			teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)

				// Team usage (team admins only — usage against team-owned providers)
				teamUsage := handlers.NewUsageHandler(stores)
				teamScoped.GET("/usage", teamUsage.TeamUsage)

				// Team personas
				teamPersonas := handlers.NewPersonaHandler(stores)
				teamScoped.GET("/personas", teamPersonas.ListTeamPersonas)
				teamScoped.POST("/personas", teamPersonas.CreateTeamPersona)
				teamScoped.PUT("/personas/:id", teamPersonas.UpdateTeamPersona)
				teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona)
				teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetTeamPersonaKBs)    // v0.17.0
				teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetTeamPersonaKBs)    // v0.17.0
				teamScoped.GET("/personas/:id/tool-grants", teamPersonas.GetTeamPersonaToolGrants)  // v0.28.0
				teamScoped.PUT("/personas/:id/tool-grants", teamPersonas.SetTeamPersonaToolGrants)  // v0.28.0
				teamScoped.POST("/personas/:id/avatar", teamPersonas.UploadTeamPersonaAvatar)       // v0.28.0
				teamScoped.DELETE("/personas/:id/avatar", teamPersonas.DeleteTeamPersonaAvatar)     // v0.28.0

				// Team role overrides
				teamRoles := handlers.NewRolesHandler(stores, roleResolver)
				teamScoped.GET("/roles", teamRoles.ListTeamRoles)
				teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
				teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)

				// Team workflow assignments (v0.26.4)
				teamAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub)
				teamScoped.GET("/assignments", teamAssignH.ListForTeam)

				// Team workflows — self-service (v0.31.2)
				teamWfH := handlers.NewWorkflowHandler(stores)
				teamScoped.GET("/workflows", teamWfH.ListTeamWorkflows)
				teamScoped.POST("/workflows", teamWfH.CreateTeamWorkflow)
				teamScoped.GET("/workflows/:id", teamWfH.GetTeamWorkflow)
				teamScoped.PATCH("/workflows/:id", teamWfH.UpdateTeamWorkflow)
				teamScoped.DELETE("/workflows/:id", teamWfH.DeleteTeamWorkflow)
				teamScoped.GET("/workflows/:id/stages", teamWfH.ListTeamWorkflowStages)
				teamScoped.POST("/workflows/:id/stages", teamWfH.CreateTeamWorkflowStage)
				teamScoped.PUT("/workflows/:id/stages/:sid", teamWfH.UpdateTeamWorkflowStage)
				teamScoped.DELETE("/workflows/:id/stages/:sid", teamWfH.DeleteTeamWorkflowStage)
				teamScoped.PATCH("/workflows/:id/stages/reorder", teamWfH.ReorderTeamWorkflowStages)
				teamScoped.POST("/workflows/:id/publish", teamWfH.PublishTeamWorkflow)
				teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion)

				// Team workflow monitoring (v0.35.0)
				teamWfMon := handlers.NewWorkflowMonitorHandler(stores)
				teamScoped.GET("/workflows/monitor/instances", teamWfMon.ListTeamActiveInstances)

				// Team workflow instance cancel (v0.37.15)
				teamWfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc, starlarkRunner)
				teamScoped.POST("/workflows/monitor/instances/:channelId/cancel", teamWfInstH.CancelTeamInstance)

				// Team tasks — admin CRUD (v0.27.5)
				teamTaskH := handlers.NewTaskHandler(stores)
				teamScoped.POST("/tasks", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.CreateTeamTask)
				teamScoped.PUT("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.Update)
				teamScoped.DELETE("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.Delete)
				teamScoped.POST("/tasks/:id/run", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.RunNow)
				teamScoped.POST("/tasks/:id/kill", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.KillRun)
			}

			// Team task viewing for all members (v0.27.5)
			teamMemberRoutes := protected.Group("/teams/:teamId")
			teamMemberRoutes.Use(middleware.RequireTeamMember(stores.Teams))
			{
				teamMemberTaskH := handlers.NewTaskHandler(stores)
				teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks)
				teamMemberRoutes.GET("/tasks/:id/runs", teamMemberTaskH.ListRuns)
			}

			// Public global settings (non-admin users can read safe subset)
			adm := handlers.NewAdminHandler(stores, keyResolver, uekCache, objStore)
			protected.GET("/settings/public", adm.PublicSettings)

			// Extensions (user-facing)
			protected.GET("/extensions", extH.ListUserExtensions)
			protected.POST("/extensions/:id/settings", extH.UpdateUserExtensionSettings)
			protected.GET("/extensions/:id/manifest", extH.GetExtensionManifest)
			protected.GET("/extensions/tools", extH.ListBrowserToolSchemas)
		}

		// ── Admin routes ────────────────────────
		admin := api.Group("/admin")
		admin.Use(middleware.Auth(cfg, stores.Users, userCache))
		admin.Use(middleware.RequireAdmin())
		admin.Use(middleware.ValidatePathParams())
		{
			adm := handlers.NewAdminHandler(stores, keyResolver, uekCache, objStore)
			adm.OnUserChanged(userCache.Evict)

			// User management
			admin.GET("/users", adm.ListUsers)
			admin.POST("/users", adm.CreateUser)
			admin.PUT("/users/:id/role", adm.UpdateUserRole)
			admin.PUT("/users/:id/active", adm.ToggleUserActive)
			admin.POST("/users/:id/reset-password", adm.ResetPassword)
			admin.POST("/users/:id/vault/reset", adm.ResetVault)
			admin.DELETE("/users/:id", adm.DeleteUser)

			// Global settings
			admin.GET("/settings", adm.ListGlobalSettings)
			admin.GET("/settings/:key", adm.GetGlobalSetting)
			admin.PUT("/settings/:key", adm.UpdateGlobalSetting)

			// Stats
			admin.GET("/stats", adm.GetStats)

			// Global Provider Configs
			admin.GET("/configs", adm.ListGlobalConfigs)
			admin.POST("/configs", adm.CreateGlobalConfig)
			admin.PUT("/configs/:id", adm.UpdateGlobalConfig)
			admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)

			// Model Catalog
			admin.GET("/models", adm.ListModelConfigs)
			admin.POST("/models/fetch", adm.FetchModels)
			admin.PUT("/models/bulk", adm.BulkUpdateModels)
			admin.PUT("/models/:id", adm.UpdateModelConfig)
			admin.DELETE("/models/:id", adm.DeleteModelConfig)

			// Personas (admin global)
			personaAdm := handlers.NewPersonaHandler(stores)
			admin.GET("/personas", personaAdm.ListAdminPersonas)
			admin.POST("/personas", personaAdm.CreateAdminPersona)
			admin.PUT("/personas/:id", personaAdm.UpdateAdminPersona)
			admin.DELETE("/personas/:id", personaAdm.DeleteAdminPersona)
			admin.POST("/personas/:id/avatar", func(c *gin.Context) { handlers.UploadPersonaAvatar(stores.Personas, c) })
			admin.DELETE("/personas/:id/avatar", func(c *gin.Context) { handlers.DeletePersonaAvatar(stores.Personas, c) })
			admin.GET("/personas/:id/knowledge-bases", personaAdm.GetPersonaKBs)    // v0.17.0
			admin.PUT("/personas/:id/knowledge-bases", personaAdm.SetPersonaKBs)    // v0.17.0
			admin.GET("/personas/:id/tool-grants", personaAdm.GetPersonaToolGrants) // v0.25.0
			admin.PUT("/personas/:id/tool-grants", personaAdm.SetPersonaToolGrants) // v0.25.0

			// Admin memory review (v0.18.0)
			adminMemH := handlers.NewMemoryHandler(stores)
			admin.GET("/memories/pending", adminMemH.ListPendingReview)
			admin.POST("/memories/bulk-approve", adminMemH.BulkApprove)

			// Teams (admin)
			teamAdm := handlers.NewTeamHandler(stores, keyResolver)
			admin.GET("/teams", teamAdm.ListTeams)
			admin.POST("/teams", teamAdm.CreateTeam)
			admin.GET("/teams/:id", teamAdm.GetTeam)
			admin.PUT("/teams/:id", teamAdm.UpdateTeam)
			admin.DELETE("/teams/:id", teamAdm.DeleteTeam)
			admin.GET("/teams/:id/members", teamAdm.ListMembers)
			admin.POST("/teams/:id/members", teamAdm.AddMember)
			admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember)
			admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember)

			// Team data export/import (v0.34.0)
			teamExport := handlers.NewDataExportHandler(stores, objStore)
			teamImport := handlers.NewDataImportHandler(stores, objStore)
			admin.GET("/teams/:id/export", teamExport.ExportTeam)
			admin.POST("/teams/:id/import", teamImport.ImportTeam)

			// Admin broadcast (v0.28.6)
			adminNotifH := handlers.NewNotificationHandler(stores, hub)
			admin.POST("/notifications/broadcast", adminNotifH.Broadcast)

			// Audit log
			admin.GET("/audit", adm.ListAuditLog)
			admin.GET("/audit/actions", adm.ListAuditActions)

			// Groups (admin — v0.16.0)
			groupAdm := handlers.NewGroupHandler(stores)
			admin.GET("/groups", groupAdm.ListGroups)
			admin.POST("/groups", groupAdm.CreateGroup)
			admin.GET("/groups/:id", groupAdm.GetGroup)
			admin.PUT("/groups/:id", groupAdm.UpdateGroup)
			admin.DELETE("/groups/:id", groupAdm.DeleteGroup)
			admin.GET("/groups/:id/members", groupAdm.ListMembers)
			admin.POST("/groups/:id/members", groupAdm.AddMember)
			admin.DELETE("/groups/:id/members/:userId", groupAdm.RemoveMember)

			// Permissions (v0.24.2)
			admin.GET("/permissions", groupAdm.ListPermissions)
			admin.GET("/users/:id/permissions", groupAdm.GetUserPermissions)

			// Resource Grants (admin — v0.16.0)
			admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant)
			admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)

			// Projects (admin — v0.19.0)
			adminProjH := handlers.NewProjectHandler(stores)
			admin.GET("/projects", adminProjH.AdminList)
			admin.DELETE("/projects/:id", adminProjH.Delete)
			admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)

			// Model Roles
			rolesH := handlers.NewRolesHandler(stores, roleResolver)
			admin.GET("/roles", rolesH.ListRoles)
			admin.GET("/roles/:role", rolesH.GetRole)
			admin.PUT("/roles/:role", rolesH.UpdateRole)
			admin.POST("/roles/:role/test", rolesH.TestRole)

			// Usage & Pricing
			usageH := handlers.NewUsageHandler(stores)
			admin.GET("/usage", usageH.AdminUsage)
			admin.GET("/usage/users/:id", usageH.AdminUserUsage)
			admin.GET("/usage/teams/:id", usageH.AdminTeamUsage)
			admin.GET("/pricing", usageH.ListPricing)
			admin.PUT("/pricing", usageH.UpsertPricing)
			admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)

			// Storage status
			storageH := handlers.NewStorageHandler(objStore)
			admin.GET("/storage/status", storageH.Status)

			// Vault
			admin.GET("/vault/status", adm.VaultStatus)

			// Email / SMTP test (v0.20.0 Phase 3)
			emailAdm := handlers.NewAdminEmailHandler(stores)
			admin.POST("/notifications/test-email", emailAdm.TestEmail)

			// Storage management (orphan cleanup)
			fileAdm := handlers.NewFileHandler(stores, objStore, extQueue)
			admin.GET("/storage/orphans", fileAdm.OrphanCount)
			admin.POST("/storage/cleanup", fileAdm.CleanupOrphans)
			admin.GET("/storage/extraction", fileAdm.ExtractionStatus)

			// Archived channels (admin — v0.23.2)
			admin.GET("/channels/archived", adm.ListArchivedChannels)
			admin.DELETE("/channels/:id/purge", adm.PurgeChannel)

			// Extensions (admin)
			extAdm := handlers.NewExtensionHandler(stores)
			admin.GET("/extensions", extAdm.AdminListExtensions)
			admin.POST("/extensions", extAdm.AdminInstallExtension)
			admin.PUT("/extensions/:id", extAdm.AdminUpdateExtension)
			admin.DELETE("/extensions/:id", extAdm.AdminUninstallExtension)

			// Extension permissions (admin — v0.29.0)
			extPermH := handlers.NewExtPermHandler(stores)
			admin.GET("/extensions/:id/permissions", extPermH.ListPackagePermissions)
			admin.GET("/extensions/:id/review", extPermH.ReviewPackage)
			admin.POST("/extensions/:id/permissions/:perm/grant", extPermH.GrantPermission)
			admin.POST("/extensions/:id/permissions/:perm/revoke", extPermH.RevokePermission)
			admin.POST("/extensions/:id/permissions/grant-all", extPermH.GrantAllPermissions)

			// Extension secrets (admin — v0.29.0 CS3)
			extSecH := handlers.NewExtSecretsHandler(stores)
			admin.GET("/extensions/:id/secrets", extSecH.GetSecrets)
			admin.PUT("/extensions/:id/secrets", extSecH.SetSecrets)
			admin.DELETE("/extensions/:id/secrets", extSecH.DeleteSecrets)

			// Admin Dashboard (v0.33.0)
			dashAdm := handlers.NewDashboardAdminHandler(stores, healthStore, hub)
			admin.GET("/dashboard", dashAdm.GetDashboard)

			// Provider Health (admin — v0.22.0)
			healthAdm := handlers.NewHealthAdminHandler(healthStore, stores)
			admin.GET("/providers/health", healthAdm.GetAllProviderHealth)
			admin.GET("/providers/:id/health", healthAdm.GetProviderHealth)

			// Capability Overrides (admin — v0.22.0)
			capAdm := handlers.NewCapOverrideAdminHandler(stores)
			admin.GET("/models/:id/capabilities", capAdm.GetModelCapabilities)
			admin.PUT("/models/:id/capabilities", capAdm.SetModelCapability)
			admin.DELETE("/models/:id/capabilities/:overrideId", capAdm.DeleteModelCapability)
			admin.GET("/capability-overrides", capAdm.ListAllOverrides)

			// Provider Types (admin — v0.22.1)
			admin.GET("/provider-types", handlers.GetProviderTypes)

			// Routing Policies (admin — v0.22.2)
			routingEval := routing.NewEvaluator()
			routingAdm := handlers.NewRoutingAdminHandler(stores, routingEval, healthStore)
			admin.GET("/routing/policies", routingAdm.ListPolicies)
			admin.GET("/routing/policies/:id", routingAdm.GetPolicy)
			admin.POST("/routing/policies", routingAdm.CreatePolicy)
			admin.PUT("/routing/policies/:id", routingAdm.UpdatePolicy)
			admin.DELETE("/routing/policies/:id", routingAdm.DeletePolicy)
			admin.POST("/routing/test", routingAdm.TestRouting)

			// Package lifecycle management (v0.28.7 — replaces surfaces + extensions admin)
			packagesDir := ""
			if cfg.StoragePath != "" {
				packagesDir = cfg.StoragePath + "/packages"
			}
			pkgAdm := handlers.NewPackageHandler(stores, packagesDir)
			pkgAdm.SetSandbox(sandbox.New(sandbox.DefaultConfig())) // v0.30.0: schema migrations

			// Package registry — must be registered before /packages/:id (v0.30.0)
			registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm)
			admin.GET("/packages/registry", registryH.BrowseRegistry)
			admin.POST("/packages/registry/install", registryH.InstallFromRegistry)

			admin.GET("/packages", pkgAdm.ListPackages)
			admin.GET("/packages/:id", pkgAdm.GetPackage)
			admin.POST("/packages/install", pkgAdm.InstallPackage)
			admin.PUT("/packages/:id/enable", pkgAdm.EnablePackage)
			admin.PUT("/packages/:id/disable", pkgAdm.DisablePackage)
			admin.DELETE("/packages/:id", pkgAdm.DeletePackage)
			admin.GET("/packages/:id/settings", pkgAdm.GetPackageSettings)   // v0.30.0
			admin.PUT("/packages/:id/settings", pkgAdm.UpdatePackageSettings) // v0.30.0

			// Package export (v0.30.0)
			pkgExport := handlers.NewPackageExportHandler(stores, packagesDir)
			admin.GET("/packages/:id/export", pkgExport.ExportPackage)

			// Workflow package export (v0.30.2)
			wfPkgH := handlers.NewWorkflowPackageHandler(stores)
			admin.GET("/workflows/:id/export", wfPkgH.ExportWorkflowPackage)

			// Workflow monitoring (v0.35.0)
			wfMonH := handlers.NewWorkflowMonitorHandler(stores)
			admin.GET("/workflows/monitor/instances", wfMonH.ListActiveInstances)
			admin.GET("/workflows/monitor/funnel/:id", wfMonH.GetFunnel)
			admin.GET("/workflows/monitor/stale", wfMonH.ListStaleInstances)

			// Surface aliases (backward compat — same handlers)
			admin.GET("/surfaces", pkgAdm.ListPackages)
			admin.GET("/surfaces/:id", pkgAdm.GetPackage)
			admin.POST("/surfaces/install", pkgAdm.InstallPackage)
			admin.PUT("/surfaces/:id/enable", pkgAdm.EnablePackage)
			admin.PUT("/surfaces/:id/disable", pkgAdm.DisablePackage)
			admin.DELETE("/surfaces/:id", pkgAdm.DeletePackage)

			// Task management — admin (v0.27.1, extended v0.27.2)
			taskAdm := handlers.NewTaskHandler(stores)
			admin.GET("/tasks", taskAdm.ListAll)
			admin.POST("/tasks/:id/run", taskAdm.RunNow)
			admin.POST("/tasks/:id/kill", taskAdm.KillRun)
			admin.DELETE("/tasks/:id", taskAdm.Delete)
			admin.GET("/system-functions", taskAdm.ListSystemFunctions)
		}
	}

	// ── Page Routes (v0.22.5) ────────────────
	// Server-rendered pages via Go templates. Runs alongside the SPA.
	// The chat surface is a bridge: Go renders the shell, existing JS
	// builds the DOM inside it — identical behavior to index.html.

	// v0.27.0: Extension surface static assets (JS, CSS, images).
	// Served without auth — same rationale as extension assets (script tags can't send headers).
	// v0.28.7: on-disk path moved to /data/packages/, URL stays /surfaces/:id/ for stability.
	// In split deployment, nginx serves these from /data/packages/ directly.
	if cfg.StoragePath != "" {
		packagesStaticDir := cfg.StoragePath + "/packages"
		base.GET("/surfaces/:id/*path", func(c *gin.Context) {
			id := c.Param("id")
			filePath := c.Param("path")
			// Security: prevent path traversal
			clean := filepath.Clean(filepath.Join(packagesStaticDir, id, filePath))
			if !strings.HasPrefix(clean, filepath.Clean(packagesStaticDir)) {
				c.Status(http.StatusForbidden)
				return
			}
			c.File(clean)
		})
	}

	pages.SetVersion(Version)
	pageEngine := pages.New(cfg, stores)

	// Login page — no auth required
	base.GET("/login", pageEngine.RenderLogin())

	// v0.25.0: Surface routes generated from manifest registry.
	// Replaces manual per-surface route blocks.
	pageEngine.RegisterPageRoutes(base, pages.PageRouteMiddleware{
		Authenticated: middleware.AuthOrRedirect(cfg, stores.Users, userCache),
		Admin: []gin.HandlerFunc{
			middleware.AuthOrRedirect(cfg, stores.Users, userCache),
			middleware.RequireAdminPage(),
		},
		Session: middleware.AuthOrSession(cfg, stores, userCache),
	})

	// v0.29.1: Extension API routes — Starlark packages serve JSON endpoints.
	// Mounted at /s/:slug/api/* with JWT auth (returns 401, not redirect).
	{
		extAPIH := handlers.NewExtAPIHandler(stores, starlarkRunner)
		extAPI := base.Group("/s/:slug/api")
		extAPI.Use(middleware.Auth(cfg, stores.Users, userCache))
		extAPI.Any("/*path", extAPIH.Handle)
	}

	// Workflow API — session participants can send messages + trigger completions
	wfAPI := base.Group("/api/v1/w")
	wfAPI.Use(middleware.AuthOrSession(cfg, stores, userCache))
	{
		wfMsgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
		wfAPI.POST("/:id/messages", wfMsgs.CreateMessage)
		wfAPI.GET("/:id/messages", wfMsgs.ListMessages)

		wfComp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder)
		wfComp.SetFilterChain(filterChain)
		wfComp.SetRunner(starlarkRunner) // v0.29.2: extension tool dispatch
		wfAPI.POST("/:id/completions", wfComp.Complete)

		// v0.29.3: Workflow form endpoints
		wfForms := handlers.NewWorkflowFormHandler(stores, starlarkRunner, hub)
		wfAPI.GET("/:id/form", wfForms.GetFormTemplate)
		wfAPI.POST("/:id/form-submit", wfForms.SubmitForm)
	}

	// Workflow visitor entry (v0.26.3) — public start endpoint
	// NOTE: Cannot use /api/v1/w/:scope/:slug/start — Gin's radix trie
	// conflicts with /api/v1/w/:id/messages (different wildcard names at
	// same path position). Separate namespace avoids the collision.
	wfEntry := handlers.NewWorkflowEntryHandler(stores)
	base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor)

	// v0.28.0: Webhook trigger endpoint (token-based auth, no JWT)
	triggerH := handlers.NewTriggerHandler(stores)
	base.POST("/api/v1/hooks/t/:token", triggerH.Handle)

	bp := cfg.BasePath
	if bp == "" {
		bp = "/"
	}
	log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port)
	log.Printf("   Base path: %s", bp)
	log.Printf("   Schema: %s", database.SchemaVersion())
	log.Printf("   Providers: %v", providers.List())
	if objStore != nil {
		log.Printf("   Storage: %s", objStore.Backend())
		if extQueue != nil {
			log.Printf("   Extraction: enabled (concurrency=%d)", cfg.ExtractionConcurrency)
		} else {
			log.Printf("   Extraction: disabled")
		}
	} else {
		log.Printf("   Storage: disabled")
	}
	log.Printf("   EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
	log.Printf("   Health: provider tracking active (flush=%s, prune=%s)", health.FlushInterval, health.PruneAge)
	log.Printf("   Pages: template engine active (%d surfaces registered)", len(pageEngine.Surfaces()))
	if err := r.Run(":" + cfg.Port); err != nil {
		log.Fatalf("Failed to start server: %v", err)
	}
}

// ── Vault CLI Commands ──────────────────────

// loadSearchConfig reads search provider config from global_config and applies it.
// Falls back to DuckDuckGo (the default set in search package init) if not configured.
func loadSearchConfig(stores store.Stores) {
	raw, err := stores.GlobalConfig.Get(context.Background(), "search_config")
	if err != nil || raw == nil {
		log.Println("🔍 Search: using default provider (DuckDuckGo)")
		return
	}
	b, _ := json.Marshal(raw)
	var cfg search.Config
	if err := json.Unmarshal(b, &cfg); err != nil {
		log.Printf("⚠️ Failed to parse search config: %v", err)
		return
	}
	if err := search.ApplyConfig(cfg); err != nil {
		log.Printf("⚠️ Failed to apply search config: %v", err)
	}
}

func runVaultCommand(subcmd string) {
	cfg := config.Load()

	switch strings.ToLower(subcmd) {
	case "rekey":
		if cfg.DatabaseURL == "" {
			log.Fatal("DATABASE_URL is required for vault operations")
		}
		if err := database.Connect(cfg); err != nil {
			log.Fatalf("❌ Database connection failed: %v", err)
		}
		defer database.Close()

		oldKey := os.Getenv("ENCRYPTION_KEY")
		newKey := os.Getenv("NEW_ENCRYPTION_KEY")

		if err := crypto.Rekey(database.DB, oldKey, newKey); err != nil {
			log.Fatalf("❌ Vault rekey failed: %v", err)
		}

	case "status":
		if cfg.DatabaseURL == "" {
			log.Fatal("DATABASE_URL is required for vault operations")
		}
		if err := database.Connect(cfg); err != nil {
			log.Fatalf("❌ Database connection failed: %v", err)
		}
		defer database.Close()

		status, err := crypto.VaultStatus(database.DB, cfg.EncryptionKey)
		if err != nil {
			log.Fatalf("❌ Failed to get vault status: %v", err)
		}
		fmt.Printf("Encryption key set:   %v\n", status.EncryptionKeySet)
		fmt.Printf("Encrypted keys:       %d\n", status.EncryptedKeys)
		fmt.Printf("Vault users (active): %d\n", status.VaultUsers)

	default:
		fmt.Fprintf(os.Stderr, "Unknown vault command: %s\n", subcmd)
		fmt.Fprintf(os.Stderr, "Usage: switchboard vault <rekey|status>\n")
		os.Exit(1)
	}
}
