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"

	"armature/database"
	"armature/models"
	"armature/sandbox"
	"armature/store"
	"armature/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.
type PackageHandler struct {
	stores         store.Stores
	packagesDir    string           // e.g. /data/packages — where archives are extracted
	bundledDir     string           // e.g. /app/bundled-packages — .pkg archive source
	sandbox        *sandbox.Sandbox
	runner         *sandbox.Runner
}

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

// SetBundledDir sets the path to the bundled packages directory.
// Used by dependency auto-activation to resolve missing dependencies.
func (h *PackageHandler) SetBundledDir(dir string) {
	h.bundledDir = dir
}

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

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

// ListPackages returns all registered packages.
// GET /api/v1/admin/packages
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
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
func (h *PackageHandler) EnablePackage(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.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
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
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
	}

	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
		}
	}

	if h.stores.Dependencies != nil {
		h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), id)
	}

	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
//
// 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

	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
	}

	// 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
		}
	}

	// Unicode security gate — scan all scannable files for invisible/deceptive
	// characters before writing anything to the extension store.
	scanExts := map[string]bool{".star": true, ".json": true, ".js": true, ".html": true}
	for _, f := range zr.File {
		if f.FileInfo().IsDir() {
			continue
		}
		ext := strings.ToLower(filepath.Ext(f.Name))
		if !scanExts[ext] {
			continue
		}
		rc, err := f.Open()
		if err != nil {
			continue
		}
		data, err := io.ReadAll(rc)
		rc.Close()
		if err != nil {
			continue
		}
		findings := sandbox.ScanSource(string(data), f.Name)
		if len(findings) > 0 {
			if blocked, reason := sandbox.Verdict(findings); blocked {
				c.JSON(http.StatusUnprocessableEntity, gin.H{
					"error":  "extension_blocked",
					"reason": fmt.Sprintf("%s (file: %s)", reason, f.Name),
				})
				return
			}
		}
	}

	// Validate manifest structure (required fields, type, constraints)
	mInfo, err := ValidateManifest(manifest)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	pkgID := mInfo.ID
	title := mInfo.Title
	pkgType := mInfo.Type

	// Package signing hook (schema reserved — no crypto verification yet)
	if os.Getenv("PACKAGE_VERIFY_SIGNATURES") == "true" {
		if mInfo.Signature != "" {
			log.Printf("[packages] %s: signature field present (%s) — verification not yet implemented", pkgID, mInfo.Signature)
		} else {
			log.Printf("[packages] %s: unsigned package (PACKAGE_VERIFY_SIGNATURES is enabled)", pkgID)
		}
	}

	// 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)
	}

	// Use validated manifest fields for DB columns
	version := mInfo.Version
	description := mInfo.Description
	author := mInfo.Author
	tier := mInfo.Tier

	userID := c.GetString("user_id")

	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)
	}

	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)
		}
	}

	// This creates the permission rows and sets status to pending_review
	// if the package declares permissions.
	SyncManifestPermissions(c, h.stores, pkgID, manifest)

	SyncManifestTriggers(c.Request.Context(), h.stores, triggers.GlobalEngine(), pkgID, manifest)

	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
			}
		}
	}

	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
		}
	}

	// Libraries must be installed first; consumers declare them.
	// If a dependency is missing but available as a bundled package, auto-install it.
	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 {
				// Attempt auto-install from bundled packages
				if h.bundledDir != "" {
					pkgPath := filepath.Join(h.bundledDir, libID+".pkg")
					if _, statErr := os.Stat(pkgPath); statErr == nil {
						log.Printf("[packages] auto-installing bundled dependency %s for %s", libID, pkgID)
						if _, installErr := installBundledPackage(pkgPath, h.packagesDir, h.stores, h.runner); installErr != nil {
							c.JSON(http.StatusBadRequest, gin.H{
								"error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr),
							})
							return
						}
						// Re-fetch after install
						lib, err = h.stores.Packages.Get(c.Request.Context(), libID)
					}
				}
				if lib == nil {
					c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID + " (not installed and not available as a bundled package)"})
					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))
	}

	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)
			}
		}
	}

	// 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 ──────────────

// 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 ─────────────────────────
// 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,
	})
}

// ── Package update ──────────────────────

// UpdatePackage applies an in-place update to an existing package.
// POST /api/v1/admin/packages/:id/update
//
// Validates version bump (semver), applies additive schema migration,
// merges settings, replaces assets, and re-syncs permissions/triggers.
func (h *PackageHandler) UpdatePackage(c *gin.Context) {
	ctx := c.Request.Context()
	pkgID := c.Param("id")

	// 1. Lookup existing package
	existing, err := h.stores.Packages.Get(ctx, pkgID)
	if err != nil || existing == nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
		return
	}
	if existing.Source == "core" {
		c.JSON(http.StatusForbidden, gin.H{"error": "cannot update core packages"})
		return
	}

	// 2. Accept file upload
	file, header, err := c.Request.FormFile("file")
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
		return
	}
	defer file.Close()

	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
	}
	if header.Size > 50*1024*1024 {
		c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
		return
	}

	tmpFile, err := os.CreateTemp("", "package-update-*.zip")
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
		return
	}
	tmpPath := tmpFile.Name()
	defer os.Remove(tmpPath)

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

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

	// 3. Extract and validate manifest
	manifest, err := extractManifest(zr)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	newID, _ := manifest["id"].(string)
	if newID != pkgID {
		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("manifest id %q does not match package %q", newID, pkgID)})
		return
	}

	newType, _ := manifest["type"].(string)
	if newType == "" {
		newType = "surface"
	}
	if newType != existing.Type {
		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("cannot change package type from %q to %q", existing.Type, newType)})
		return
	}

	// 4. Version comparison — reject same or older
	newVersion, _ := manifest["version"].(string)
	if newVersion == "" {
		newVersion = "0.0.0"
	}
	oldSemver, errOld := ParseSemver(existing.Version)
	newSemver, errNew := ParseSemver(newVersion)
	if errOld != nil || errNew != nil {
		// If either version is unparseable, allow the update (legacy data)
		if errNew != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version in manifest: " + errNew.Error()})
			return
		}
	} else if newSemver.Compare(oldSemver) <= 0 {
		c.JSON(http.StatusConflict, gin.H{
			"error": fmt.Sprintf("version %s is not newer than installed %s", newVersion, existing.Version),
		})
		return
	}

	// 5. Unicode security scan
	scanExts := map[string]bool{".star": true, ".json": true, ".js": true, ".html": true}
	for _, f := range zr.File {
		if f.FileInfo().IsDir() {
			continue
		}
		ext := strings.ToLower(filepath.Ext(f.Name))
		if !scanExts[ext] {
			continue
		}
		rc, err := f.Open()
		if err != nil {
			continue
		}
		data, err := io.ReadAll(rc)
		rc.Close()
		if err != nil {
			continue
		}
		findings := sandbox.ScanSource(string(data), f.Name)
		if len(findings) > 0 {
			if blocked, reason := sandbox.Verdict(findings); blocked {
				c.JSON(http.StatusUnprocessableEntity, gin.H{
					"error":  "extension_blocked",
					"reason": fmt.Sprintf("%s (file: %s)", reason, f.Name),
				})
				return
			}
		}
	}

	// 6. Additive schema migration
	var schemaChanges []string
	if tables, ok := ParseDBTables(manifest); ok {
		changes, err := MigrateExtTables(ctx, database.DB, database.IsPostgres(), h.stores, pkgID, tables)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "schema migration failed: " + err.Error()})
			return
		}
		schemaChanges = changes
	}

	// 7. Settings merge — add new keys with defaults, preserve existing
	mergedSettings := mergePackageSettings(existing, manifest)
	if mergedSettings != nil {
		if err := h.stores.Packages.SetPackageSettings(ctx, pkgID, mergedSettings); err != nil {
			log.Printf("[packages] settings merge failed for %s: %v", pkgID, err)
		}
	}

	// 8. Replace assets
	if h.packagesDir != "" {
		if err := extractPackageAssets(zr, h.packagesDir, pkgID); err != nil {
			log.Printf("[packages] asset extraction failed for %s: %v", pkgID, err)
		}
	}

	// 9. Update DB record
	description, _ := manifest["description"].(string)
	author, _ := manifest["author"].(string)
	tier, _ := manifest["tier"].(string)
	if tier == "" {
		tier = "browser"
	}

	pkg := &store.PackageRegistration{
		Title:       manifest["title"].(string),
		Type:        newType,
		Version:     newVersion,
		Description: description,
		Author:      author,
		Tier:        tier,
		IsSystem:    existing.IsSystem,
		Enabled:     existing.Enabled,
		Manifest:    manifest,
	}
	if err := h.stores.Packages.Update(ctx, pkgID, pkg); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package record"})
		return
	}

	// 10. Re-sync permissions, triggers, dependencies
	SyncManifestPermissions(c, h.stores, pkgID, manifest)
	SyncManifestTriggers(ctx, h.stores, triggers.GlobalEngine(), pkgID, manifest)

	if deps, ok := manifest["dependencies"].(map[string]any); ok && len(deps) > 0 {
		if h.stores.Dependencies != nil {
			h.stores.Dependencies.DeleteAllForConsumer(ctx, pkgID)
		}
		for libID, vSpec := range deps {
			lib, err := h.stores.Packages.Get(ctx, libID)
			if err != nil || lib == nil {
				log.Printf("[packages] update dependency %s not found for %s", libID, pkgID)
				continue
			}
			versionSpec, _ := vSpec.(string)
			if versionSpec == "" {
				versionSpec = ">=0.0.0"
			}
			if h.stores.Dependencies != nil {
				h.stores.Dependencies.Create(ctx, &models.ExtDependency{
					ConsumerID:  pkgID,
					LibraryID:   libID,
					VersionSpec: versionSpec,
					ResolvedVer: lib.Version,
				})
			}
		}
	}

	// 11. Re-install workflow definition if applicable
	if newType == "workflow" {
		if err := InstallWorkflowFromManifest(c, h.stores, pkgID, manifest); err != nil {
			log.Printf("[packages] workflow update failed for %s: %v", pkgID, err)
		}
	}

	previousVersion := existing.Version
	log.Printf("[packages] Updated %s from v%s to v%s", pkgID, previousVersion, newVersion)

	c.JSON(http.StatusOK, gin.H{
		"id":               pkgID,
		"version":          newVersion,
		"previous_version": previousVersion,
		"changes":          schemaChanges,
	})
}

// mergePackageSettings merges existing settings with a new manifest's settings schema.
// New keys get their declared default value; existing keys are preserved.
func mergePackageSettings(existing *store.PackageRegistration, manifest map[string]any) json.RawMessage {
	newSchema, _ := manifest["settings"].([]any)
	if len(newSchema) == 0 {
		return nil
	}

	// Parse existing settings
	var current map[string]any
	if existing.PackageSettings != nil {
		if err := json.Unmarshal(existing.PackageSettings, &current); err != nil {
			current = make(map[string]any)
		}
	}
	if current == nil {
		current = make(map[string]any)
	}

	changed := false
	for _, entry := range newSchema {
		e, ok := entry.(map[string]any)
		if !ok {
			continue
		}
		key, _ := e["key"].(string)
		if key == "" {
			continue
		}
		if _, exists := current[key]; !exists {
			current[key] = e["default"]
			changed = true
		}
	}

	if !changed {
		return nil
	}

	data, _ := json.Marshal(current)
	return data
}

// ── Package export ──────────────────────

// ExportPackage exports an installed package as a downloadable .pkg archive.
// GET /api/v1/admin/packages/:id/export
func (h *PackageHandler) ExportPackage(c *gin.Context) {
	ctx := c.Request.Context()
	pkgID := c.Param("id")

	pkg, err := h.stores.Packages.Get(ctx, pkgID)
	if err != nil || pkg == nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
		return
	}

	// Set response headers
	filename := fmt.Sprintf("%s-%s.pkg", pkgID, pkg.Version)
	c.Header("Content-Type", "application/zip")
	c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))

	zw := zip.NewWriter(c.Writer)
	defer zw.Close()

	// Write manifest.json from DB
	manifestJSON, err := json.MarshalIndent(pkg.Manifest, "", "  ")
	if err != nil {
		log.Printf("[packages] export: failed to marshal manifest for %s: %v", pkgID, err)
		return
	}
	mw, err := zw.Create("manifest.json")
	if err != nil {
		return
	}
	mw.Write(manifestJSON)

	// Walk packagesDir/{id}/ and add all asset files
	if h.packagesDir == "" {
		return
	}
	pkgDir := filepath.Join(h.packagesDir, pkgID)
	filepath.Walk(pkgDir, func(path string, info os.FileInfo, err error) error {
		if err != nil || info.IsDir() {
			return nil
		}
		relPath, err := filepath.Rel(pkgDir, path)
		if err != nil {
			return nil
		}
		relPath = filepath.ToSlash(relPath) // normalize to forward slashes in zip

		fw, err := zw.Create(relPath)
		if err != nil {
			return nil
		}
		f, err := os.Open(path)
		if err != nil {
			return nil
		}
		defer f.Close()
		io.Copy(fw, f)
		return nil
	})
}
