package handlers

import (
	"archive/zip"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"strings"

	"github.com/gin-gonic/gin"
	"go.starlark.net/starlark"

	"switchboard-core/database"
	"switchboard-core/models"
	"switchboard-core/sandbox"
	"switchboard-core/store"
	"switchboard-core/triggers"
)

// validPackageID matches lowercase alphanumeric slugs with optional hyphens.
// No leading/trailing hyphens, 2-64 chars.
var validPackageID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)

// PackageHandler manages unified package lifecycle (admin-only).
// Replaces SurfaceHandler (v0.28.7).
type PackageHandler struct {
	stores      store.Stores
	packagesDir string           // e.g. /data/packages — where archives are extracted
	sandbox     *sandbox.Sandbox // v0.30.0: for schema migration scripts
	runner      *sandbox.Runner  // v0.38.2: for test-tool execution
}

func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
	dir := ""
	if len(packagesDir) > 0 {
		dir = packagesDir[0]
	}
	return &PackageHandler{stores: s, packagesDir: dir}
}

// SetSandbox attaches a Starlark sandbox for schema migrations (v0.30.0).
func (h *PackageHandler) SetSandbox(sb *sandbox.Sandbox) {
	h.sandbox = sb
}

// SetRunner attaches the Starlark runner for test-tool execution (v0.38.2).
func (h *PackageHandler) SetRunner(r *sandbox.Runner) {
	h.runner = r
}

// ListPackages returns all registered packages.
// GET /api/v1/admin/packages
// GET /api/v1/admin/surfaces (alias)
func (h *PackageHandler) ListPackages(c *gin.Context) {
	typeFilter := c.Query("type")

	var pkgs []store.PackageRegistration
	var err error
	if typeFilter != "" {
		pkgs, err = h.stores.Packages.ListByType(c.Request.Context(), typeFilter)
	} else {
		pkgs, err = h.stores.Packages.List(c.Request.Context())
	}
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"})
		return
	}
	if pkgs == nil {
		pkgs = []store.PackageRegistration{}
	}
	c.JSON(http.StatusOK, gin.H{"data": pkgs})
}

// GetPackage returns a single package by ID.
// GET /api/v1/admin/packages/:id
// GET /api/v1/admin/surfaces/:id (alias)
func (h *PackageHandler) GetPackage(c *gin.Context) {
	id := c.Param("id")
	pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
	if err != nil || pkg == nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
		return
	}
	c.JSON(http.StatusOK, pkg)
}

// EnablePackage enables a package.
// PUT /api/v1/admin/packages/:id/enable
// PUT /api/v1/admin/surfaces/:id/enable (alias)
func (h *PackageHandler) EnablePackage(c *gin.Context) {
	id := c.Param("id")

	// v0.3.7: Block enabling dormant packages (unmet requires).
	pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
	if err != nil || pkg == nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
		return
	}
	if pkg.Status == "dormant" {
		c.JSON(http.StatusConflict, gin.H{
			"error": "package is dormant — it requires capabilities not yet available (e.g. chat)",
		})
		return
	}

	if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, true); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true})
}

// DisablePackage disables a package. Admin cannot be disabled.
// PUT /api/v1/admin/packages/:id/disable
// PUT /api/v1/admin/surfaces/:id/disable (alias)
func (h *PackageHandler) DisablePackage(c *gin.Context) {
	id := c.Param("id")

	if id == "admin" {
		c.JSON(http.StatusBadRequest, gin.H{"error": id + " cannot be disabled"})
		return
	}

	if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, false); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"id": id, "enabled": false})
}

// DeletePackage uninstalls a package. Core packages cannot be deleted.
// DELETE /api/v1/admin/packages/:id
// DELETE /api/v1/admin/surfaces/:id (alias)
func (h *PackageHandler) DeletePackage(c *gin.Context) {
	id := c.Param("id")

	pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
	if err != nil || pkg == nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
		return
	}
	if pkg.Source == "core" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "core packages cannot be uninstalled"})
		return
	}

	// v0.38.2: Libraries with active consumers cannot be uninstalled.
	if pkg.Type == "library" && h.stores.Dependencies != nil {
		hasConsumers, _ := h.stores.Dependencies.HasConsumers(c.Request.Context(), id)
		if hasConsumers {
			c.JSON(http.StatusConflict, gin.H{"error": "cannot uninstall: library has active consumers"})
			return
		}
	}

	// v0.38.2: Clean up dependency records when uninstalling a consumer.
	if h.stores.Dependencies != nil {
		h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), id)
	}

	// v0.29.2: Drop namespaced DB tables before removing the package record.
	if err := DropExtTables(c.Request.Context(), database.DB, h.stores, id); err != nil {
		log.Printf("[ext-db] schema drop failed for %s: %v", id, err)
	}

	if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"})
		return
	}

	// Clean up extracted static assets
	if h.packagesDir != "" {
		assetDir := filepath.Join(h.packagesDir, id)
		if err := os.RemoveAll(assetDir); err != nil {
			log.Printf("⚠️ Failed to clean up package assets at %s: %v", assetDir, err)
		}
	}

	c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true})
}

// InstallPackage uploads and installs a .pkg/.surface archive.
// POST /api/v1/admin/packages/install
// POST /api/v1/admin/surfaces/install (alias)
//
// Also supports pre-downloaded files via gin context:
//   c.Set("_registry_file", "/path/to/file.pkg")   — skip form upload
//   c.Set("_registry_source", "registry")            — override source
func (h *PackageHandler) InstallPackage(c *gin.Context) {
	var tmpPath string
	var cleanupTmp bool

	// v0.30.0: Check for pre-downloaded file from registry install
	if regFile, ok := c.Get("_registry_file"); ok {
		tmpPath = regFile.(string)
		// Don't remove — caller manages lifecycle
	} else {
		file, header, err := c.Request.FormFile("file")
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
			return
		}
		defer file.Close()

		// Validate extension
		validExt := strings.HasSuffix(header.Filename, ".pkg") ||
			strings.HasSuffix(header.Filename, ".surface") ||
			strings.HasSuffix(header.Filename, ".zip")
		if !validExt {
			c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
			return
		}

		// Size limit: 50MB
		if header.Size > 50*1024*1024 {
			c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
			return
		}

		// Read into temp file for zip processing
		tmpFile, err := os.CreateTemp("", "package-*.zip")
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
			return
		}
		tmpPath = tmpFile.Name()
		cleanupTmp = true

		if _, err := io.Copy(tmpFile, file); err != nil {
			tmpFile.Close()
			os.Remove(tmpPath)
			c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
			return
		}
		tmpFile.Close()
	}
	if cleanupTmp {
		defer os.Remove(tmpPath)
	}

	// Open as zip
	zr, err := zip.OpenReader(tmpPath)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
		return
	}
	defer zr.Close()

	// Find and parse manifest.json
	var manifest map[string]any
	for _, f := range zr.File {
		name := filepath.Base(f.Name)
		if name == "manifest.json" && !f.FileInfo().IsDir() {
			rc, err := f.Open()
			if err != nil {
				continue
			}
			data, err := io.ReadAll(rc)
			rc.Close()
			if err != nil {
				continue
			}
			if err := json.Unmarshal(data, &manifest); err != nil {
				c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()})
				return
			}
			break
		}
	}

	if manifest == nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
		return
	}

	// v0.38.0: Entry point validation for starlark-tier packages.
	// Scripts are loaded from disk at runtime — no _starlark_script injection.
	if manifestTier, _ := manifest["tier"].(string); manifestTier == "starlark" {
		entryPoint := "script.star"
		if ep, ok := manifest["entry_point"].(string); ok && ep != "" {
			entryPoint = ep
		}
		found := false
		for _, f := range zr.File {
			base := filepath.Base(f.Name)
			if base == entryPoint && !f.FileInfo().IsDir() {
				found = true
				break
			}
		}
		if !found {
			c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("starlark package missing entry point %q", entryPoint)})
			return
		}
	}

	// Validate required fields
	pkgID, _ := manifest["id"].(string)
	title, _ := manifest["title"].(string)
	if pkgID == "" || title == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "manifest must have 'id' and 'title'"})
		return
	}

	// Validate package ID is a safe slug
	if !validPackageID.MatchString(pkgID) {
		c.JSON(http.StatusBadRequest, gin.H{"error": "package id must be a lowercase slug (a-z, 0-9, hyphens, 2-64 chars)"})
		return
	}

	// Determine type (default: surface for backward compat)
	pkgType, _ := manifest["type"].(string)
	if pkgType == "" {
		pkgType = "surface"
	}
	if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" && pkgType != "library" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', 'workflow', or 'library'"})
		return
	}

	// Type-specific validation
	hasRoute := manifest["route"] != nil || manifest["routes"] != nil
	hasTools := manifest["tools"] != nil
	hasPipes := manifest["pipes"] != nil
	hasHooks := manifest["hooks"] != nil
	hasSettings := manifest["settings"] != nil
	hasExtBehavior := hasTools || hasPipes || hasHooks

	switch pkgType {
	case "surface":
		// route is optional for surfaces (core surfaces don't always have it in manifest)
	case "extension":
		if !hasExtBehavior {
			c.JSON(http.StatusBadRequest, gin.H{"error": "extension packages require at least one of: tools, pipes, hooks"})
			return
		}
		if hasRoute {
			c.JSON(http.StatusBadRequest, gin.H{"error": "extension packages cannot have a route — use type 'full' for both"})
			return
		}
	case "full":
		if !hasRoute {
			c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require a route"})
			return
		}
		// v0.31.0: full packages need either server-side behavior (tools/pipes/hooks)
		// or a settings schema. A surface-with-settings is a valid "full" package
		// (e.g. editor: browser-tier surface + admin-configurable settings).
		if !hasExtBehavior && !hasSettings {
			c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require at least one of: tools, pipes, hooks, settings"})
			return
		}
	case "workflow":
		if manifest["workflow_definition"] == nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "workflow packages require a 'workflow_definition' in the manifest"})
			return
		}
	case "library":
		// v0.38.2: Libraries must declare exports, cannot have tools/pipes/route.
		exports, _ := manifest["exports"].([]any)
		if len(exports) == 0 {
			c.JSON(http.StatusBadRequest, gin.H{"error": "library packages require an 'exports' array"})
			return
		}
		if hasTools || hasPipes {
			c.JSON(http.StatusBadRequest, gin.H{"error": "library packages cannot have tools or pipes"})
			return
		}
		if hasRoute {
			c.JSON(http.StatusBadRequest, gin.H{"error": "library packages cannot have a route"})
			return
		}
	}

	// Check for conflicts with core packages
	existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
	if existing != nil && existing.Source == "core" {
		c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package: " + pkgID})
		return
	}

	// Extract static assets (js/, css/, assets/) to packagesDir/{id}/
	if h.packagesDir != "" {
		destDir := filepath.Join(h.packagesDir, pkgID)
		os.MkdirAll(destDir, 0755)

		for _, f := range zr.File {
			if f.FileInfo().IsDir() {
				continue
			}

			relPath := extractableRelPath(f.Name)
			if relPath == "" {
				continue
			}

			destPath := filepath.Join(destDir, relPath)

			// Security: prevent path traversal
			if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
				continue
			}

			os.MkdirAll(filepath.Dir(destPath), 0755)

			rc, err := f.Open()
			if err != nil {
				continue
			}
			out, err := os.Create(destPath)
			if err != nil {
				rc.Close()
				continue
			}
			io.Copy(out, rc)
			out.Close()
			rc.Close()
		}

		log.Printf("[packages] Extracted %s to %s", pkgID, destDir)
	}

	// Extract manifest fields for DB columns
	version, _ := manifest["version"].(string)
	if version == "" {
		version = "0.0.0"
	}
	description, _ := manifest["description"].(string)
	author, _ := manifest["author"].(string)
	tier, _ := manifest["tier"].(string)
	if tier == "" {
		tier = "browser"
	}

	userID := c.GetString("user_id")

	// v0.30.0: use registry source if set by registry install handler
	pkgSource := "extension"
	if src, ok := c.Get("_registry_source"); ok {
		pkgSource = src.(string)
	}

	// Register in database via Seed (upsert — handles re-install)
	if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, pkgSource, manifest); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"})
		return
	}

	// Read back to get the preserved enabled state (Seed doesn't override it)
	existing, _ = h.stores.Packages.Get(c.Request.Context(), pkgID)
	preservedEnabled := true
	if existing != nil {
		preservedEnabled = existing.Enabled
	}

	// Update fields that Seed doesn't set (type, version, author, etc.)
	pkg := &store.PackageRegistration{
		Title:       title,
		Type:        pkgType,
		Version:     version,
		Description: description,
		Author:      author,
		Tier:        tier,
		IsSystem:    false,
		Enabled:     preservedEnabled,
		Manifest:    manifest,
	}
	if userID != "" {
		pkg.InstalledBy = &userID
	}
	if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
		log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err)
	}

	// v0.29.2: Create namespaced DB tables declared in the manifest.
	if tables, ok := ParseDBTables(manifest); ok {
		if err := CreateExtTables(c.Request.Context(), database.DB, database.IsPostgres(), h.stores, pkgID, tables); err != nil {
			log.Printf("[ext-db] schema create failed for %s: %v", pkgID, err)
		}
	}

	// v0.38.5: Declare manifest permissions (same as AdminInstallExtension).
	// This creates the permission rows and sets status to pending_review
	// if the package declares permissions.
	SyncManifestPermissions(c, h.stores, pkgID, manifest)

	// v0.2.2: Sync triggers from manifest
	SyncManifestTriggers(c.Request.Context(), h.stores, triggers.GlobalEngine(), pkgID, manifest)

	// v0.30.0: Run schema migrations if declared.
	newSchemaVersion := ParseSchemaVersion(manifest)
	if newSchemaVersion > 0 && h.sandbox != nil {
		oldSchemaVersion := 0
		if existing != nil {
			oldSchemaVersion = existing.SchemaVersion
		}
		if newSchemaVersion < oldSchemaVersion {
			c.JSON(http.StatusConflict, gin.H{
				"error": fmt.Sprintf("downgrade not supported (current: %d, new: %d); uninstall first",
					oldSchemaVersion, newSchemaVersion),
			})
			return
		}
		if newSchemaVersion > oldSchemaVersion {
			if err := RunSchemaMigrations(
				c.Request.Context(), h.sandbox, h.stores,
				database.DB, database.IsPostgres(),
				pkgID, manifest, oldSchemaVersion, newSchemaVersion,
			); err != nil {
				log.Printf("[pkg-migrate] migration failed for %s: %v", pkgID, err)
				c.JSON(http.StatusInternalServerError, gin.H{
					"error": "schema migration failed: " + err.Error(),
				})
				return
			}
		}
	}

	// v0.30.2: Install workflow definition from package manifest.
	if pkgType == "workflow" {
		if err := InstallWorkflowFromManifest(c, h.stores, pkgID, manifest); err != nil {
			log.Printf("[packages] workflow install failed for %s: %v", pkgID, err)
			c.JSON(http.StatusInternalServerError, gin.H{"error": "workflow install failed: " + err.Error()})
			return
		}
	}

	// v0.38.2: Create dependency records from manifest "dependencies" map.
	// Libraries must be installed first; consumers declare them.
	if deps, ok := manifest["dependencies"].(map[string]any); ok && len(deps) > 0 {
		// Clear stale dependency records from a previous install of the same consumer.
		if h.stores.Dependencies != nil {
			h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), pkgID)
		}
		for libID, vSpec := range deps {
			lib, err := h.stores.Packages.Get(c.Request.Context(), libID)
			if err != nil || lib == nil {
				c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID})
				return
			}
			if lib.Type != "library" {
				c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
				return
			}
			if lib.Status == "suspended" || lib.Status == "dormant" {
				c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID})
				return
			}
			versionSpec, _ := vSpec.(string)
			if versionSpec == "" {
				versionSpec = ">=0.0.0"
			}
			if h.stores.Dependencies != nil {
				if err := h.stores.Dependencies.Create(c.Request.Context(), &models.ExtDependency{
					ConsumerID:  pkgID,
					LibraryID:   libID,
					VersionSpec: versionSpec,
					ResolvedVer: lib.Version,
				}); err != nil {
					log.Printf("[packages] dependency record failed for %s → %s: %v", pkgID, libID, err)
					c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dependency record"})
					return
				}
			}
		}
		log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
	}

	// v0.2.1: Auto-set default_surface when the first extension surface is installed.
	if (pkgType == "surface" || pkgType == "full") && pkgSource != "core" {
		if raw, err := h.stores.GlobalConfig.Get(c.Request.Context(), "default_surface"); err != nil || raw == nil {
			dflt := models.JSONMap{"id": pkgID}
			if setErr := h.stores.GlobalConfig.Set(c.Request.Context(), "default_surface", dflt, ""); setErr != nil {
				log.Printf("[packages] failed to auto-set default_surface to %s: %v", pkgID, setErr)
			} else {
				log.Printf("[packages] Auto-set default_surface to %s (first extension surface)", pkgID)
			}
		}
	}

	// v0.3.7: Auto-set dormant for packages with unmet requires.
	// Known capabilities: (none yet — chat and legacy-sdk are future/removed).
	knownCaps := map[string]bool{}
	var unmetReqs []string
	if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 {
		for _, r := range reqs {
			req, _ := r.(string)
			if req != "" && !knownCaps[req] {
				unmetReqs = append(unmetReqs, req)
			}
		}
	}
	isDormant := len(unmetReqs) > 0
	if isDormant {
		h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant")
		h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false)
		preservedEnabled = false
		log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
	}

	resp := gin.H{
		"id":      pkgID,
		"title":   title,
		"type":    pkgType,
		"version": version,
		"source":  pkgSource,
		"enabled": preservedEnabled,
	}
	if isDormant {
		resp["status"] = "dormant"
	}
	c.JSON(http.StatusOK, resp)
}

// extractableRelPath returns the relative path for a zip entry if it
// belongs to an extractable directory (js/, css/, assets/, star/) or
// is a bare .star file at the archive root.
// Returns "" if the file should be skipped.
func extractableRelPath(name string) string {
	staticPrefixes := []string{"js/", "css/", "assets/", "star/"}

	// Direct match (flat archive without package-id/ wrapper)
	for _, p := range staticPrefixes {
		if strings.HasPrefix(name, p) {
			return name
		}
	}

	// Bare .star file at root (e.g. "script.star")
	if strings.HasSuffix(name, ".star") && !strings.Contains(name, "/") {
		return name
	}

	// Nested inside package-id/ directory
	idx := strings.Index(name, "/")
	if idx <= 0 {
		return ""
	}

	rest := name[idx+1:]
	for _, p := range staticPrefixes {
		if strings.HasPrefix(rest, p) {
			return rest
		}
	}

	// Bare .star file inside package-id/ (e.g. "gitea-client/script.star")
	if strings.HasSuffix(rest, ".star") && !strings.Contains(rest, "/") {
		return rest
	}

	return ""
}

// ── Package settings (v0.30.0 CS2) ──────────────

// GetPackageSettings returns the settings schema from the manifest merged
// with current admin-configured values.
// GET /api/v1/admin/packages/:id/settings
func (h *PackageHandler) GetPackageSettings(c *gin.Context) {
	id := c.Param("id")
	pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
	if err != nil || pkg == nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
		return
	}

	// Extract settings schema from manifest
	schema, _ := pkg.Manifest["settings"].([]any)

	// Get current values
	current, err := h.stores.Packages.GetPackageSettings(c.Request.Context(), id)
	if err != nil {
		current = json.RawMessage("{}")
	}

	c.JSON(http.StatusOK, gin.H{
		"schema": schema,
		"values": json.RawMessage(current),
	})
}

// UpdatePackageSettings validates and stores admin-configured package settings.
// PUT /api/v1/admin/packages/:id/settings
func (h *PackageHandler) UpdatePackageSettings(c *gin.Context) {
	id := c.Param("id")
	pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
	if err != nil || pkg == nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
		return
	}

	var body map[string]any
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
		return
	}

	// Validate keys against declared schema
	schemaArr, _ := pkg.Manifest["settings"].([]any)
	validKeys := make(map[string]map[string]any, len(schemaArr))
	for _, s := range schemaArr {
		if entry, ok := s.(map[string]any); ok {
			if key, ok := entry["key"].(string); ok {
				validKeys[key] = entry
			}
		}
	}

	// Filter to only declared keys and type-check
	filtered := make(map[string]any, len(body))
	for k, v := range body {
		schemaDef, ok := validKeys[k]
		if !ok {
			continue // skip undeclared keys
		}
		settingType, _ := schemaDef["type"].(string)
		if validateSettingType(v, settingType) {
			filtered[k] = v
		}
	}

	settingsJSON, _ := json.Marshal(filtered)
	if err := h.stores.Packages.SetPackageSettings(c.Request.Context(), id, json.RawMessage(settingsJSON)); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save settings"})
		return
	}

	c.JSON(http.StatusOK, gin.H{"data": json.RawMessage(settingsJSON)})
}

// validateSettingType checks if a value matches the expected setting type.
func validateSettingType(v any, settingType string) bool {
	switch settingType {
	case "string", "select":
		_, ok := v.(string)
		return ok
	case "number":
		_, ok := v.(float64)
		return ok
	case "boolean":
		_, ok := v.(bool)
		return ok
	default:
		return true // unknown types pass through
	}
}

// ListEnabledSurfaces returns enabled surface/full packages for nav rendering.
// GET /api/v1/surfaces
func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
	pkgs, err := h.stores.Packages.List(c.Request.Context())
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"})
		return
	}

	type navSurface struct {
		ID    string `json:"id"`
		Title string `json:"title"`
		Route string `json:"route"`
		Icon  string `json:"icon,omitempty"`
	}

	var enabled []navSurface
	for _, p := range pkgs {
		if !p.Enabled {
			continue
		}
		// Only surface and full types have routes
		if p.Type != "surface" && p.Type != "full" {
			continue
		}
		route, _ := p.Manifest["route"].(string)
		icon, _ := p.Manifest["icon"].(string)
		enabled = append(enabled, navSurface{
			ID:    p.ID,
			Title: p.Title,
			Route: route,
			Icon:  icon,
		})
	}
	if enabled == nil {
		enabled = []navSurface{}
	}
	c.JSON(http.StatusOK, gin.H{"data": enabled})
}

// ── Test Tool (v0.38.2) ─────────────────────────
// POST /admin/packages/:id/test-tool
// Invokes a starlark extension's on_tool_call entry point directly,
// without going through the AI chat loop. Admin-only test harness.

// TestToolRequest is the JSON body for test-tool invocation.
type TestToolRequest struct {
	ToolName  string         `json:"tool_name" binding:"required"`
	Arguments map[string]any `json:"arguments"`
}

// TestTool invokes a package's on_tool_call entry point for testing.
func (h *PackageHandler) TestTool(c *gin.Context) {
	if h.runner == nil {
		c.JSON(http.StatusServiceUnavailable, gin.H{"error": "starlark runner not configured"})
		return
	}

	pkgID := c.Param("id")
	pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
		return
	}
	if pkg.Tier != "starlark" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "test-tool requires tier=starlark"})
		return
	}
	if !pkg.Enabled {
		c.JSON(http.StatusBadRequest, gin.H{"error": "package is disabled"})
		return
	}

	var req TestToolRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// Build a call dict identical to what executeExtensionTool sends.
	// Build the arguments as an interface{} map for jsonToStarlark.
	var args interface{}
	if req.Arguments != nil {
		argsJSON, _ := json.Marshal(req.Arguments)
		_ = json.Unmarshal(argsJSON, &args)
	}

	callDict := starlark.NewDict(3)
	_ = callDict.SetKey(starlark.String("tool_name"), starlark.String(req.ToolName))
	_ = callDict.SetKey(starlark.String("tool_call_id"), starlark.String("test-"+pkgID))
	_ = callDict.SetKey(starlark.String("arguments"), jsonToStarlark(args))

	userID, _ := c.Get("user_id")
	rc := &sandbox.RunContext{UserID: fmt.Sprintf("%v", userID)}

	val, output, err := h.runner.CallEntryPoint(
		c.Request.Context(), pkg, "on_tool_call",
		starlark.Tuple{callDict}, nil, rc,
	)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{
			"error":  err.Error(),
			"output": output,
		})
		return
	}

	// Serialize the Starlark return value to Go types.
	result := starlarkValueToGo(val)
	c.JSON(http.StatusOK, gin.H{
		"result": result,
		"output": output,
	})
}
