package api

import (
	"archive/zip"
	"bytes"
	"context"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"webby-builder/internal/agent"
	"webby-builder/internal/browser"
	"webby-builder/internal/buildtarget"
	"webby-builder/internal/executor"
	"webby-builder/internal/logging"
	"webby-builder/internal/models"
	"webby-builder/internal/pkg/unzip"
	"webby-builder/internal/pusher"
	"webby-builder/internal/revision"
	"webby-builder/internal/webhook"

	"github.com/gin-gonic/gin"
	"github.com/sirupsen/logrus"
)

// Server holds the server state
type Server struct {
	sessions         map[string]*agent.Session
	mu               sync.RWMutex
	revisionManagers map[string]*revision.Manager
	revisionMu       sync.RWMutex
	serverKey        string
	workspacePath    string
	router           *gin.Engine
	version          string
	logger           *logrus.Logger
	debug            bool
	// browserMgr is the process-wide headless-browser session manager used by
	// the webby-plugin-webagent tools. Single instance shared across all build
	// sessions; per-build-session cap enforced inside the manager.
	browserMgr *browser.Manager
}

// NewServer creates a new server instance
func NewServer(cfg ServerConfig, logger *logrus.Logger) *Server {
	// Ensure workspace directory exists
	_ = os.MkdirAll(cfg.WorkspacePath, 0755)

	browserMgr := browser.NewManager(browser.Config{
		MaxIdle:              60 * time.Second,
		MaxActionsPerSession: 50,
	})
	browserMgr.StartReaper()

	s := &Server{
		sessions:         make(map[string]*agent.Session),
		revisionManagers: make(map[string]*revision.Manager),
		serverKey:        cfg.ServerKey,
		workspacePath:    cfg.WorkspacePath,
		version:          cfg.Version,
		logger:           logger,
		debug:            cfg.Debug,
		browserMgr:       browserMgr,
	}

	s.router = s.SetupRouter()

	// Start background cleanup worker
	s.StartCleanupWorker()

	return s
}

// Run starts the server on the specified address
func (s *Server) RunAddr(addr string) error {
	return s.router.Run(addr)
}

// Run starts the server
func (s *Server) Run() error {
	return s.router.Run()
}

// getSession retrieves a session by ID
func (s *Server) getSession(sessionID string) (*agent.Session, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	session, ok := s.sessions[sessionID]
	return session, ok
}

// resolveWorkspaceFromRequest extracts a workspace or session ID from the request
// and resolves it to a workspace path on the filesystem.
func (s *Server) resolveWorkspaceFromRequest(c *gin.Context) (workspacePath string, workspaceID string, ok bool) {
	id := c.Param("session_id")
	if id == "" {
		id = c.Param("workspace_id")
	}
	if id == "" {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Missing workspace or session ID"})
		return "", "", false
	}
	path, err := s.safeWorkspacePath(id)
	if err != nil {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid workspace or session ID"})
		return "", "", false
	}
	if _, err := os.Stat(path); os.IsNotExist(err) {
		c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Workspace not found"})
		return "", "", false
	}
	return path, id, true
}

// getActiveSessionCount returns the number of active sessions
func (s *Server) getActiveSessionCount() int {
	s.mu.RLock()
	defer s.mu.RUnlock()
	count := 0
	for _, session := range s.sessions {
		if session.IsActive() {
			count++
		}
	}
	return count
}

// Root endpoint - server info
func (s *Server) handleRoot(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{
		"version":  s.version,
		"sessions": s.GetSessionCount(),
	})
}

// POST /api/run - Start a new agent session
func (s *Server) handleRun(c *gin.Context) {
	var req models.RunRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
		return
	}

	// Validate config
	if err := req.Config.Validate(); err != nil {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
		return
	}

	// Use workspace ID as session ID (no separate UUID)
	sessionID := req.WorkspaceID

	// Create workspace path for this session (reject traversal in the id)
	workspacePath, err := s.safeWorkspacePath(req.WorkspaceID)
	if err != nil {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid workspace ID"})
		return
	}
	_ = os.MkdirAll(workspacePath, 0755)

	// Check if session already exists for this workspace
	// Hold lock through entire check-and-modify operation to prevent race conditions
	s.mu.Lock()
	existingSession, exists := s.sessions[sessionID]

	var session *agent.Session
	var reused bool

	if exists {
		status := existingSession.GetStatus()
		// Active sessions: pending or running - reject
		if status == models.StatusPending || status == models.StatusRunning {
			s.mu.Unlock()
			s.logger.WithFields(logrus.Fields{
				"session_id":      sessionID,
				"workspace_id":    req.WorkspaceID,
				"existing_status": status,
			}).Warn("Rejecting new session - workspace already has active session")

			c.JSON(http.StatusConflict, gin.H{
				"error":         "A session is already active for this workspace",
				"session_id":    sessionID,
				"status":        string(status),
				"can_reconnect": true,
			})
			return
		}
		// Terminal sessions: completed, failed, or cancelled - reuse the session
		s.logger.WithFields(logrus.Fields{
			"session_id":      sessionID,
			"workspace_id":    req.WorkspaceID,
			"existing_status": status,
		}).Info("Reusing existing terminal session")

		session = s.reuseSession(existingSession, req)
		reused = true
	}
	s.mu.Unlock()

	// Create appropriate streamer based on Pusher config
	if !reused {
		if req.Pusher != nil {
			// Validate and create Pusher client
			pusherConfig := &pusher.Config{
				AppID:   req.Pusher.AppID,
				Key:     req.Pusher.Key,
				Secret:  req.Pusher.Secret,
				Cluster: req.Pusher.Cluster,
				Host:    req.Pusher.Host,
				Scheme:  req.Pusher.Scheme,
			}

			pusherClient, err := pusher.NewClient(pusherConfig)
			if err != nil {
				c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid Pusher config: " + err.Error()})
				return
			}

			// Validate credentials (fail fast)
			if err := pusherClient.ValidateCredentials(); err != nil {
				c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Pusher validation failed: " + err.Error()})
				return
			}

			// Create webhook notifier for critical events (status, complete, error)
			webhookNotifier := webhook.NewNotifier(req.WebhookURL, s.serverKey, sessionID)

			// Create hybrid streamer with logger for debug logging
			// Use WorkspaceID (project ID) as channel ID so frontend can subscribe before the build starts
			hybridStreamer := pusher.NewHybridStreamerWithLogger(pusherClient, webhookNotifier, req.WorkspaceID, s.logger)

			// Create session with hybrid streamer
			session = agent.NewSessionWithStreamer(sessionID, req.WorkspaceID, workspacePath, req.WebhookURL, &req.Config, hybridStreamer)
		} else {
			// Create session with webhook-only notifier
			session = agent.NewSession(sessionID, req.WorkspaceID, workspacePath, req.WebhookURL, s.serverKey, &req.Config)
		}

		// Set template URL if provided
		if req.Template != nil {
			session.SetTemplateURL(req.Template.URL)
		}

		// Set Laravel URL for template fetching
		session.SetLaravelURL(req.LaravelURL)
		if req.LaravelURL != "" {
			s.logger.WithField("laravel_url", req.LaravelURL).Debug("Laravel URL set from request")
		}

		// Store the Laravel user ID for end-of-session Firecrawl reconciliation.
		if req.UserID > 0 {
			session.SetUserID(req.UserID)
		}

		// Set selected template ID and name if provided
		if req.Template != nil && req.Template.TemplateID != "" {
			session.SetSelectedTemplate(req.Template.TemplateID, req.Template.TemplateName)
		}

		// Set feature flags if provided, otherwise use defaults
		if req.Features != nil {
			session.SetFeatures(*req.Features)
		}

		// Set project capabilities if provided
		if req.ProjectCapabilities != nil {
			session.SetProjectCapabilities(*req.ProjectCapabilities)
			s.logger.WithFields(logrus.Fields{
				"session_id":        session.ID,
				"supabase_enabled":  req.ProjectCapabilities.Supabase != nil && req.ProjectCapabilities.Supabase.Enabled,
				"storage_enabled":   req.ProjectCapabilities.Storage != nil && req.ProjectCapabilities.Storage.Enabled,
				"web_agent_enabled": req.ProjectCapabilities.WebAgent != nil && req.ProjectCapabilities.WebAgent.Enabled,
			}).Debug("Project capabilities set from request")
		} else {
			s.logger.WithFields(logrus.Fields{
				"session_id": session.ID,
			}).Debug("No project capabilities in request")
		}

		// Set theme preset if provided
		if req.ThemePreset != nil {
			session.SetThemePreset(*req.ThemePreset)
			s.logger.WithField("theme_preset", req.ThemePreset.ID).Debug("Theme preset set from request")
		}

		// Set the resolved design system if provided (build-time overlay).
		if req.DesignSystem != nil {
			session.SetDesignSystem(req.DesignSystem)
			s.logger.WithFields(logrus.Fields{"design_system": req.DesignSystem.Slug, "accent": req.DesignSystem.Accent}).Debug("Design system set from request")
		}

		// Select the generation output kind (defaults to website when empty).
		session.SetOutputType(req.OutputType)

		// Store the project's display name (titles the generated site).
		session.SetProjectName(req.ProjectName)

		// Create workspace-specific logger if debug mode
		if s.debug {
			wsLogger, cleanup := logging.NewWorkspaceLogger(req.WorkspaceID, true)
			session.SetLogger(wsLogger, cleanup)
		}
	}

	// Create cancellable context FIRST (before session is accessible to other goroutines)
	ctx, cancel := context.WithCancel(context.Background())
	session.SetCancel(cancel)

	// THEN store session (now fully initialized with cancel function)
	s.mu.Lock()
	s.sessions[sessionID] = session
	s.mu.Unlock()

	// Start agent in background
	go s.runAgent(ctx, session, req.Goal, req.History, req.IsCompacted, req.MaxIterations)

	// Log session creation
	logMsg := "Agent session created"
	if reused {
		logMsg = "Agent session reused"
	}
	s.logger.WithFields(logrus.Fields{
		"session_id":   sessionID,
		"workspace_id": req.WorkspaceID,
		"reused":       reused,
	}).Info(logMsg)

	response := gin.H{"session_id": sessionID}
	if reused {
		response["reused"] = true
	}
	c.JSON(http.StatusOK, response)
}

// reuseSession reconfigures an existing terminal session for a new run
func (s *Server) reuseSession(session *agent.Session, req models.RunRequest) *agent.Session {
	// Reset session state for continuation
	session.ResetForContinuation()

	// Update config (AI provider settings, model, etc.)
	session.SetConfig(&req.Config)

	// Set template URL if provided
	if req.Template != nil {
		session.SetTemplateURL(req.Template.URL)
		if req.Template.TemplateID != "" {
			session.SetSelectedTemplate(req.Template.TemplateID, req.Template.TemplateName)
		}
	}

	// Set Laravel URL
	session.SetLaravelURL(req.LaravelURL)

	// Update the user ID in case it changed between runs.
	if req.UserID > 0 {
		session.SetUserID(req.UserID)
	}

	// Set feature flags
	if req.Features != nil {
		session.SetFeatures(*req.Features)
	}

	// Set project capabilities
	if req.ProjectCapabilities != nil {
		session.SetProjectCapabilities(*req.ProjectCapabilities)
		s.logger.WithFields(logrus.Fields{
			"session_id":       session.ID,
			"supabase_enabled": req.ProjectCapabilities.Supabase != nil && req.ProjectCapabilities.Supabase.Enabled,
			"storage_enabled":  req.ProjectCapabilities.Storage != nil && req.ProjectCapabilities.Storage.Enabled,
		}).Debug("Project capabilities set from request (reuse)")
	} else {
		s.logger.WithFields(logrus.Fields{
			"session_id": session.ID,
		}).Debug("No project capabilities in request (reuse)")
	}

	// Set theme preset if provided
	if req.ThemePreset != nil {
		session.SetThemePreset(*req.ThemePreset)
		s.logger.WithField("theme_preset", req.ThemePreset.ID).Debug("Theme preset set from request (reuse)")
	}

	// Set the resolved design system if provided (build-time overlay).
	if req.DesignSystem != nil {
		session.SetDesignSystem(req.DesignSystem)
		s.logger.WithFields(logrus.Fields{"design_system": req.DesignSystem.Slug, "accent": req.DesignSystem.Accent}).Debug("Design system set from request (reuse)")
	}

	// Select the generation output kind (defaults to website when empty).
	session.SetOutputType(req.OutputType)

	// Store the project's display name (titles the generated site).
	session.SetProjectName(req.ProjectName)

	// Reinitialize streamer
	var newStreamer webhook.Streamer
	if req.Pusher != nil {
		pusherConfig := &pusher.Config{
			AppID:   req.Pusher.AppID,
			Key:     req.Pusher.Key,
			Secret:  req.Pusher.Secret,
			Cluster: req.Pusher.Cluster,
			Host:    req.Pusher.Host,
			Scheme:  req.Pusher.Scheme,
		}
		webhookNotifier := webhook.NewNotifier(req.WebhookURL, s.serverKey, session.ID)
		pusherClient, err := pusher.NewClient(pusherConfig)
		if err != nil {
			s.logger.Warnf("Failed to reinitialize Pusher client for session %s: %v, falling back to webhook-only", session.ID, err)
			newStreamer = webhookNotifier
		} else {
			newStreamer = pusher.NewHybridStreamerWithLogger(pusherClient, webhookNotifier, session.WorkspaceID, s.logger)
		}
	} else {
		newStreamer = webhook.NewNotifier(req.WebhookURL, s.serverKey, session.ID)
	}
	session.ReinitializeStreamer(newStreamer)

	return session
}

// POST /api/stop/:session_id - Stop a running session
func (s *Server) handleStop(c *gin.Context) {
	sessionID := c.Param("session_id")

	session, ok := s.getSession(sessionID)
	if !ok {
		c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Session not found"})
		return
	}

	session.Cancel()

	s.logger.WithFields(logrus.Fields{
		"session_id": sessionID,
	}).Info("Stopping session")

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

// GET /api/status/:session_id - Get session status
func (s *Server) handleStatus(c *gin.Context) {
	sessionID := c.Param("session_id")

	session, ok := s.getSession(sessionID)
	if !ok {
		c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Session not found"})
		return
	}

	// Return 404 for terminal sessions so Laravel knows the build is done
	status := session.GetStatus()
	if isTerminalStatus(status) {
		c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Session ended"})
		return
	}

	c.JSON(http.StatusOK, models.StatusResponse{
		SessionID:      sessionID,
		Status:         string(status),
		Iterations:     session.Iterations,
		TokensUsed:     session.TokensUsed,
		ActiveSessions: s.getActiveSessionCount(),
		Error:          session.Error,
	})
}

// GET /api/files/:session_id or /api/files-workspace/:workspace_id - List workspace files
func (s *Server) handleListFiles(c *gin.Context) {
	workspacePath, _, ok := s.resolveWorkspaceFromRequest(c)
	if !ok {
		return
	}

	files := s.listFilesInPath(workspacePath)
	c.JSON(http.StatusOK, models.FileListResponse{Files: files})
}

// listFilesInPath returns a list of files in the given workspace path
func (s *Server) listFilesInPath(workspacePath string) []models.FileInfo {
	var files []models.FileInfo

	_ = filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return nil
		}

		relPath, _ := filepath.Rel(workspacePath, path)
		if relPath == "." {
			return nil
		}

		// Skip hidden files, common excludes, and internal metadata
		if strings.HasPrefix(info.Name(), ".") ||
			info.Name() == "node_modules" ||
			info.Name() == "dist" ||
			info.Name() == "template.json" {
			if info.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}

		files = append(files, models.FileInfo{
			Path:    relPath,
			Name:    info.Name(),
			Size:    info.Size(),
			IsDir:   info.IsDir(),
			ModTime: info.ModTime().Format(time.RFC3339),
		})

		return nil
	})

	return files
}

// GET /api/file/:session_id or /api/file-workspace/:workspace_id - Get file content
func (s *Server) handleGetFile(c *gin.Context) {
	filePath := c.Query("path")

	if filePath == "" {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "path query parameter is required"})
		return
	}

	workspacePath, _, ok := s.resolveWorkspaceFromRequest(c)
	if !ok {
		return
	}

	s.serveFileContent(c, workspacePath, filePath)
}

// validateAndResolvePath validates a relative path and resolves it to an absolute path
// within the workspace. Returns an error if the path would escape the workspace.
func (s *Server) validateAndResolvePath(workspacePath, relativePath string) (string, error) {
	// Reject obviously bad patterns first
	if strings.Contains(relativePath, "..") || strings.HasPrefix(relativePath, "/") {
		return "", fmt.Errorf("invalid path")
	}

	// Delegate to shared utility (follows symlinks to prevent escape)
	return executor.ResolveAndValidatePath(workspacePath, relativePath)
}

// serveFileContent reads and returns file content from a workspace
func (s *Server) serveFileContent(c *gin.Context, workspacePath, filePath string) {
	// Validate and resolve path (prevent traversal)
	fullPath, err := s.validateAndResolvePath(workspacePath, filePath)
	if err != nil {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid path"})
		return
	}

	content, err := os.ReadFile(fullPath)
	if err != nil {
		c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "File not found"})
		return
	}

	info, err := os.Stat(fullPath)
	if err != nil {
		c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to stat file"})
		return
	}

	c.JSON(http.StatusOK, models.FileResponse{
		Path:    filePath,
		Content: string(content),
		Size:    info.Size(),
	})
}

// validatePathForAPIWrite checks if a file path is allowed for writes via the code editor API.
// Blocks protected config files and npm config files that could enable script injection.
func (s *Server) validatePathForAPIWrite(path string) error {
	cleaned := filepath.Clean(path)

	// Block protected config files (same as AI agent)
	if executor.IsProtectedFile(cleaned) {
		return fmt.Errorf("cannot modify %s: this is a protected system file", path)
	}

	// Block npm/yarn config files that could enable script injection
	base := filepath.Base(cleaned)
	npmConfigFiles := []string{".npmrc", ".yarnrc", ".yarnrc.yml"}
	for _, f := range npmConfigFiles {
		if base == f {
			return fmt.Errorf("cannot modify %s: npm/yarn config files are not allowed", path)
		}
	}

	return nil
}

// PUT /api/file/:session_id or /api/file-workspace/:workspace_id - Update file content
func (s *Server) handleUpdateFile(c *gin.Context) {
	var req models.FileUpdateRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
		return
	}

	workspacePath, _, ok := s.resolveWorkspaceFromRequest(c)
	if !ok {
		return
	}

	// Validate and resolve path (prevent traversal)
	fullPath, err := s.validateAndResolvePath(workspacePath, req.Path)
	if err != nil {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid path"})
		return
	}

	// Block writes to protected files
	if err := s.validatePathForAPIWrite(req.Path); err != nil {
		c.JSON(http.StatusForbidden, models.ErrorResponse{Error: err.Error()})
		return
	}

	// Ensure directory exists
	_ = os.MkdirAll(filepath.Dir(fullPath), 0755)

	if err := os.WriteFile(fullPath, []byte(req.Content), 0644); err != nil {
		c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
		return
	}

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

// PUT /api/theme-workspace/:workspace_id - Apply theme preset to workspace CSS
func (s *Server) handleApplyTheme(c *gin.Context) {
	workspaceID := c.Param("workspace_id")

	var req models.ApplyThemeRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
		return
	}

	workspacePath, err := s.safeWorkspacePath(workspaceID)
	if err != nil {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid workspace ID"})
		return
	}
	if _, err := os.Stat(workspacePath); os.IsNotExist(err) {
		c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Workspace not found"})
		return
	}

	// Apply to src/custom.css: it imports after index.css and wins the cascade,
	// so it's the effective source of truth for theme variables. Create it if
	// the template shipped without one (every current template includes it, but
	// defence-in-depth).
	cssPath := filepath.Join(workspacePath, "src", "custom.css")

	var content []byte
	if existing, err := os.ReadFile(cssPath); err == nil {
		content = existing
	} else if os.IsNotExist(err) {
		content = []byte("/* Custom styles - add your own CSS here */\n")
	} else {
		c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "failed to read custom.css"})
		return
	}

	newContent := rewriteThemeBlocks(string(content), req.Light, req.Dark)

	// Ensure parent dir exists (src/ always does for valid workspaces, but be safe)
	if err := os.MkdirAll(filepath.Dir(cssPath), 0755); err != nil {
		c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "failed to create src dir"})
		return
	}
	if err := os.WriteFile(cssPath, []byte(newContent), 0644); err != nil {
		c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "failed to write custom.css"})
		return
	}

	// Keep design-intelligence.json's colors record in sync with the CSS
	// we just wrote. If this fails we log but still succeed overall — the
	// palette is correctly applied to the source files; only the agent's
	// future "design memory" is stale, which is a soft failure.
	if err := executor.RecordThemePalette(workspacePath, req.PresetID, req.Light, req.Dark); err != nil {
		logrus.Warnf("handleApplyTheme: RecordThemePalette failed for workspace %s: %v", workspaceID, err)
	}

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

// PUT /api/design-workspace/:workspace_id - Re-apply a design system to a
// workspace. Overlays the system's tokens + chosen accent onto src/index.css
// and injects the system fonts — the same deterministic overlay run at build
// time (executor.ApplyDesignOverlay), so a re-theme from project settings takes
// effect on the next rebuild. Laravel triggers the rebuild separately.
func (s *Server) handleApplyDesign(c *gin.Context) {
	workspaceID := c.Param("workspace_id")

	var ds models.DesignSystem
	if err := c.ShouldBindJSON(&ds); err != nil {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
		return
	}

	workspacePath, err := s.safeWorkspacePath(workspaceID)
	if err != nil {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid workspace ID"})
		return
	}
	if _, err := os.Stat(workspacePath); os.IsNotExist(err) {
		c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Workspace not found"})
		return
	}

	// Checkpoint the pre-theme state so a Design re-theme is undoable and
	// listed in the revision history. Non-blocking, matching runAgent — and
	// like runAgent, skipped on an empty workspace (an empty checkpoint is a
	// trap: undoing into it would wipe what the overlay is about to write).
	if workspaceHasFiles(workspacePath) {
		if err := s.getOrCreateRevisionManager(workspaceID).CreateSnapshot("Before: Design change"); err != nil {
			s.logger.WithField("error", err.Error()).Warn("Failed to create revision snapshot for design apply (non-blocking)")
		}
	}

	if _, err := executor.ApplyDesignOverlay(workspacePath, &ds); err != nil {
		c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
		return
	}

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

// rewriteThemeBlocks replaces (or inserts at top) the :root and .dark blocks
// in a CSS file with the given light/dark variable maps. All other rules are
// preserved verbatim. Used by the Design tab's Apply flow.
func rewriteThemeBlocks(css string, light, dark map[string]string) string {
	lightBlock := generateCSSBlock(light)
	darkBlock := generateCSSBlock(dark)

	rootPattern := regexp.MustCompile(`(:root\s*\{)([^}]*?)(\})`)
	if rootPattern.MatchString(css) {
		css = rootPattern.ReplaceAllString(css, "${1}\n"+lightBlock+"${3}")
	} else {
		css = ":root {\n" + lightBlock + "}\n\n" + css
	}

	darkPattern := regexp.MustCompile(`(\.dark\s*\{)([^}]*?)(\})`)
	if darkPattern.MatchString(css) {
		css = darkPattern.ReplaceAllString(css, "${1}\n"+darkBlock+"${3}")
	} else {
		// Insert .dark block immediately after the :root block we just wrote/inserted.
		rootEnd := rootPattern.FindStringIndex(css)
		if rootEnd != nil {
			head := css[:rootEnd[1]]
			tail := css[rootEnd[1]:]
			css = head + "\n\n.dark {\n" + darkBlock + "}" + tail
		} else {
			// Pathological: root insertion didn't take; append.
			css = css + "\n\n.dark {\n" + darkBlock + "}\n"
		}
	}

	return css
}

// applyThemeToCSS is the legacy in-place rewriter used for any files that
// already contain both :root and .dark blocks (template src/index.css).
// Kept for backward compatibility; new callers should use rewriteThemeBlocks.
func applyThemeToCSS(css string, light, dark map[string]string) string {
	return rewriteThemeBlocks(css, light, dark)
}

// generateCSSBlock creates CSS variable declarations from a map
func generateCSSBlock(vars map[string]string) string {
	var lines []string
	for name, value := range vars {
		lines = append(lines, fmt.Sprintf("  --%s: %s;", name, value))
	}
	// Sort for consistent output
	sort.Strings(lines)
	return strings.Join(lines, "\n") + "\n"
}

// POST /api/build/:session_id or /api/build-workspace/:workspace_id - Trigger a build
func (s *Server) handleBuild(c *gin.Context) {
	workspacePath, _, ok := s.resolveWorkspaceFromRequest(c)
	if !ok {
		return
	}

	s.runBuildInWorkspace(c, workspacePath)
}

// runBuildInWorkspace runs npm install and npm run build in the given workspace
func (s *Server) runBuildInWorkspace(c *gin.Context, workspacePath string) {
	// Check if package.json exists
	packageJSON := filepath.Join(workspacePath, "package.json")
	if _, err := os.Stat(packageJSON); os.IsNotExist(err) {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "No package.json found in workspace"})
		return
	}

	// Run npm install first
	installCmd := exec.Command("npm", "install", "--ignore-scripts")
	installCmd.Dir = workspacePath
	if output, err := installCmd.CombinedOutput(); err != nil {
		s.logger.WithField("output", string(output)).WithError(err).Error("npm install failed")
		c.JSON(http.StatusInternalServerError, models.ErrorResponse{
			Error: "npm install failed",
		})
		return
	}

	// Run npm run build
	buildCmd := exec.Command("npm", "run", "build")
	buildCmd.Dir = workspacePath
	if output, err := buildCmd.CombinedOutput(); err != nil {
		s.logger.WithField("output", string(output)).WithError(err).Error("npm run build failed")
		c.JSON(http.StatusInternalServerError, models.ErrorResponse{
			Error: "Build failed",
		})
		return
	}

	// Check if dist folder exists
	distPath := filepath.Join(workspacePath, "dist")
	if _, err := os.Stat(distPath); os.IsNotExist(err) {
		c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Build completed but dist folder not found"})
		return
	}

	c.JSON(http.StatusOK, models.BuildResponse{
		Success: true,
		Message: "Build completed successfully",
	})
}

// GET /api/build-output/:session_id or /api/build-output-workspace/:workspace_id - Download built files as zip
func (s *Server) handleBuildOutput(c *gin.Context) {
	workspacePath, workspaceID, ok := s.resolveWorkspaceFromRequest(c)
	if !ok {
		return
	}

	s.serveBuildOutput(c, workspacePath, workspaceID)
}

// GET /api/export-workspace/:workspace_id - Download the editable SOURCE of a
// project as a zip (for code export / off-platform deploy). Excludes installed
// dependencies, build artifacts, lockfiles and Webby-internal metadata so the
// archive is a clean, buildable source tree.
func (s *Server) handleSourceExport(c *gin.Context) {
	workspacePath, _, ok := s.resolveWorkspaceFromRequest(c)
	if !ok {
		return
	}
	s.serveSourceExport(c, workspacePath)
}

// sourceExportExcluded reports whether a workspace-relative path should be left
// out of a source export.
func sourceExportExcluded(rel string) bool {
	rel = filepath.ToSlash(rel)
	prefixes := []string{"node_modules/", "dist/", ".git/", ".vite/", "storage/"}
	for _, p := range prefixes {
		if strings.HasPrefix(rel, p) {
			return true
		}
	}
	exact := map[string]bool{
		"package-lock.json":  true,
		"yarn.lock":          true,
		"bun.lockb":          true,
		"pnpm-lock.yaml":     true,
		".webby-tsbuildinfo": true,
		"KNOWLEDGE.md":       true, // Webby agent guidance, not part of the user's app
		"template.json":      true, // Webby template metadata
		".DS_Store":          true,
	}
	if exact[rel] {
		return true
	}
	return strings.HasSuffix(rel, "/.DS_Store")
}

// serveSourceExport zips the workspace source (minus deps/build/metadata).
func (s *Server) serveSourceExport(c *gin.Context, workspacePath string) {
	if _, err := os.Stat(workspacePath); os.IsNotExist(err) {
		c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Workspace not found"})
		return
	}

	buf := new(bytes.Buffer)
	zipWriter := zip.NewWriter(buf)
	fileCount := 0

	err := filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error {
		if err != nil || info.IsDir() {
			return err
		}
		relPath, relErr := filepath.Rel(workspacePath, path)
		if relErr != nil {
			return relErr
		}
		if sourceExportExcluded(relPath) {
			return nil
		}
		data, readErr := os.ReadFile(path)
		if readErr != nil {
			return readErr
		}
		w, createErr := zipWriter.Create(filepath.ToSlash(relPath))
		if createErr != nil {
			return createErr
		}
		if _, wErr := w.Write(data); wErr != nil {
			return wErr
		}
		fileCount++
		return nil
	})

	if closeErr := zipWriter.Close(); closeErr != nil && err == nil {
		err = closeErr
	}
	if err != nil {
		c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
		return
	}

	c.Header("X-Files-Count", strconv.Itoa(fileCount))
	c.Header("Content-Disposition", "attachment; filename=source.zip")
	c.Data(http.StatusOK, "application/zip", buf.Bytes())
}

// serveBuildOutput creates and serves a zip of the dist folder
func (s *Server) serveBuildOutput(c *gin.Context, workspacePath, workspaceID string) {
	// Explicit target hint from the caller (Laravel knows the project's
	// output target). Authoritative when present; the structural style.css
	// detection below stays as a fallback for callers that don't send it.
	if c.Query("output_type") == "wordpress_theme" {
		s.serveThemeOutput(c, workspacePath)
		return
	}

	distPath := filepath.Join(workspacePath, "dist")
	if _, err := os.Stat(distPath); os.IsNotExist(err) {
		// WordPress block themes have no dist/ — the workspace IS the theme.
		// Detect one by its required style.css and package the theme directory.
		if _, sErr := os.Stat(filepath.Join(workspacePath, "style.css")); sErr == nil {
			s.serveThemeOutput(c, workspacePath)
			return
		}
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Build output not found"})
		return
	}

	// Extract project ID from workspace ID for base tag injection
	projectID := extractProjectID(workspaceID)

	// Create zip in memory
	buf := new(bytes.Buffer)
	zipWriter := zip.NewWriter(buf)
	fileCount := 0

	err := filepath.Walk(distPath, func(path string, info os.FileInfo, err error) error {
		if err != nil || info.IsDir() {
			return err
		}

		relPath, _ := filepath.Rel(distPath, path)

		// Read file content
		data, err := os.ReadFile(path)
		if err != nil {
			return err
		}

		// Inject base tag into index.html to fix relative asset paths
		// This ensures ./assets/* resolves to /preview/{id}/assets/* instead of /preview/assets/*
		if relPath == "index.html" {
			data = injectBaseTag(data, projectID)
		}

		w, err := zipWriter.Create(relPath)
		if err != nil {
			return err
		}

		if _, wErr := w.Write(data); wErr != nil {
			return wErr
		}
		fileCount++
		return nil
	})

	if closeErr := zipWriter.Close(); closeErr != nil && err == nil {
		err = closeErr
	}

	if err != nil {
		c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
		return
	}

	c.Header("X-Build-Status", "success")
	c.Header("X-Files-Count", strconv.Itoa(fileCount))
	c.Header("Content-Disposition", "attachment; filename=dist.zip")
	c.Data(http.StatusOK, "application/zip", buf.Bytes())
}

// serveThemeOutput zips a WordPress block theme (the whole workspace minus
// development cruft) as the installable deliverable. No base-tag injection —
// WordPress resolves asset URLs itself.
func (s *Server) serveThemeOutput(c *gin.Context, workspacePath string) {
	// Make the packaged theme distribution-grade (template-part registration,
	// header completion, readme/screenshot fallbacks). Idempotent + best-effort.
	executor.FinalizeWordPressTheme(workspacePath)

	buf := new(bytes.Buffer)
	zipWriter := zip.NewWriter(buf)
	fileCount := 0

	err := filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		relPath, relErr := filepath.Rel(workspacePath, path)
		if relErr != nil {
			return relErr
		}
		if relPath == "." {
			return nil
		}
		// Skip development directories that don't belong in a distributed theme.
		top := strings.SplitN(relPath, string(os.PathSeparator), 2)[0]
		switch top {
		case "node_modules", ".git", "dist", ".webby", ".revisions":
			if info.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		if info.IsDir() {
			return nil
		}
		// Skip Webby-internal metadata files (builder artifacts, not theme files).
		switch relPath {
		case "template.json", "KNOWLEDGE.md", "memory.json", "design-intelligence.json", ".webby-tsbuildinfo":
			return nil
		}
		data, readErr := os.ReadFile(path)
		if readErr != nil {
			return readErr
		}
		w, cErr := zipWriter.Create(filepath.ToSlash(relPath))
		if cErr != nil {
			return cErr
		}
		if _, wErr := w.Write(data); wErr != nil {
			return wErr
		}
		fileCount++
		return nil
	})

	if closeErr := zipWriter.Close(); closeErr != nil && err == nil {
		err = closeErr
	}
	if err != nil {
		c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: err.Error()})
		return
	}

	c.Header("X-Build-Status", "success")
	c.Header("X-Files-Count", strconv.Itoa(fileCount))
	c.Header("Content-Disposition", "attachment; filename=theme.zip")
	c.Data(http.StatusOK, "application/zip", buf.Bytes())
}

// POST /api/reset/:session_id - Reset workspace
func (s *Server) handleReset(c *gin.Context) {
	sessionID := c.Param("session_id")

	session, ok := s.getSession(sessionID)
	if !ok {
		c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Session not found"})
		return
	}

	// Remove workspace contents (but not the directory itself)
	workspacePath := session.GetWorkspacePath()
	entries, _ := os.ReadDir(workspacePath)
	for _, entry := range entries {
		_ = os.RemoveAll(filepath.Join(workspacePath, entry.Name()))
	}

	// .revisions/ was just deleted with everything else — drop the cached
	// manager so its in-memory history can't ghost-reference snapshots that
	// no longer exist on disk (undo would 400 on the missing files).
	s.evictRevisionManager(session.WorkspaceID)

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

// GET /api/suggestions/:session_id - Get AI suggestions
func (s *Server) handleSuggestions(c *gin.Context) {
	sessionID := c.Param("session_id")

	_, ok := s.getSession(sessionID)
	if !ok {
		c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Session not found"})
		return
	}

	// TODO: Implement actual suggestions
	c.JSON(http.StatusOK, models.SuggestionsResponse{
		Suggestions: []string{
			"Add a responsive navigation menu",
			"Implement dark mode toggle",
			"Add form validation",
		},
	})
}

// POST /api/chat/:session_id - Continue conversation
func (s *Server) handleChat(c *gin.Context) {
	sessionID := c.Param("session_id")

	var req models.ChatRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: err.Error()})
		return
	}

	session, ok := s.getSession(sessionID)
	if !ok {
		c.JSON(http.StatusNotFound, models.ErrorResponse{Error: "Session not found"})
		return
	}

	// Check if session can accept new messages
	if !session.CanContinue() {
		status := session.GetStatus()
		if status == models.StatusRunning || status == models.StatusPending {
			c.JSON(http.StatusConflict, models.ErrorResponse{Error: "Session is still running"})
			return
		}
		c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Session cannot continue (status: " + string(status) + ")"})
		return
	}

	// Reinitialize streamer for the new conversation turn using stored server key
	webhookNotifier := webhook.NewNotifier(
		session.GetWebhookURL(),
		s.serverKey,
		sessionID,
	)

	// Note: If the original request had Pusher config, chat continuation uses webhook-only
	// This is simpler and ensures critical events (status, complete, error) still work
	session.ReinitializeStreamer(webhookNotifier)

	// Reset session for continuation
	session.ResetForContinuation()

	// Refresh per-turn config from the request. Laravel sends a full, current
	// config each turn (provider, model, and the user's live credit balance), so
	// the session must adopt it — otherwise the runner keeps using the config
	// captured when the session first started. In particular this lets a session
	// that started on a metered plan pick up remaining_build_credits=0 after the
	// user moved to an unlimited plan, instead of staying capped at the old limit.
	if req.Config != nil {
		session.SetConfig(req.Config)
		session.SetRemainingCredits(req.Config.Agent.RemainingBuildCredits)
	} else {
		// Laravel always sends a full config on chat; if it ever doesn't, the
		// session silently keeps its start-of-session credit limit (the bug this
		// resync fixes). Surface it loudly rather than failing silently.
		s.logger.WithField("session_id", sessionID).Warn("handleChat: no config in request — credit limit and provider config not refreshed")
	}

	// Create cancellable context
	ctx, cancel := context.WithCancel(context.Background())
	session.SetCancel(cancel)

	// Use history from request (Laravel sends full conversation history)
	history := req.History
	if history == nil {
		history = []models.HistoryMessage{}
	}

	// Start agent in background with the new message
	go s.runAgent(ctx, session, req.Message, history, req.IsCompacted, 20)

	c.JSON(http.StatusOK, gin.H{
		"session_id": sessionID,
		"status":     "running",
	})
}

// extractProjectID returns the workspace ID directly (now a UUID)
// Previously stripped "project-" prefix, now workspace IDs are UUIDs directly
func extractProjectID(workspaceID string) string {
	return workspaceID
}

// injectBaseTag injects a <base> tag into HTML content to fix relative asset paths
// This ensures that relative paths like "./assets/index.js" resolve correctly
// when the preview is served at /preview/{id}/ instead of /preview/{id}
func injectBaseTag(htmlContent []byte, projectID string) []byte {
	baseTag := fmt.Sprintf(`<base href="/preview/%s/">`, projectID)

	content := strings.Replace(string(htmlContent), "<head>", "<head>\n    "+baseTag, 1)
	return []byte(content)
}

// workspaceHasFiles checks if the workspace already contains source files
// (indicating it was previously initialized and shouldn't be overwritten)
// workspaceHasFiles reports whether the workspace holds any project files.
// Target-agnostic: websites keep sources under src/, WordPress themes at the
// workspace root — so any visible entry counts. Builder-internal dot-dirs
// (.revisions, .webby, .git) and dependency/output dirs are ignored.
func workspaceHasFiles(workspacePath string) bool {
	entries, err := os.ReadDir(workspacePath)
	if err != nil {
		return false
	}
	for _, entry := range entries {
		name := entry.Name()
		if strings.HasPrefix(name, ".") || name == "node_modules" || name == "dist" {
			continue
		}
		if !entry.IsDir() {
			return true
		}
		if sub, err := os.ReadDir(filepath.Join(workspacePath, name)); err == nil && len(sub) > 0 {
			return true
		}
	}
	return false
}

// initializeWorkspaceFromTemplate fetches template ZIP from Laravel API and extracts it to workspace
func (s *Server) initializeWorkspaceFromTemplate(workspacePath, templateID, laravelURL, outputType string) error {
	// Build download URL, scoped by output target so slug/name resolution on
	// the Laravel side can never cross template families (e.g. a WordPress
	// build resolving "default" must get the WordPress default, not React).
	target := buildtarget.Resolve(outputType).Key()
	downloadURL := fmt.Sprintf("%s/api/templates/%s/download?output_target=%s", laravelURL, templateID, url.QueryEscape(target))
	s.logger.WithField("download_url", downloadURL).Debug("Fetching template from Laravel")

	// Create HTTP request with server key authentication
	req, err := http.NewRequest("GET", downloadURL, nil)
	if err != nil {
		return fmt.Errorf("creating download request: %w", err)
	}
	req.Header.Set("X-Server-Key", s.serverKey)
	req.Header.Set("User-Agent", models.HTTPUserAgent)

	// Fetch template ZIP
	client := &http.Client{Timeout: 60 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("fetching template: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()

	// Handle API errors
	if resp.StatusCode != http.StatusOK {
		body, readErr := io.ReadAll(resp.Body)
		if readErr != nil {
			return fmt.Errorf("template API returned status %d (failed to read response body: %s)", resp.StatusCode, readErr.Error())
		}
		return fmt.Errorf("template API returned status %d: %s", resp.StatusCode, string(body))
	}

	// Create temporary file for ZIP
	tmpFile, err := os.CreateTemp("", "template-*.zip")
	if err != nil {
		return fmt.Errorf("creating temp file: %w", err)
	}
	tmpPath := tmpFile.Name()
	defer func() { _ = os.Remove(tmpPath) }()

	// Download ZIP to temp file
	if _, err := io.Copy(tmpFile, resp.Body); err != nil {
		_ = tmpFile.Close()
		return fmt.Errorf("downloading template: %w", err)
	}
	_ = tmpFile.Close()

	// Extract ZIP to workspace
	if err := unzip.Extract(tmpPath, workspacePath); err != nil {
		return fmt.Errorf("extracting template: %w", err)
	}

	s.logger.WithFields(logrus.Fields{
		"template_id":    templateID,
		"workspace_path": workspacePath,
		"source":         laravelURL,
	}).Info("Workspace initialized from template")

	return nil
}

// runAgent runs the agent loop
func (s *Server) runAgent(ctx context.Context, session *agent.Session, goal string, history []models.HistoryMessage, isCompacted bool, maxIterations int) {
	// Get Laravel URL for template fetching
	laravelURL := session.GetLaravelURL()
	selectedTemplate := session.GetSelectedTemplate()

	// Log template selection for this session
	if selectedTemplate != "" {
		s.logger.WithFields(logrus.Fields{
			"session_id":   session.ID,
			"workspace_id": session.WorkspaceID,
			"template_id":  selectedTemplate,
		}).Info("Template selected for session")
	}

	// Initialize workspace from template BEFORE starting agent
	// Only initialize if workspace is empty (skip for chat continuations)
	// Only pre-initialize when user explicitly selected a template;
	// when "Automatic" (no template), the AI agent will select via fetchTemplates + useTemplate
	workspacePath := session.GetWorkspacePath()
	if !workspaceHasFiles(workspacePath) && selectedTemplate != "" {
		if err := s.initializeWorkspaceFromTemplate(workspacePath, selectedTemplate, laravelURL, session.GetOutputType()); err != nil {
			// Notify user about template fetch failure
			s.notifyError(session.ID, fmt.Sprintf("Failed to initialize workspace from template '%s': %v", selectedTemplate, err))
			session.SetError(err.Error())
			session.SetStatus(models.StatusFailed)
			return
		}

		// Template was extracted — mark session so auto-build triggers
		// even if the agent doesn't modify any files during its session
		session.SetFilesChanged(true)

		// Title the extracted template after the real project so the stock
		// placeholder <title> never reaches previews or exports.
		if err := executor.ApplyProjectTitle(workspacePath, session.GetProjectName()); err != nil {
			s.logger.WithField("error", err.Error()).Warn("Failed to apply project title (non-blocking)")
		}

		// Apply theme preset to workspace CSS if provided
		if themePreset := session.GetThemePreset(); themePreset != nil && themePreset.Light != nil && themePreset.Dark != nil {
			cssPath := filepath.Join(workspacePath, "src", "index.css")
			if content, err := os.ReadFile(cssPath); err == nil {
				newContent := applyThemeToCSS(string(content), themePreset.Light, themePreset.Dark)
				if err := os.WriteFile(cssPath, []byte(newContent), 0644); err == nil {
					s.logger.WithFields(logrus.Fields{
						"session_id":   session.ID,
						"theme_preset": themePreset.ID,
					}).Debug("Applied theme preset to workspace CSS")
				}
			}
		}
	}

	// Convert history to runner format with compaction metadata
	runnerHistory := agent.HistoryInput{
		Messages:    make([]agent.HistoryMsg, len(history)),
		IsCompacted: isCompacted,
	}
	for i, h := range history {
		runnerHistory.Messages[i] = agent.HistoryMsg{
			Role:    h.Role,
			Content: h.Content,
		}
	}

	// Update goal to inform AI about template selection state
	initialGoal := goal
	if selectedTemplate != "" {
		templateName := session.GetSelectedTemplateName()
		if templateName == "" {
			templateName = selectedTemplate // Fall back to ID if name not provided
		}
		initialGoal = fmt.Sprintf("%s\n\n(Note: The '%s' template has been pre-selected and initialized for this project. Read template.json and proceed.)", goal, templateName)
	} else if !workspaceHasFiles(workspacePath) {
		initialGoal = fmt.Sprintf("%s\n\n(Note: No template was pre-selected. You MUST call fetchTemplates first to see available templates, then select the most appropriate one using useTemplate before doing any other work.)", goal)
	}

	// Use workspace logger if available, otherwise fall back to server logger
	logger := session.GetLogger()
	if logger == nil {
		logger = s.logger
	}

	// Create runner with config from session (templatePath is no longer needed)
	cfg := session.GetConfig()
	runner := agent.NewRunnerWithTemplate(
		session.GetWorkspacePath(),
		"", // templatePath - no longer needed, templates fetched from API
		cfg.Agent,
		cfg.Summarizer,
		logger,
		s.serverKey, // serverKey for Laravel API auth
		laravelURL,  // Laravel API URL for template fetching
		cfg.Tools,   // tool config for timeout and retry
	)
	// Wire the process-wide headless-browser manager so the runner can
	// activate webby-plugin-webagent tools when the project's capability
	// payload indicates the plugin is enabled.
	runner.SetBrowserManager(s.browserMgr)

	// Create revision snapshot before running the agent. Skipped while the
	// workspace is still empty (template eager-extraction happens inside the
	// runner, AFTER this point on a project's first run) — an empty "Before"
	// checkpoint is a trap: undoing/restoring into it wipes the workspace.
	if workspaceHasFiles(workspacePath) {
		revMgr := s.getOrCreateRevisionManager(session.WorkspaceID)
		if err := revMgr.CreateSnapshot(fmt.Sprintf("Before: %s", truncateLabel(goal, 60))); err != nil {
			s.logger.WithField("error", err.Error()).Warn("Failed to create revision snapshot (non-blocking)")
		}
	}

	// Run the agent. Error handling (status, streamer events) is done inside
	// Run; log here so the failure is visible in PM2 stdout even if the streamer
	// is unable to deliver. User-initiated cancellations are not failures and
	// are skipped to avoid alert noise.
	if err := runner.Run(ctx, session, initialGoal, runnerHistory, maxIterations); err != nil {
		if models.IsCancelled(err) {
			return
		}
		s.logger.WithFields(logrus.Fields{
			"session_id": session.ID,
			"error":      err.Error(),
		}).Error("Agent run failed")
		return
	}
}

// notifyError sends an error notification via session streamer
func (s *Server) notifyError(sessionID, message string) {
	// Strip HTML tags from error message to prevent HTML in logs
	cleanMessage := logging.StripHTMLTags(message)
	s.logger.WithFields(logrus.Fields{
		"session_id": sessionID,
	}).Error(cleanMessage)
	// The streamer will send the error to the client
}

// getOrCreateRevisionManager returns or creates a revision manager for the
// given workspace. On first creation for a workspace that has persisted
// revisions on disk, rehydrates state from .revisions/metadata.json.
//
// Concurrency:
//   - Disk I/O for LoadFromDisk runs OUTSIDE the server-wide revisionMu,
//     so a slow reload on one workspace can't block manager lookups for
//     every other workspace.
//   - The manager is only published into the map AFTER LoadFromDisk
//     finishes, so no caller can ever observe a half-hydrated manager.
//   - If two goroutines race to hydrate the same workspace, both pay the
//     load cost but only the first to reach the map insert wins; the
//     loser's fully-loaded (but now discarded) manager is garbage-collected.
//     LoadFromDisk is a pure read of metadata.json + file bytes, so doing
//     it twice is idempotent and cheap.
func (s *Server) getOrCreateRevisionManager(workspaceID string) *revision.Manager {
	s.revisionMu.RLock()
	mgr, exists := s.revisionManagers[workspaceID]
	s.revisionMu.RUnlock()

	if exists {
		return mgr
	}

	workspacePath := filepath.Join(s.workspacePath, workspaceID)
	newMgr := revision.NewManager(workspacePath)
	if err := newMgr.LoadFromDisk(); err != nil {
		logrus.Warnf("revision manager: LoadFromDisk failed for workspace %s: %v", workspaceID, err)
	}

	s.revisionMu.Lock()
	defer s.revisionMu.Unlock()
	// Someone else may have raced to the map insert while we were loading.
	// Keep their instance; drop ours.
	if existing, ok := s.revisionManagers[workspaceID]; ok {
		return existing
	}
	s.revisionManagers[workspaceID] = newMgr
	return newMgr
}

// evictRevisionManager drops the cached revision manager for a workspace.
// Called when the workspace is deleted so the map can't grow unbounded and a
// recreated workspace can't observe a stale manager.
func (s *Server) evictRevisionManager(workspaceID string) {
	s.revisionMu.Lock()
	defer s.revisionMu.Unlock()
	delete(s.revisionManagers, workspaceID)
}

// guardRevisionWrite rejects revision mutations while an agent session is
// actively writing to the same workspace — a restore racing the agent's file
// writes would corrupt both. Returns false when the request was rejected.
func (s *Server) guardRevisionWrite(c *gin.Context, workspaceID string) bool {
	if s.hasActiveSessionForWorkspace(workspaceID) {
		c.JSON(http.StatusConflict, gin.H{
			"success": false,
			"error":   "A build is currently running for this workspace",
		})
		return false
	}
	return true
}

// handleUndo reverts workspace to the previous revision
func (s *Server) handleUndo(c *gin.Context) {
	_, workspaceID, ok := s.resolveWorkspaceFromRequest(c)
	if !ok {
		return
	}
	if !s.guardRevisionWrite(c, workspaceID) {
		return
	}

	mgr := s.getOrCreateRevisionManager(workspaceID)
	info, err := mgr.Undo()
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"success":  true,
		"revision": info,
	})
}

// handleRedo moves workspace forward to the next revision
func (s *Server) handleRedo(c *gin.Context) {
	_, workspaceID, ok := s.resolveWorkspaceFromRequest(c)
	if !ok {
		return
	}
	if !s.guardRevisionWrite(c, workspaceID) {
		return
	}

	mgr := s.getOrCreateRevisionManager(workspaceID)
	info, err := mgr.Redo()
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"success":  true,
		"revision": info,
	})
}

// handleRestoreRevision jumps the workspace to a specific revision id (the
// Revision History panel's "Restore" action). Live changes are checkpointed
// by the manager before the jump, so the restore is always reversible.
func (s *Server) handleRestoreRevision(c *gin.Context) {
	_, workspaceID, ok := s.resolveWorkspaceFromRequest(c)
	if !ok {
		return
	}
	if !s.guardRevisionWrite(c, workspaceID) {
		return
	}

	var req struct {
		RevisionID int `json:"revision_id" binding:"required"`
	}
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
		return
	}

	mgr := s.getOrCreateRevisionManager(workspaceID)
	info, err := mgr.RestoreTo(req.RevisionID)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"success":  true,
		"revision": info,
	})
}

// handleListRevisions returns a page of revisions with current pointer info.
//
// Query params:
//
//	?limit=N  — page size, clamped to [1, 50] (default 20).
//	?before=K — return only revisions with id < K (exclusive cursor).
//	            Omit or pass 0 for the newest page.
//
// Response (always):
//
//	{
//	  "revisions":  [{id, label, file_count, timestamp}, ...],  // newest-first
//	  "current":    <int>    // legacy: array-index pointer into mgr.List()
//	  "current_id": <int>    // revision id at the pointer (preferred)
//	  "has_more":   <bool>   // more rows exist older than the returned window
//	  "oldest_id":  <int>    // id of the last row returned; use as next before
//	}
//
// The `current` field is kept for one release so older frontends keep
// rendering a best-effort "Current" badge. New callers must prefer
// `current_id`.
func (s *Server) handleListRevisions(c *gin.Context) {
	_, workspaceID, ok := s.resolveWorkspaceFromRequest(c)
	if !ok {
		return
	}

	limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
	before, _ := strconv.Atoi(c.DefaultQuery("before", "0"))

	mgr := s.getOrCreateRevisionManager(workspaceID)
	page, currentID, hasMore := mgr.ListPaginated(limit, before)

	oldestID := 0
	if len(page) > 0 {
		oldestID = page[len(page)-1].ID
	}

	// `current` was an array-index pointer into the full (unpaginated) list.
	// Under pagination it no longer maps cleanly to the returned page, so
	// we repurpose it to the *in-page* index of the current revision, or -1
	// when the current revision isn't in this page. Old consumers that did
	// `rows[current]` get the right row when visible and a bounds-safe -1
	// otherwise. New consumers should prefer `current_id`.
	legacyCurrent := -1
	for i, r := range page {
		if r.ID == currentID {
			legacyCurrent = i
			break
		}
	}

	c.JSON(http.StatusOK, gin.H{
		"revisions":  page,
		"current":    legacyCurrent,
		"current_id": currentID,
		"has_more":   hasMore,
		"oldest_id":  oldestID,
	})
}

// handleRecover attempts to recover a workspace from a crashed/failed state.
// Accepts an optional `?output_type=` hint from Laravel (authoritative);
// WordPress theme workspaces have no package.json, so they are validated
// structurally instead of npm-rebuilt.
func (s *Server) handleRecover(c *gin.Context) {
	workspacePath, _, ok := s.resolveWorkspaceFromRequest(c)
	if !ok {
		return
	}

	packageJSON := filepath.Join(workspacePath, "package.json")
	_, pkgErr := os.Stat(packageJSON)

	// WordPress theme recovery: no npm step exists — a "recovered" theme is
	// one that passes structural validation. The output_type hint is
	// authoritative; the style.css sniff is the fallback for older callers.
	isWordPress := c.Query("output_type") == "wordpress_theme"
	if !isWordPress && os.IsNotExist(pkgErr) {
		if _, err := os.Stat(filepath.Join(workspacePath, "style.css")); err == nil {
			isWordPress = true
		}
	}
	if isWordPress {
		result := executor.ValidateWordPressTheme(c.Request.Context(), workspacePath)
		status := "success"
		if !result.Success {
			status = "failed"
		}
		c.JSON(http.StatusOK, gin.H{
			"success":      result.Success,
			"build_status": status,
			"build_output": result.Content,
		})
		return
	}

	// Check package.json exists
	if os.IsNotExist(pkgErr) {
		c.JSON(http.StatusOK, gin.H{"success": false, "build_status": "no_package_json", "error": "No package.json found in workspace"})
		return
	}

	// Run npm install
	installCtx, installCancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
	defer installCancel()

	installCmd := exec.CommandContext(installCtx, "npm", "install", "--ignore-scripts")
	installCmd.Dir = workspacePath
	if output, err := installCmd.CombinedOutput(); err != nil {
		c.JSON(http.StatusOK, gin.H{
			"success":      false,
			"build_status": "failed",
			"error":        fmt.Sprintf("npm install failed: %s", err.Error()),
			"build_output": string(output),
		})
		return
	}

	// Run npm run build
	buildCtx, buildCancel := context.WithTimeout(c.Request.Context(), 120*time.Second)
	defer buildCancel()

	buildCmd := exec.CommandContext(buildCtx, "npm", "run", "build")
	buildCmd.Dir = workspacePath
	buildOutput, buildErr := buildCmd.CombinedOutput()

	if buildErr != nil {
		c.JSON(http.StatusOK, gin.H{
			"success":      false,
			"build_status": "failed",
			"error":        fmt.Sprintf("npm run build failed: %s", buildErr.Error()),
			"build_output": string(buildOutput),
		})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"success":      true,
		"build_status": "success",
		"build_output": string(buildOutput),
	})
}

// handleClassEdit performs className-aware search/replace in TSX files
func (s *Server) handleClassEdit(c *gin.Context) {
	workspacePath, workspaceID, ok := s.resolveWorkspaceFromRequest(c)
	if !ok {
		return
	}

	var req struct {
		// Path is the legacy explicit form. When empty the element is located by
		// its full className across src/ (the visual editor can't know the source
		// file of a production-built element), disambiguated by TextAnchor.
		Path         string `json:"path"`
		OldClassName string `json:"old_class_name" binding:"required"`
		NewClassName string `json:"new_class_name" binding:"required"`
		TextAnchor   string `json:"text_anchor"`
	}
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	oldPattern := fmt.Sprintf(`className="%s"`, req.OldClassName)
	newPattern := fmt.Sprintf(`className="%s"`, req.NewClassName)

	// Resolve the target file + byte offset of the className to replace.
	var fullPath string
	var idx int
	if req.Path != "" {
		var err error
		fullPath, err = s.validateAndResolvePath(workspacePath, req.Path)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid path"})
			return
		}
		if writeErr := s.validatePathForAPIWrite(req.Path); writeErr != nil {
			c.JSON(http.StatusForbidden, gin.H{"error": writeErr.Error()})
			return
		}
		b, err := os.ReadFile(fullPath)
		if err != nil {
			c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
			return
		}
		idx = strings.Index(string(b), oldPattern)
		if idx < 0 {
			c.JSON(http.StatusOK, gin.H{"success": false, "reason": "not_found", "replacements": 0})
			return
		}
	} else {
		// Content-located deterministic edit. A "not_found"/"ambiguous" result is
		// not an error — the caller falls back to the AI style-edit path.
		f, i, status := locateClassEditTarget(filepath.Join(workspacePath, "src"), oldPattern, req.TextAnchor)
		if status != "ok" {
			c.JSON(http.StatusOK, gin.H{"success": false, "reason": status, "replacements": 0})
			return
		}
		fullPath, idx = f, i
	}

	content, err := os.ReadFile(fullPath)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
		return
	}
	fileStr := string(content)
	if idx < 0 || idx+len(oldPattern) > len(fileStr) || fileStr[idx:idx+len(oldPattern)] != oldPattern {
		c.JSON(http.StatusOK, gin.H{"success": false, "reason": "not_found", "replacements": 0})
		return
	}

	// Checkpoint the pre-edit state so the deterministic style edit is
	// undoable and visible in the revision history (and so a stale redo
	// branch can't silently clobber it). Non-blocking, matching runAgent.
	if err := s.getOrCreateRevisionManager(workspaceID).CreateSnapshot("Before: Style edit"); err != nil {
		s.logger.WithField("error", err.Error()).Warn("Failed to create revision snapshot for class edit (non-blocking)")
	}

	newContent := fileStr[:idx] + newPattern + fileStr[idx+len(oldPattern):]
	if err := os.WriteFile(fullPath, []byte(newContent), 0644); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to write file"})
		return
	}

	c.JSON(http.StatusOK, gin.H{"success": true, "replacements": 1})
}

// classOccurrence is one source location of a className="..." literal.
type classOccurrence struct {
	file string
	idx  int
}

// locateClassEditTarget finds the single source occurrence of oldPattern
// (`className="..."`) under srcDir. One occurrence is unambiguous; several are
// disambiguated by textAnchor (the element's visible text, which in JSX sits a
// few hundred bytes from its className). Returns status "ok" | "not_found" |
// "ambiguous"; "ambiguous"/"not_found" tell the caller to fall back to the AI.
func locateClassEditTarget(srcDir, oldPattern, textAnchor string) (string, int, string) {
	exts := map[string]bool{".tsx": true, ".jsx": true, ".ts": true, ".js": true}
	var occs []classOccurrence
	cache := map[string]string{}

	_ = filepath.Walk(srcDir, func(p string, info os.FileInfo, err error) error {
		if err != nil || info.IsDir() || !exts[filepath.Ext(p)] {
			return nil
		}
		b, e := os.ReadFile(p)
		if e != nil {
			return nil
		}
		str := string(b)
		cache[p] = str
		for i := 0; ; {
			j := strings.Index(str[i:], oldPattern)
			if j < 0 {
				break
			}
			occs = append(occs, classOccurrence{file: p, idx: i + j})
			i = i + j + len(oldPattern)
		}
		return nil
	})

	switch {
	case len(occs) == 0:
		return "", 0, "not_found"
	case len(occs) == 1:
		return occs[0].file, occs[0].idx, "ok"
	case textAnchor == "":
		return "", 0, "ambiguous"
	}

	// Multiple identical classNames: pick the occurrence whose element text
	// (textAnchor) is nearest in source, within a tight window.
	const maxDist = 800
	bestDist, ties := -1, 0
	var best classOccurrence
	for _, o := range occs {
		str := cache[o.file]
		nearest := -1
		for k := 0; ; {
			j := strings.Index(str[k:], textAnchor)
			if j < 0 {
				break
			}
			pos := k + j
			d := pos - o.idx
			if d < 0 {
				d = -d
			}
			if nearest < 0 || d < nearest {
				nearest = d
			}
			k = pos + len(textAnchor)
		}
		if nearest < 0 || nearest > maxDist {
			continue
		}
		if bestDist < 0 || nearest < bestDist {
			bestDist, best, ties = nearest, o, 1
		} else if nearest == bestDist {
			ties++
		}
	}
	if bestDist < 0 || ties > 1 {
		return "", 0, "ambiguous"
	}
	return best.file, best.idx, "ok"
}

// truncateLabel shortens a label to maxLen characters
func truncateLabel(label string, maxLen int) string {
	if len(label) <= maxLen {
		return label
	}
	return label[:maxLen-3] + "..."
}
