package agent

import (
	"context"
	"encoding/json"
	"fmt"
	"math/rand"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"time"

	"webby-builder/internal/browser"
	"webby-builder/internal/buildtarget"
	"webby-builder/internal/client"
	"webby-builder/internal/client/laravel"
	"webby-builder/internal/executor"
	gitpkg "webby-builder/internal/git"
	"webby-builder/internal/logging"
	"webby-builder/internal/models"
	"webby-builder/internal/registry"
	"webby-builder/internal/scrape"
	"webby-builder/internal/smoke"
	"webby-builder/internal/summarizer"

	"github.com/sirupsen/logrus"
)

// FactoryImport is a type alias to avoid import issues
type Factory = client.Factory

// Fun phrase pools for action messages
var createPhrases = []string{
	"Laying the bricks for",
	"Conjuring",
	"Cooking up",
	"Crafting",
	"Bringing to life",
	"Building",
	"Weaving",
	"Whipping up",
}

var editPhrases = []string{
	"Polishing",
	"Sprinkling magic on",
	"Fine-tuning",
	"Adding sparkle to",
	"Seasoning",
	"Touching up",
}

var readPhrases = []string{
	"Studying",
	"Checking the blueprints in",
	"Peeking at",
	"Consulting",
	"Scanning",
}

var explorePhrases = []string{
	"Surveying the land",
	"Getting the lay of the land",
	"Mapping things out",
	"Scouting the project",
}

// randomPhrase returns a random phrase from the given pool
func randomPhrase(phrases []string) string {
	return phrases[rand.Intn(len(phrases))]
}

// errorData builds an ErrorData event payload carrying per-run token usage so
// Laravel can charge build credits for a run that failed before reaching the
// complete event. Without this, a failed build consumes provider tokens but
// deducts no credits.
func errorData(session *Session, model, msg string) models.ErrorData {
	rp, rc, rt := session.GetRunTokenStats()
	return models.ErrorData{
		Error:               msg,
		RunTokensUsed:       rt,
		RunPromptTokens:     rp,
		RunCompletionTokens: rc,
		Model:               model,
	}
}

// formatProjectCapabilities formats project capabilities as JSON for tool result
func formatProjectCapabilities(caps *models.ProjectCapabilities) string {
	var supabase, storage, webAgent string

	if caps.Supabase != nil {
		supabase = fmt.Sprintf(`"supabase":{"enabled":%t,"url":%q,"publishable_key":%q,"schema":%q}`,
			caps.Supabase.Enabled, caps.Supabase.URL, caps.Supabase.PublishableKey, caps.Supabase.Schema)
	} else {
		supabase = `"supabase":{"enabled":false,"url":"","publishable_key":"","schema":""}`
	}

	if caps.Storage != nil {
		storage = fmt.Sprintf(`"storage":{"enabled":%t,"max_file_size_mb":%d}`,
			caps.Storage.Enabled, caps.Storage.MaxFileSizeMB)
	} else {
		storage = `"storage":{"enabled":false,"max_file_size_mb":0}`
	}

	// Surface web agent availability so the agent's getProjectCapabilities
	// self-check matches the actual registered tool list.
	if caps.WebAgent != nil {
		mode := caps.WebAgent.FetchMode
		if mode == "" {
			mode = "http"
		}
		webAgent = fmt.Sprintf(`"web_agent":{"enabled":%t,"fetch_mode":%q}`, caps.WebAgent.Enabled, mode)
	} else {
		webAgent = `"web_agent":{"enabled":false}`
	}

	return fmt.Sprintf("{%s,%s,%s}", supabase, storage, webAgent)
}

// executeDefineTable handles the session-level defineTable tool. It parses the
// agent's structured spec, proxies it to Laravel (which authors and executes
// the safe schema-qualified SQL + RLS), and returns the JSON result to the
// agent so it can react to validation errors.
//
// SECURITY: this path NEVER reads or forwards secret_key/db_connection — those
// live only on the Laravel side. The builder only sends the structured spec
// plus the session id.
func (r *Runner) executeDefineTable(session *Session, args map[string]interface{}) string {
	// Defense-in-depth: re-verify the capability even though the tool is only
	// registered when Supabase is enabled.
	caps := session.GetProjectCapabilities()
	if caps == nil || caps.Supabase == nil || !caps.Supabase.Enabled {
		return `{"ok":false,"error":"Supabase is not enabled for this project"}`
	}

	laravelURL := session.GetLaravelURL()
	serverKey := r.executor.GetServerKey()
	if laravelURL == "" || serverKey == "" {
		r.logger.WithField("session_id", session.ID).Warn("defineTable: missing Laravel URL or server key")
		return `{"ok":false,"error":"Database service is not reachable"}`
	}

	spec := parseDefineTableArgs(args)

	client := laravel.NewClient(laravelURL, serverKey)
	result, err := client.DefineTable(session.ID, spec)
	if err != nil {
		r.logger.WithFields(logrus.Fields{
			"session_id": session.ID,
			"table":      spec.Table,
			"error":      err.Error(),
		}).Warn("defineTable: Laravel request failed")
		return `{"ok":false,"error":"Could not reach the database service — please try again"}`
	}

	r.logger.WithFields(logrus.Fields{
		"session_id": session.ID,
		"table":      spec.Table,
		"access":     spec.Access,
		"ok":         result.OK,
	}).Info("defineTable: completed")

	out, err := json.Marshal(result)
	if err != nil {
		return `{"ok":false,"error":"Failed to encode result"}`
	}
	return string(out)
}

// parseDefineTableArgs converts the loosely-typed tool arguments (from the LLM)
// into a structured DefineTableSpec. Missing/odd fields degrade gracefully so
// Laravel performs the authoritative validation.
func parseDefineTableArgs(args map[string]interface{}) laravel.DefineTableSpec {
	spec := laravel.DefineTableSpec{}
	spec.Table, _ = args["table"].(string)
	spec.Access, _ = args["access"].(string)

	if rawCols, ok := args["columns"].([]interface{}); ok {
		for _, rc := range rawCols {
			colMap, ok := rc.(map[string]interface{})
			if !ok {
				continue
			}
			col := laravel.DefineTableColumn{}
			col.Name, _ = colMap["name"].(string)
			col.Type, _ = colMap["type"].(string)
			if n, ok := colMap["nullable"].(bool); ok {
				col.Nullable = &n
			}
			if d, ok := colMap["default"].(string); ok {
				col.Default = d
			}
			spec.Columns = append(spec.Columns, col)
		}
	}
	return spec
}

// HistoryMsg represents a message in conversation history
type HistoryMsg struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

// HistoryInput wraps history with metadata for the runner
type HistoryInput struct {
	Messages    []HistoryMsg
	IsCompacted bool // If true, skip summarization (history already compacted)
}

// Runner executes the agent loop
type Runner struct {
	executor        *executor.Executor
	aiConfig        models.ProviderConfig
	summarizer      *summarizer.Summarizer
	logger          *logrus.Logger
	providerFactory *Factory
	// browserMgr is the process-wide headless-browser session manager. Set via
	// SetBrowserManager. When nil, web tools still register but webBrowser*
	// calls return chrome_unavailable.
	browserMgr *browser.Manager
	// providerOverride, when non-nil, replaces the factory-created provider.
	// It lets tests drive the agent loop with scripted responses instead of
	// calling a live LLM. Always nil in production.
	providerOverride models.AIProvider
}

// SetBrowserManager wires the process-wide headless-browser manager so the
// runner can build a WebExecutor when the project has the web agent capability.
func (r *Runner) SetBrowserManager(m *browser.Manager) {
	r.browserMgr = m
}

// SetProviderForTest injects an AIProvider that replaces the factory-created
// provider, so tests can drive the agent loop deterministically with scripted
// responses. Intended for tests only.
func (r *Runner) SetProviderForTest(p models.AIProvider) {
	r.providerOverride = p
}

// reconcileFirecrawl posts the session's accumulated Firecrawl page count to
// Laravel for monthly quota tracking. Best-effort — failures are logged but do
// not fail the build. Uses context.Background() so it runs even when the
// build's main context is cancelled.
func (r *Runner) reconcileFirecrawl(ctx context.Context, session *Session) {
	// Prefer the live executor SessionState (set when Firecrawl was wired);
	// fall back to the session field for tests that set it directly.
	pagesUsed := session.firecrawlPagesUsed
	if session.firecrawlState != nil {
		pagesUsed = session.firecrawlState.FirecrawlPagesUsed
	}
	if pagesUsed <= 0 {
		return
	}
	laravelURL := session.GetLaravelURL()
	if laravelURL == "" {
		r.logger.Warn("firecrawl reconciliation skipped: no Laravel URL on session")
		return
	}
	serverKey := r.executor.GetServerKey()
	laravelClient := laravel.NewClient(laravelURL, serverKey)
	if err := laravelClient.ReportFirecrawlUsage(ctx, session.ID, session.GetUserID(), pagesUsed); err != nil {
		r.logger.WithFields(logrus.Fields{
			"session_id": session.ID,
			"pages_used": pagesUsed,
			"error":      err.Error(),
		}).Warn("firecrawl usage reconciliation failed; quota may drift")
	} else {
		r.logger.WithFields(logrus.Fields{
			"session_id": session.ID,
			"pages_used": pagesUsed,
		}).Info("firecrawl usage reconciliation posted to Laravel")
	}
}

// NewRunner creates a new runner with configuration
func NewRunner(workspacePath, templatePath string, agentCfg, summarizerCfg models.ProviderConfig, logger *logrus.Logger, toolConfig models.ToolExecutionConfig) *Runner {
	return &Runner{
		executor:        executor.NewExecutor(workspacePath, logger, toolConfig),
		aiConfig:        agentCfg,
		summarizer:      summarizer.NewSummarizerWithConfig(summarizerCfg),
		logger:          logger,
		providerFactory: client.NewFactory(logger, models.DefaultRetryConfig(), nil),
	}
}

// NewRunnerWithTemplate creates a new runner with template fetching support
func NewRunnerWithTemplate(workspacePath, templatePath string, agentCfg, summarizerCfg models.ProviderConfig, logger *logrus.Logger, serverKey, laravelURL string, toolConfig models.ToolExecutionConfig) *Runner {
	return &Runner{
		executor:        executor.NewExecutorWithTemplate(workspacePath, serverKey, laravelURL, logger, toolConfig),
		aiConfig:        agentCfg,
		summarizer:      summarizer.NewSummarizerWithConfig(summarizerCfg),
		logger:          logger,
		providerFactory: client.NewFactory(logger, models.DefaultRetryConfig(), nil),
	}
}

// loadTemplatePrompts reads template.json from workspace and extracts prompt sections
func (r *Runner) loadTemplatePrompts(workspacePath string) *models.TemplatePrompts {
	templatePath := filepath.Join(workspacePath, "template.json")

	data, err := os.ReadFile(templatePath)
	if err != nil {
		r.logger.WithFields(logrus.Fields{
			"path": templatePath,
		}).Debug("No template.json found, using default prompts")
		return nil
	}

	var metadata models.TemplateMetadata
	if err := json.Unmarshal(data, &metadata); err != nil {
		r.logger.WithFields(logrus.Fields{
			"path":  templatePath,
			"error": err.Error(),
		}).Warn("Failed to parse template.json for prompts")
		return nil
	}

	if metadata.Prompts != nil {
		r.logger.WithFields(logrus.Fields{
			"prompt_count": len(metadata.Prompts.Prompts),
		}).Debug("Loaded template prompts")
	}

	return metadata.Prompts
}

// loadTemplateMetadata reads template.json from workspace and returns the full metadata.
// Returns nil if template.json doesn't exist or is invalid (graceful fallback).
func (r *Runner) loadTemplateMetadata(workspacePath string) *models.TemplateMetadata {
	templatePath := filepath.Join(workspacePath, "template.json")

	data, err := os.ReadFile(templatePath)
	if err != nil {
		return nil
	}

	var metadata models.TemplateMetadata
	if err := json.Unmarshal(data, &metadata); err != nil {
		r.logger.WithFields(logrus.Fields{
			"path":  templatePath,
			"error": err.Error(),
		}).Warn("Failed to parse template.json for metadata")
		return nil
	}

	r.logger.WithFields(logrus.Fields{
		"template_name": metadata.Name,
		"pages":         len(metadata.AvailablePages),
	}).Debug("Loaded template metadata")

	return &metadata
}

// loadTemplateKnowledge reads an optional KNOWLEDGE.md shipped at the template
// root. It holds the template's design system and component/logic notes —
// authored per template so the agent gets template-specific guidance without
// bloating the global system prompt. Returns "" when the template ships none.
func (r *Runner) loadTemplateKnowledge(workspacePath string) string {
	knowledgePath := filepath.Join(workspacePath, "KNOWLEDGE.md")
	data, err := os.ReadFile(knowledgePath)
	if err != nil {
		return ""
	}
	r.logger.WithFields(logrus.Fields{
		"path":  knowledgePath,
		"bytes": len(data),
	}).Debug("Loaded template knowledge doc")
	return string(data)
}

// loadSiteMemory reads the workspace's memory.json (recorded business facts).
// Present on continuation builds; returns "" on a fresh project.
func (r *Runner) loadSiteMemory(workspacePath string) string {
	data, err := os.ReadFile(filepath.Join(workspacePath, "memory.json"))
	if err != nil {
		return ""
	}
	r.logger.WithFields(logrus.Fields{"bytes": len(data)}).Debug("Loaded site memory")
	return string(data)
}

// loadDesignIntelligence reads the workspace's design-intelligence.json
// (recorded design decisions). Present on continuation builds; "" otherwise.
func (r *Runner) loadDesignIntelligence(workspacePath string) string {
	data, err := os.ReadFile(filepath.Join(workspacePath, "design-intelligence.json"))
	if err != nil {
		return ""
	}
	r.logger.WithFields(logrus.Fields{"bytes": len(data)}).Debug("Loaded design intelligence")
	return string(data)
}

// getToolRecoveryGuidance returns specific recovery steps for a failed tool
func getToolRecoveryGuidance(toolName string) string {
	switch toolName {
	case "editFile":
		return `RECOVERY STEPS:
1. Use readFile to see the CURRENT file content (it may have changed since you last read it)
2. Copy the EXACT text you want to replace, including all whitespace and newlines
3. If the text doesn't exist anymore, use searchFiles to find where it moved
4. For changes affecting >20 lines, consider using createFile instead
5. If this file has failed multiple edits, use createFile to rewrite it completely`
	case "createFile":
		return `RECOVERY STEPS:
1. Read the error message - look for the specific line/column with the syntax issue
2. Check for: missing closing brackets }, unclosed JSX tags </>, unmatched quotes
3. Verify all imports reference files that actually exist
4. Ensure there's exactly ONE default export
5. Use diffPreview first to see what would change`
	case "verifyBuild":
		return `RECOVERY STEPS:
1. Read the SPECIFIC file and line mentioned in the error using readFile
2. Common issues: missing imports, typos in component names, unclosed tags
3. Fix ONE error at a time, then rerun verifyBuild
4. Don't guess - always read the problematic code first before attempting a fix`
	case "verifyIntegration":
		return `RECOVERY STEPS:
1. Read src/routes.tsx to see current imports and routes
2. Ensure the page file exists in src/pages/
3. Check that the import path matches the actual file name (case-sensitive)
4. Verify the route entry has all required fields: path, label, element, showInNav, layout`
	default:
		return "Try a different approach, or use readFile to understand the current state before retrying."
	}
}

// githubCommitMessage generates a concise commit subject from the staged diff
// via the cheap summarizer model, falling back to the goal-derived default on
// any error or empty output.
func (r *Runner) githubCommitMessage(ctx context.Context, diffStat, goal string) string {
	fallback := commitMessageFromGoal(goal)
	if r.summarizer == nil || strings.TrimSpace(diffStat) == "" {
		return fallback
	}
	llmCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
	defer cancel()
	system := "You write concise git commit subject lines for an AI website builder. Output ONLY a single imperative subject line under 72 characters — no quotes, no body, no markdown."
	user := "The user asked: " + goal + "\n\nStaged changes:\n" + diffStat + "\n\nWrite the commit subject:"
	out, err := r.summarizer.Complete(llmCtx, system, user, 40)
	if err != nil {
		return fallback
	}
	if m := sanitizeCommitMessage(out); m != "" {
		return m
	}
	return fallback
}

// Run executes the agent loop for a session
func (r *Runner) Run(ctx context.Context, session *Session, goal string, historyInput HistoryInput, maxIterations int) (err error) {
	// Create cancellable context
	ctx, cancel := context.WithCancel(ctx)
	session.SetCancel(cancel)
	session.SetStatus(models.StatusRunning)

	streamer := session.GetStreamer()
	features := session.GetFeatures()

	// Pass theme preset to executor for application after useTemplate
	if preset := session.GetThemePreset(); preset != nil {
		r.executor.SetThemePreset(preset)
	}

	// Pass the resolved design system to the executor; it is overlaid onto the
	// template after useTemplate and its playbook is captured for prompt injection.
	if ds := session.GetDesignSystem(); ds != nil {
		r.executor.SetDesignSystem(ds)
	}

	// Select the generation output kind (website vs WordPress theme).
	r.executor.SetOutputTarget(buildtarget.Resolve(session.GetOutputType()))

	// Pass the project's display name so useTemplate can title the site.
	r.executor.SetProjectName(session.GetProjectName())

	// Fetch image library from Laravel API (cached per session)
	if laravelURL := session.GetLaravelURL(); laravelURL != "" {
		if serverKey := r.executor.GetServerKey(); serverKey != "" {
			imgClient := laravel.NewClient(laravelURL, serverKey)
			imgResp, err := imgClient.FetchImageLibrary()
			if err != nil {
				r.logger.WithField("error", err.Error()).Warn("Failed to fetch image library, using fallback")
			} else if len(imgResp.Images) > 0 {
				reg := registry.NewImageRegistry()
				entries := make([]registry.ImageEntry, len(imgResp.Images))
				for i, img := range imgResp.Images {
					entries[i] = registry.ImageEntry{
						Filename:   img.Filename,
						Type:       img.Type,
						Subject:    img.Subject,
						Category:   img.Category,
						Categories: img.Categories,
						Mood:       img.Mood,
						Tone:       img.Tone,
						Contrast:   img.Contrast,
					}
				}
				reg.LoadFromEntries(entries)
				r.executor.SetImageRegistry(reg)
				r.logger.WithField("count", len(entries)).Info("Loaded image library from Laravel API")
			}
		}
	}

	// Sync credit enforcement from the (possibly refreshed) config on every run.
	// 0 = unlimited. This MUST be unconditional: a reused session whose plan
	// became unlimited (or whose balance changed) would otherwise keep the stale
	// limit captured on a previous run and be wrongly capped mid-build.
	session.SetRemainingCredits(r.aiConfig.RemainingBuildCredits)
	if r.aiConfig.RemainingBuildCredits > 0 {
		r.logger.WithFields(logrus.Fields{
			"session_id":        session.ID,
			"remaining_credits": r.aiConfig.RemainingBuildCredits,
		}).Info("Credit enforcement enabled")
	} else {
		r.logger.WithField("session_id", session.ID).Info("Credit enforcement disabled (unlimited)")
	}

	// Log session start with visual separator
	logging.LogSessionStart(r.logger, session.ID, session.WorkspaceID, goal, maxIterations)

	defer func() {
		// Safety net for failure paths that returned an error without setting
		// terminal state or notifying the streamer. Without this, an early
		// return between SESSION START and the agent loop would be silently
		// promoted to StatusCompleted by the unconditional branch below.
		if err != nil {
			if session.GetStatus() == models.StatusRunning {
				session.SetError(err.Error())
				streamer.SendStatus("failed", err.Error())
				streamer.SendError(errorData(session, r.aiConfig.Model, err.Error()))
				r.logger.WithFields(logrus.Fields{
					"session_id": session.ID,
					"error":      err.Error(),
				}).Error("Agent run failed")
			}
		} else if session.GetStatus() == models.StatusRunning {
			session.SetStatus(models.StatusCompleted)
		}
		// Ensure token usage is always reported, even on error/cancel paths.
		// If SendComplete was already called by the normal flow, skip.
		if !session.IsCompleteSent() && session.TokensUsed > 0 {
			runPrompt, runCompletion, runTotal := session.GetRunTokenStats()
			streamer.SendComplete(models.CompleteData{
				Iterations:          session.Iterations,
				TokensUsed:          session.TokensUsed,
				FilesChanged:        r.executor.HasFileChanges() || session.GetFilesChanged(),
				PromptTokens:        session.PromptTokens,
				CompletionTokens:    session.CompletionTokens,
				RunTokensUsed:       runTotal,
				RunPromptTokens:     runPrompt,
				RunCompletionTokens: runCompletion,
				Model:               r.aiConfig.Model,
			})
		}
		streamer.Close()
	}()

	// Create AI provider with retry config based on feature flags
	var provider models.AIProvider
	if r.providerOverride != nil {
		// Test-injected provider — drives the agent loop deterministically.
		provider = r.providerOverride
	} else if features.RetryLLM {
		// Create retry callback to send events
		retryCallback := func(attempt, maxRetries int, delay time.Duration, err error) {
			reason := err.Error()
			if features.GranularEvents {
				streamer.SendRetry(attempt, maxRetries, delay.Milliseconds(), reason)
			}
			r.logger.WithFields(logrus.Fields{
				"session_id":  session.ID,
				"attempt":     attempt,
				"max_retries": maxRetries,
				"delay_ms":    delay.Milliseconds(),
				"reason":      reason,
			}).Debug("LLM retry")
		}
		// Create factory with retry callback
		retryFactory := client.NewFactory(r.logger, models.DefaultRetryConfig(), retryCallback)
		var providerErr error
		provider, providerErr = retryFactory.CreateProvider(r.aiConfig)
		if providerErr != nil {
			session.SetError(providerErr.Error())
			streamer.SendStatus("failed", "Failed to create AI provider: "+providerErr.Error())
			streamer.SendError(errorData(session, r.aiConfig.Model, providerErr.Error()))
			r.logger.WithFields(logrus.Fields{
				"session_id": session.ID,
				"error":      providerErr.Error(),
			}).Error("Failed to create AI provider")
			return fmt.Errorf("failed to create AI provider: %w", providerErr)
		}
	} else {
		var providerErr error
		provider, providerErr = r.providerFactory.CreateProvider(r.aiConfig)
		if providerErr != nil {
			session.SetError(providerErr.Error())
			streamer.SendStatus("failed", "Failed to create AI provider: "+providerErr.Error())
			streamer.SendError(errorData(session, r.aiConfig.Model, providerErr.Error()))
			r.logger.WithFields(logrus.Fields{
				"session_id": session.ID,
				"error":      providerErr.Error(),
			}).Error("Failed to create AI provider")
			return fmt.Errorf("failed to create AI provider: %w", providerErr)
		}
	}

	// Load template prompts and metadata from workspace (only if a template is already present)
	// When no template is pre-selected, the AI will call useTemplate during the agent loop,
	// so template.json won't exist yet — skip the unnecessary file read.
	var templatePrompts *models.TemplatePrompts
	var templateMetadata *models.TemplateMetadata
	var templateKnowledge string
	var designPlaybook string
	if session.GetSelectedTemplate() != "" {
		templatePrompts = r.loadTemplatePrompts(r.executor.GetWorkspacePath())
		templateMetadata = r.loadTemplateMetadata(r.executor.GetWorkspacePath())
		templateKnowledge = r.loadTemplateKnowledge(r.executor.GetWorkspacePath())
		// The template was eagerly extracted (not via the useTemplate tool), so
		// apply the design-system overlay here and capture its playbook. The
		// executor dispatches per build target (theme.json merge for WordPress,
		// src/index.css for websites).
		if session.GetDesignSystem() != nil {
			if pb, dErr := r.executor.ApplyEagerDesignOverlay(); dErr == nil {
				designPlaybook = pb
			} else {
				r.logger.WithError(dErr).Warn("Eager design overlay failed (non-blocking)")
			}
		}
	}

	// Site memory and design intelligence from prior sessions. On continuation
	// builds these files persist in the workspace; loading them here injects
	// the recorded context into the system prompt so the agent always has it,
	// without depending on it remembering to call readSiteMemory.
	siteMemory := r.loadSiteMemory(r.executor.GetWorkspacePath())
	designIntelligence := r.loadDesignIntelligence(r.executor.GetWorkspacePath())

	// Calculate token budget for the system prompt (reserve ~60% for history + response)
	tokenBudget := 0
	if r.aiConfig.ContextWindow > 0 {
		tokenBudget = int(float64(r.aiConfig.ContextWindow) * 0.4)
	}

	// Build system prompt with template injections
	promptCfg := PromptConfig{
		ProjectName:        projectNameOrDefault(session.GetProjectName()),
		WorkspacePath:      r.executor.GetWorkspacePath(),
		TemplatePrompts:    templatePrompts,
		TemplateMetadata:   templateMetadata,
		TemplateKnowledge:  templateKnowledge,
		SiteMemory:         siteMemory,
		DesignIntelligence: designIntelligence,
		Capabilities:       session.GetProjectCapabilities(),
		ThemePreset:        session.GetThemePreset(),
		Compact:            getModelTier(r.aiConfig.ProviderType, r.aiConfig.Model) == "standard",
		TokenBudget:        tokenBudget,
		Goal:               goal,
		OutputType:         session.GetOutputType(),
	}
	systemPrompt, promptErr := BuildSystemPrompt(promptCfg)
	if promptErr != nil {
		session.SetError(promptErr.Error())
		streamer.SendStatus("failed", "Failed to build system prompt: "+promptErr.Error())
		streamer.SendError(errorData(session, r.aiConfig.Model, promptErr.Error()))
		r.logger.WithFields(logrus.Fields{
			"session_id": session.ID,
			"error":      promptErr.Error(),
		}).Error("Failed to build system prompt")
		return fmt.Errorf("building system prompt: %w", promptErr)
	}

	// Initialize conversation with system prompt
	messages := []models.Message{
		{Role: "system", Content: systemPrompt},
	}

	// Inject the design-system playbook (from the eager overlay) as an
	// authoritative early message so the agent builds on the system.
	if designPlaybook != "" {
		messages = append(messages, models.Message{
			Role:    "user",
			Content: "## Design System (authoritative — build on this):\n\n" + designPlaybook,
		})
	}

	// Process history
	history := historyInput.Messages
	if len(history) > 0 {
		if historyInput.IsCompacted {
			// Already compacted by Laravel - use directly without summarization
			r.logger.WithFields(logrus.Fields{
				"session_id":     session.ID,
				"history_length": len(history),
			}).Debug("Using pre-compacted history, skipping summarization")

			for _, h := range history {
				messages = append(messages, models.Message{
					Role:    h.Role,
					Content: h.Content,
				})
			}
		} else {
			// Not compacted - run through summarizer
			turns := make([]summarizer.Turn, len(history))
			for i, h := range history {
				turns[i] = summarizer.Turn{Role: h.Role, Content: h.Content}
			}

			// Send status BEFORE summarization to show animation during API call
			if len(turns) > r.summarizer.GetKeepRecent() {
				streamer.SendStatus("compacting", "Summarizing conversation history...")
			}

			// Process with summarization if needed
			state, err := r.summarizer.Process(ctx, turns)
			if err == nil {
				// Track summarization tokens for credit depletion
				if state.TotalTokens > 0 {
					session.UpdateTokens(state.PromptTokens, state.CompletionTokens, state.TotalTokens)
					r.logger.WithFields(logrus.Fields{
						"session_id":            session.ID,
						"summarizer_prompt":     state.PromptTokens,
						"summarizer_completion": state.CompletionTokens,
						"summarizer_total":      state.TotalTokens,
					}).Debug("Added summarization tokens to session")
				}

				// Build compacted history to send back to Laravel
				var compactedHistory []models.HistoryMessage

				// Add summary if generated
				if state.Summary != "" {
					// Add summary as assistant message for Laravel storage
					compactedHistory = append(compactedHistory, models.HistoryMessage{
						Role:    "assistant",
						Content: "[Previous conversation summary]\n" + state.Summary,
					})

					messages = append(messages, models.Message{
						Role:    "system",
						Content: "CONVERSATION HISTORY:\n" + state.Summary,
					})
				}

				// Add recent turns in full
				for _, t := range state.RecentTurns {
					compactedHistory = append(compactedHistory, models.HistoryMessage{
						Role:    t.Role,
						Content: t.Content,
					})
					messages = append(messages, models.Message{
						Role:    t.Role,
						Content: t.Content,
					})
				}

				// Send summarization complete event with compacted history
				if state.Summary != "" {
					oldTokens := summarizer.EstimateTokens(state.Summary)
					for _, t := range turns {
						oldTokens += summarizer.EstimateTokens(t.Content)
					}
					// Estimate new tokens: summary + recent turns
					newTokens := summarizer.EstimateTokens(state.Summary)
					for _, t := range state.RecentTurns {
						newTokens += summarizer.EstimateTokens(t.Content)
					}
					turnsCompacted := len(turns) - len(state.RecentTurns)
					reductionPct := float64(0)
					if oldTokens > 0 {
						reductionPct = float64(oldTokens-newTokens) / float64(oldTokens) * 100
					}
					streamer.SendSummarizationComplete(models.SummarizationEventData{
						OldTokens:        oldTokens,
						NewTokens:        newTokens,
						ReductionPercent: reductionPct,
						TurnsCompacted:   turnsCompacted,
						TurnsKept:        len(state.RecentTurns),
						Message:          fmt.Sprintf("Compressed %d turns into summary, keeping %d recent turns", turnsCompacted, len(state.RecentTurns)),
						CompactedHistory: compactedHistory,
					})
				}
			} else {
				// Log summarizer failure for debugging
				r.logger.WithFields(logrus.Fields{
					"session_id": session.ID,
					"error":      err.Error(),
					"turns":      len(turns),
				}).Warn("Summarizer failed, using truncated history fallback")

				// Fallback: just use last 20 messages
				historyLimit := 20
				startIdx := 0
				if len(history) > historyLimit {
					startIdx = len(history) - historyLimit
				}
				for _, h := range history[startIdx:] {
					messages = append(messages, models.Message{
						Role:    h.Role,
						Content: h.Content,
					})
				}
			}
		}
	}

	// Inject persistent context (survives conversation compaction)
	if memContent := loadSiteMemory(r.executor.GetWorkspacePath()); memContent != "" {
		messages = append(messages, models.Message{
			Role:    "system",
			Content: "[SITE MEMORY — business context from previous sessions]\n" + memContent,
		})
	}
	if diContent := loadDesignIntelligence(r.executor.GetWorkspacePath()); diContent != "" {
		messages = append(messages, models.Message{
			Role:    "system",
			Content: "[DESIGN INTELLIGENCE — design decisions from previous sessions]\n" + diContent,
		})
	}

	// Add current user message
	messages = append(messages, models.Message{
		Role:    "user",
		Content: goal,
	})

	// Build tool options with dynamic image categories from the registry
	toolOpts := ToolOptions{}
	if reg := r.executor.GetImageRegistry(); reg != nil {
		toolOpts.BackgroundCategories, toolOpts.GalleryCategories = reg.UniqueCategories()
	}
	// Target gating: declarative targets (WordPress block themes) drop the
	// React/Node-only tools (shadcn components, Lucide icons, route analysis,
	// integration checks, AEO) so the agent never sees tools that cannot apply.
	toolOpts.WordPressTarget = !r.executor.GetOutputTarget().NeedsNodeBuild()
	// Supabase gating: only offer the defineTable tool when the project has an
	// enabled Supabase capability. The session-level dispatch in the tool loop
	// re-checks this as defense-in-depth.
	if pcaps := session.GetProjectCapabilities(); pcaps != nil && pcaps.Supabase != nil && pcaps.Supabase.Enabled {
		toolOpts.SupabaseEnabled = true
	}
	// GitHub gating: only offer the read-only gitLog tool when the project has
	// an enabled GitHub capability. The session-level dispatch in the tool loop
	// re-checks this as defense-in-depth.
	if pcaps := session.GetProjectCapabilities(); pcaps != nil && pcaps.Github != nil && pcaps.Github.Enabled {
		toolOpts.GithubEnabled = true
	}
	// webby-plugin-webagent gating: only register web tools when the project's
	// capability payload says the plugin is enabled. The Executor.Execute
	// dispatch also re-checks via WebExecutor.Caps as defense-in-depth.
	if pcaps := session.GetProjectCapabilities(); pcaps != nil && pcaps.WebAgent != nil && pcaps.WebAgent.Enabled {
		toolOpts.WebAgentEnabled = true

		// Resolve mode once; empty string defaults to "http". The tools.go
		// switch carries its own empty-string fallback as defense-in-depth,
		// but the canonical resolution happens here so resource wiring and
		// cleanup decisions stay consistent.
		mode := pcaps.WebAgent.FetchMode
		if mode == "" {
			mode = "http"
		}
		toolOpts.WebAgentFetchMode = mode
		needsBrowser := mode == "browser" || mode == "smart"

		// Wire Firecrawl ToolOptions so GetTools can register the
		// webFetchFirecrawl tool definition with the correct quota hint.
		toolOpts.FirecrawlEnabled = pcaps.WebAgent.FirecrawlEnabled
		toolOpts.FirecrawlRemainingPages = pcaps.WebAgent.FirecrawlRemainingPages

		// Cap payload values server-side. Laravel sends the configured
		// limits, but if X-Server-Key leaks an attacker could call /api/run
		// with arbitrary values. These ceilings come from spec section
		// "Hard limits (enforced by builder)".
		httpTimeout := pcaps.WebAgent.HTTPTimeoutSeconds
		if httpTimeout <= 0 || httpTimeout > 30 {
			httpTimeout = 15
		}
		maxRespMB := pcaps.WebAgent.MaxResponseSizeMB
		if maxRespMB <= 0 || maxRespMB > 10 {
			maxRespMB = 5
		}
		httpClient := scrape.NewClient(scrape.Config{
			TimeoutSeconds:    httpTimeout,
			MaxResponseSizeMB: maxRespMB,
		})

		var webExec *executor.WebExecutor
		if needsBrowser {
			if r.browserMgr != nil {
				webExec = executor.NewWebExecutor(httpClient, r.browserMgr, pcaps.WebAgent)
				r.executor.SetWebExecutor(webExec)
				r.executor.SetCurrentBuildSession(session.ID)
			} else {
				r.logger.Warn("WebAgent capability enabled but browserMgr is nil — web tools will return chrome_unavailable")
				// Still wire the HTTP tier so webFetchHttp works in "both" mode.
				webExec = executor.NewWebExecutor(httpClient, nil, pcaps.WebAgent)
				r.executor.SetWebExecutor(webExec)
				r.executor.SetCurrentBuildSession(session.ID)
			}
		} else {
			// http-only mode: no browser session is ever opened. Wire the
			// executor with a nil browser manager — the LLM never sees browser
			// tools to dispatch, and the HTTP tier needs no browser.
			webExec = executor.NewWebExecutor(httpClient, nil, pcaps.WebAgent)
			r.executor.SetWebExecutor(webExec)
			r.executor.SetCurrentBuildSession(session.ID)
		}

		// Initialise session-level Firecrawl state from the capability payload.
		// firecrawlRemaining is a copy of the pointer value from pcaps so that
		// HandleFirecrawl can decrement it in place without racing the session.
		if pcaps.WebAgent.FirecrawlEnabled && pcaps.WebAgent.FirecrawlAPIKey != "" {
			// Deep-copy the remaining-pages value so the executor's in-place
			// decrement doesn't mutate the original capability pointer.
			var remaining *int
			if pcaps.WebAgent.FirecrawlRemainingPages != nil {
				v := *pcaps.WebAgent.FirecrawlRemainingPages
				remaining = &v
			}
			session.firecrawlRemaining = remaining
			session.firecrawlCache = make(map[string]*scrape.FirecrawlResult)

			// Build the Firecrawl HTTP client and inject it into the executor.
			firecrawlTimeout := time.Duration(httpTimeout) * time.Second
			fc := scrape.NewFirecrawlClient(pcaps.WebAgent.FirecrawlAPIKey, firecrawlTimeout)
			webExec.SetFirecrawlClient(fc)

			// Allocate a shared SessionState that the WebExecutor will mutate
			// in place. We keep a reference so reconcileFirecrawl can read the
			// final counters after the agent loop ends.
			fcState := &executor.SessionState{
				FirecrawlRemaining: session.firecrawlRemaining,
				FirecrawlPagesUsed: 0,
				FirecrawlCallCount: 0,
				FirecrawlCache:     session.firecrawlCache,
			}
			webExec.SetSessionState(fcState)
			// Store a back-reference on session so reconcileFirecrawl can
			// read the live pagesUsed value after the loop ends.
			session.firecrawlState = fcState

			r.logger.WithFields(logrus.Fields{
				"session_id":          session.ID,
				"firecrawl_remaining": remaining,
			}).Info("Firecrawl client wired for this build session")
		}
	}
	tools := GetTools(toolOpts)

	// End-of-session Firecrawl reconciliation: post accumulated page usage
	// back to Laravel for monthly quota tracking. Best-effort — does not fail
	// the build. Use context.Background() so this runs even when ctx is done.
	defer r.reconcileFirecrawl(context.Background(), session)

	// Cleanup any browser session at end of build. Only defer when a browser
	// session could actually have been opened — in http-only mode no browser
	// is ever wired, so the cleanup call would be wasted work and misleading.
	if toolOpts.WebAgentEnabled && r.browserMgr != nil &&
		(toolOpts.WebAgentFetchMode == "browser" || toolOpts.WebAgentFetchMode == "smart") {
		defer r.browserMgr.CleanupForBuild(session.ID)
	}

	// Send initial status
	streamer.SendStatus("running", "Let's build something awesome...")

	// Track last non-empty AI content for final message
	var lastAIContent string
	// Track whether the in-loop SendMessage has already streamed the current
	// lastAIContent. Used by the end-of-loop logic to avoid double-sending.
	// Reset to false whenever lastAIContent changes to a new value.
	var lastAIContentSent bool

	// Integration verification: track fix attempts and repeated errors to avoid infinite loop
	integrationFixAttempts := 0
	const maxIntegrationFixes = 2
	var lastIntegrationError string
	integrationFinalNotified := false // Flag to track if we've sent final notification about unresolved issues

	// Build verification gate: after integration resolves, run npm run build and
	// feed any compile errors back to the agent (same repair-loop mechanism as
	// the integration gate). Bounded so we never loop forever.
	buildFixAttempts := 0
	const maxBuildFixes = 3

	// Runtime smoke gate (browser-gated): after the build passes, render key
	// routes in a headless browser and feed any blank/NotFound/console-error
	// routes back to the agent. Bounded.
	smokeFixAttempts := 0
	const maxSmokeFixes = 2

	// Empty response nudge: when AI returns no content and no tool calls, nudge once for a final summary
	emptyResponseNudged := false

	// Set model-appropriate circuit breaker thresholds
	session.SetCircuitBreakerForModel(r.aiConfig.ProviderType, r.aiConfig.Model)
	circuitBreaker := session.GetCircuitBreaker()

	// Whether the template's KNOWLEDGE.md has been delivered to the agent.
	// Pre-selected templates inject it into the system prompt above; the
	// Automatic flow (agent picks via useTemplate mid-loop) injects it as a
	// message right after useTemplate succeeds — see the tool-result handling.
	templateKnowledgeInjected := templateKnowledge != ""
	// True once the design playbook has been injected (eagerly above, or via the
	// useTemplate hook below for the Automatic-template flow).
	designPlaybookInjected := designPlaybook != ""

	// Main agent loop - use local counter to allow continuation after reaching max_iterations
	// session.Iterations tracks cumulative count for billing/stats, but shouldn't block new runs
	iterationsRun := 0

	// Two-budget loop. `maxIterations` is the feature-work budget. When it is spent
	// while the agent is still mid-work, abandoning the build here would skip the
	// verification gates (they live in the done-branch) and ship a broken app — the
	// exact failure that broke complex builds. Instead we run up to `maxGateRepairs`
	// extra iterations as a bounded "finalization" tail whose only goal is a
	// compiling, fully-routed project, so the gates actually execute. A separate
	// authoritative build check after the loop guarantees we never report success
	// on a project that does not compile.
	const maxGateRepairs = 8
	hardCeiling := maxIterations + maxGateRepairs
	finalizing := false

	// Layer A: debounced in-flight type checking. esbuild validation (file.go) is
	// SYNTAX-ONLY, so type errors (e.g. wrong component props) stay invisible until
	// the production `tsc && vite build`. After every `typecheckEvery` write tools
	// we run `tsc --noEmit` and feed real type errors back so the agent fixes them
	// in context instead of blind at the end.
	writesSinceTypecheck := 0
	const typecheckEvery = 3

	// Idle-completion guard. A verbose model can keep emitting read-only/verify
	// tool calls (verifyBuild, listFiles, readFile) after the project is already
	// built and verified, never returning the zero-tool-call response that fires
	// the done-branch — so the loop spins all the way to hardCeiling. This is the
	// dominant failure mode for output targets whose completion gates are no-ops
	// (e.g. WordPress themes: the integration/build/smoke gates only run for the
	// React/Node target), but it can hit any chatty model. Once the build is
	// verified-good (WasBuildOK) we count consecutive iterations that make NO
	// writes; any write resets the counter (real work resumed). At
	// idleNudgeThreshold we inject one firm "you're done — stop and summarize"
	// message; at idleBreakThreshold we force-break into the same finalization
	// path the done-branch uses. Keying on WasBuildOK means pre-verify
	// exploration (listFiles/readFile before the first successful build) never
	// counts, so legitimate builds are never cut short.
	idleAfterVerify := 0
	idleNudged := false
	const idleNudgeThreshold = 3
	const idleBreakThreshold = 6

	for iterationsRun < hardCeiling {
		// Enter the finalization tail once the feature-work budget is spent.
		if !finalizing && iterationsRun >= maxIterations {
			finalizing = true
			r.logger.WithFields(logrus.Fields{
				"session_id": session.ID,
				"iteration":  iterationsRun,
			}).Info("Work budget reached — entering finalization phase")
			// Target-appropriate wrap-up. A WordPress theme has no npm build,
			// no src/pages and no src/routes.tsx — instructing the agent to run
			// `npm run build` or wire routes.tsx would push it to fabricate Node
			// scaffolding. WordPress themes are also the likeliest to reach this
			// ceiling (see the idle-completion guard note above), so the branch
			// matters. Mirrors the smoke/build/final-verify gates' target guard.
			finalizeMsg := "You've reached the build budget. Stop adding new features now. " +
				"Make the project compile — `npm run build` must pass with no TypeScript errors — " +
				"and ensure every page in src/pages is imported and routed in src/routes.tsx. " +
				"I'll verify the build before finishing."
			if !r.executor.GetOutputTarget().NeedsNodeBuild() {
				// Name exactly what the theme validator enforces (templates/index.html,
				// the style.css "Theme Name:" header, valid theme.json) so "I'll verify"
				// stays honest; the parts/patterns nudge is the agent's own completeness
				// duty, not a gate claim.
				finalizeMsg = "You've reached the build budget. Stop adding new features now. " +
					"Make the theme valid — templates/index.html must exist, style.css must " +
					"carry the \"Theme Name:\" header, and theme.json must be well-formed JSON. " +
					"Also make sure any parts and patterns your templates reference exist with " +
					"no leftover placeholders. I'll verify the theme before finishing."
			}
			messages = append(messages, models.Message{
				Role:    "user",
				Content: finalizeMsg,
			})
		}

		select {
		case <-ctx.Done():
			session.SetStatus(models.StatusCancelled)
			streamer.SendStatus("cancelled", "Agent cancelled")
			return ctx.Err()
		default:
		}

		iterationsRun++      // Track iterations for this run
		session.Iterations++ // Track cumulative iterations for billing

		// Send iteration_start event if granular events enabled
		if features.GranularEvents {
			streamer.SendIterationStart(iterationsRun, maxIterations)
		}

		// Log before AI call
		r.logger.WithFields(logrus.Fields{
			"session_id": session.ID,
			"iteration":  iterationsRun,
			"model":      r.aiConfig.Model,
			"messages":   len(messages),
		}).Debug("Calling AI provider")

		// Send thinking event at start to begin frontend timer
		streamer.SendThinking("", iterationsRun)

		// Call AI
		response, err := provider.Chat(ctx, messages, tools)
		if err != nil {
			// Check if this is a user-initiated cancellation (works for all providers)
			// errors.Is() traverses wrapped errors, so "openai error: context canceled" is detected
			if models.IsCancelled(err) {
				session.SetStatus(models.StatusCancelled)
				streamer.SendStatus("cancelled", "Build stopped")
				r.logger.WithFields(logrus.Fields{
					"session_id": session.ID,
					"iteration":  iterationsRun,
				}).Info("Session cancelled by user")
				return ctx.Err()
			}

			// Real error - handle as before
			session.SetStatus(models.StatusFailed)
			session.Error = err.Error()
			streamer.SendStatus("failed", "AI provider error: "+err.Error())
			streamer.SendError(errorData(session, r.aiConfig.Model, err.Error()))
			r.logger.WithFields(logrus.Fields{
				"session_id": session.ID,
				"iteration":  iterationsRun,
				"error":      err.Error(),
			}).Error("AI provider error")
			return fmt.Errorf("AI error: %w", err)
		}

		session.UpdateTokens(response.PromptTokens, response.CompletionTokens, response.TokensUsed)

		// Send token_usage event if granular events enabled
		if features.GranularEvents {
			prompt, completion, total, context := session.GetTokenStats()
			streamer.SendTokenUsage(prompt, completion, total, context)
		}

		// Credit enforcement checks
		if session.ShouldWarnCredits() {
			percentUsed, _ := session.CheckCredits()
			_, _, runTotal := session.GetRunTokenStats()
			creditData := models.CreditEventData{
				UsedTokens:       runTotal, // Per-run tokens
				RemainingCredits: session.GetRemainingCredits() - runTotal,
				PercentUsed:      percentUsed,
				Message:          "You have used 80% of your build credits for this session. The session will complete the current task and then stop.",
			}
			streamer.SendCreditWarning(creditData)
			r.logger.WithFields(logrus.Fields{
				"session_id":        session.ID,
				"percent_used":      percentUsed,
				"run_tokens_used":   runTotal,
				"total_tokens_used": session.TokensUsed,
			}).Warn("Credit warning threshold reached")
		}

		// Check if credits exceeded - graceful exit
		percentUsed, exceeded := session.CheckCredits()
		if exceeded {
			runPrompt, runCompletion, runTotal := session.GetRunTokenStats()
			creditData := models.CreditEventData{
				UsedTokens:       runTotal, // Per-run tokens
				RemainingCredits: 0,
				PercentUsed:      percentUsed,
				Message:          "Build credit limit reached. Session is stopping gracefully.",
			}
			streamer.SendCreditExceeded(creditData)
			streamer.SendStatus("credit_limit", "Build credit limit reached")
			r.logger.WithFields(logrus.Fields{
				"session_id":        session.ID,
				"run_tokens_used":   runTotal,
				"total_tokens_used": session.TokensUsed,
				"limit":             session.GetRemainingCredits(),
			}).Warn("Credit limit exceeded, stopping session")

			// Send completion with credit exceeded message
			hasChanges := r.executor.HasFileChanges() || session.GetFilesChanged()
			session.SetFilesChanged(hasChanges)
			session.SetStatus(models.StatusCompleted)
			completeData := models.CompleteData{
				Iterations:          iterationsRun,
				TokensUsed:          session.TokensUsed,
				FilesChanged:        hasChanges,
				Message:             "Build credit limit reached. Please upgrade your plan or wait for credits to reset.",
				BuildStatus:         session.GetBuildStatus(),
				BuildMessage:        session.GetBuildMessage(),
				BuildRequired:       session.IsBuildRequired(),
				PromptTokens:        session.PromptTokens,
				CompletionTokens:    session.CompletionTokens,
				RunTokensUsed:       runTotal,
				RunPromptTokens:     runPrompt,
				RunCompletionTokens: runCompletion,
				Model:               r.aiConfig.Model,
			}
			session.MarkCompleteSent()
			streamer.SendComplete(completeData)
			return nil
		}

		// Log AI response
		r.logger.WithFields(logrus.Fields{
			"session_id":  session.ID,
			"iteration":   iterationsRun,
			"tokens":      response.TokensUsed,
			"tool_calls":  len(response.ToolCalls),
			"stop_reason": response.StopReason,
		}).Debug("Received AI response")

		// Stream thinking/content and track last response
		if response.Content != "" {
			// Send thinking event
			streamer.SendThinking(response.Content, iterationsRun)
			lastAIContent = response.Content // Track for final message
			lastAIContentSent = false        // Defer streaming to end-of-loop

			// Note: we DO NOT stream long AI content as a persistent SendMessage
			// here. The text might be a "completion" summary, but the runner
			// still needs to run verifyIntegration as a post-loop sanity check.
			// If that check fails, we'd contradict the AI with "I found
			// integration issues..." right after their success message.
			//
			// Instead, we buffer lastAIContent and let the end-of-loop block
			// (around the SendComplete path below) emit it. By that point we
			// know whether the integration check passed and whether the loop
			// terminated cleanly. If the integration auto-check fires and
			// re-enters the loop, lastAIContent is cleared so we don't emit
			// a stale completion message later.

			// Log AI content (truncated)
			r.logger.WithFields(logrus.Fields{
				"session_id": session.ID,
				"content":    logging.TruncateForLog(response.Content, 500),
			}).Debug("AI response content")
		}

		// Check if done (no tool calls)
		if len(response.ToolCalls) == 0 {
			// If AI returned an empty response (no content, no tool calls), nudge it once
			// to provide a final summary. This happens when AI templates already satisfy the
			// goal and the AI has nothing to change but also doesn't send a closing message.
			if response.Content == "" && !emptyResponseNudged {
				emptyResponseNudged = true
				r.logger.WithFields(logrus.Fields{
					"session_id": session.ID,
					"iteration":  iterationsRun,
				}).Debug("AI returned empty response, nudging for final summary")

				messages = append(messages, models.Message{
					Role:    "user",
					Content: "Please provide a brief summary of what has been set up and what the user can do next. Describe the key features and pages included in the project.",
				})
				continue
			}

			// Before completing, scan src/pages/ for unregistered pages.
			// Skip if we've already sent final notification about unresolved issues.
			//
			// Also skip if the AI already ran verifyIntegration successfully in
			// this run. The AI knows which files it actually created/touched;
			// the runner's scanPageFiles() includes pre-existing template files
			// (e.g., About.tsx/Contact.tsx) that the AI may have intentionally
			// left out of routes.tsx, producing false-positive "I found
			// integration issues" contradictions to the AI's own success message.
			// Node/React targets only: scanPageFiles looks for src/pages/*.tsx and
			// the check verifies wiring into src/routes.tsx — neither exists in a
			// WordPress theme. Today it no-ops there (empty scan), but the guard
			// keeps it from misfiring on a hybrid theme and matches the smoke/
			// build/final-verify gates' target convention.
			if !integrationFinalNotified && integrationFixAttempts < maxIntegrationFixes && !session.WasVerifyIntegrationOK() && r.executor.GetOutputTarget().NeedsNodeBuild() {
				// Filter scanPageFiles to only files the agent actually wrote
				// in this session. Pre-existing template defaults that the AI
				// never touched are NOT the agent's responsibility to integrate
				// — flagging them produced false-positive "I found integration
				// issues" contradictions for prompts that intentionally use
				// only a subset of template pages.
				allPageFiles := scanPageFiles(r.executor.GetWorkspacePath())
				pageFiles := filterToTouched(allPageFiles, session)
				if len(pageFiles) > 0 {
					streamer.SendStatus("verifying", "Checking page integration...")

					filesArg := make([]interface{}, len(pageFiles))
					for i, p := range pageFiles {
						filesArg[i] = p
					}

					verifyResult, err := r.executor.Execute(ctx, "verifyIntegration", map[string]interface{}{
						"files": filesArg,
					})
					if err == nil && !verifyResult.Success {
						// Check if this is the same error as last time (avoid infinite loop on same issue)
						currentError := verifyResult.Content
						if currentError == lastIntegrationError {
							r.logger.WithFields(logrus.Fields{
								"session_id": session.ID,
							}).Warn("Same integration error twice in a row, giving up on auto-fix")

							// Notify AI about unresolved issues before completing
							finalMsg := fmt.Sprintf(
								"⚠️ INTEGRATION ISSUES REMAIN (same error repeated):\n\n%s\n\nPlease inform the user about these unresolved integration issues in your final response.",
								currentError,
							)
							messages = append(messages, models.Message{
								Role:    "system",
								Content: finalMsg,
							})
							integrationFinalNotified = true
							continue // Let AI respond about the issues
						}
						lastIntegrationError = currentError

						integrationFixAttempts++
						if integrationFixAttempts >= maxIntegrationFixes {
							r.logger.WithFields(logrus.Fields{
								"session_id": session.ID,
								"attempt":    integrationFixAttempts,
							}).Warn("Max integration fix attempts reached")

							// Notify AI about unresolved issues before completing
							finalMsg := fmt.Sprintf(
								"⚠️ INTEGRATION ISSUES REMAIN after %d fix attempts:\n\n%s\n\nPlease inform the user about these unresolved integration issues in your final response.",
								integrationFixAttempts, verifyResult.Content,
							)
							messages = append(messages, models.Message{
								Role:    "system",
								Content: finalMsg,
							})
							integrationFinalNotified = true
							continue // Let AI respond about the issues
						}

						r.logger.WithFields(logrus.Fields{
							"session_id": session.ID,
							"attempt":    integrationFixAttempts,
						}).Info("Integration issues found, re-entering loop to fix")

						fixPrompt := fmt.Sprintf(
							"⚠️ INTEGRATION ISSUES DETECTED:\n\n%s\n\nYou MUST fix these integration issues before completing the task. Edit src/routes.tsx to import and register all pages. Do NOT tell the user the task is complete until all pages are routed.",
							verifyResult.Content,
						)
						messages = append(messages, models.Message{
							Role:    "user",
							Content: fixPrompt,
						})

						streamer.SendMessage("I found some integration issues. Let me fix those...")
						streamer.SendStatus("fixing", "Fixing integration issues...")
						// Clear the buffered "completion" content so the end-of-loop
						// block doesn't later emit a stale success summary that
						// contradicts the integration-issues message we just sent.
						// The AI's next iteration will produce a fresh summary.
						lastAIContent = ""
						lastAIContentSent = false
						continue
					}
				}
			}

			// Build-verify gate: the integration gate above has resolved (OK or
			// exhausted). Before completing, ensure the project actually compiles
			// (npm run build). If it doesn't, feed the compile errors back to the
			// agent using the SAME mechanism the integration gate uses (append a
			// message + continue), so the agent edits and we re-check. Bounded by
			// maxBuildFixes so we never loop forever.
			if !session.WasBuildOK() && buildFixAttempts < maxBuildFixes && workspaceHasPackageJSON(r.executor.GetWorkspacePath()) {
				streamer.SendStatus("verifying", "Verifying the build compiles...")
				bres, _ := r.executor.Execute(ctx, "verifyBuild", map[string]interface{}{})
				if bres != nil && !bres.Success {
					buildFixAttempts++
					session.SetBuildOK(false)
					msg := fmt.Sprintf("⚠️ BUILD FAILED — the app does not compile (npm run build):\n\n%s\n\nFix every error above. The project MUST compile before you finish.", bres.Content)
					if buildFixAttempts >= maxBuildFixes {
						msg = fmt.Sprintf("⚠️ BUILD STILL FAILS after %d attempts:\n\n%s\n\nMake a final fix attempt; if it cannot compile, tell the user in your final response.", buildFixAttempts, bres.Content)
					}
					r.logger.WithFields(logrus.Fields{
						"session_id": session.ID,
						"attempt":    buildFixAttempts,
					}).Warn("Build gate: build failed, re-entering loop to fix")
					messages = append(messages, models.Message{
						Role:    "user",
						Content: msg,
					})
					streamer.SendMessage("The build didn't compile. Let me fix that...")
					streamer.SendStatus("fixing", "Fixing build errors...")
					// Clear buffered "completion" content so the end-of-loop block
					// doesn't emit a stale success summary contradicting the
					// build-failure message we just injected.
					lastAIContent = ""
					lastAIContentSent = false
					continue
				}
				// Build succeeded (or executor returned no result, treated as not
				// blocking — mirrors the integration gate's tolerance of nil).
				session.SetBuildOK(true)
			}

			// Runtime smoke gate (browser-gated): only when the WebAgent browser
			// capability is enabled for this project. After a passing build, render
			// each static route in a headless browser and feed back any route that
			// renders blank, resolves to NotFound, or throws a console error. If the
			// browser can't start, we SKIP (treat as pass) — the gate never blocks.
			//
			// Node/React targets ONLY: the gate renders dist/index.html against the
			// routes parsed from src/routes.tsx. A WordPress block theme has neither
			// a dist/ nor src/routes.tsx, so without the NeedsNodeBuild() guard the
			// smoke test loads a non-existent dist page, sees a blank render, and
			// reports bogus "runtime issues" that send the agent into a needless
			// fix loop on every WordPress build. Mirrors the build/memory gates,
			// which already exclude non-Node targets.
			if pcaps := session.GetProjectCapabilities(); pcaps != nil && pcaps.WebAgent != nil && pcaps.WebAgent.Enabled && smokeFixAttempts < maxSmokeFixes && r.executor.GetOutputTarget().NeedsNodeBuild() {
				workspacePath := r.executor.GetWorkspacePath()
				distDir := filepath.Join(workspacePath, "dist")
				routesSrc, _ := os.ReadFile(filepath.Join(workspacePath, "src", "routes.tsx"))
				base := readBaseHref(distDir)
				streamer.SendStatus("verifying", "Checking pages render in a browser...")
				// Inject the same client-safe runtime config the live preview serves,
				// so config-dependent (e.g. Supabase) apps boot instead of rendering
				// blank under the test and producing false "all pages blank" failures.
				appConfigJSON := buildSmokeAppConfig(session.GetProjectCapabilities())
				issues, serr := smoke.Smoke(ctx, distDir, base, smoke.ParseRoutePaths(string(routesSrc)), appConfigJSON)
				if serr != nil {
					// Browser unavailable or smoke setup failed — skip (= pass).
					r.logger.WithError(serr).Warn("Smoke gate skipped (browser unavailable)")
				} else if len(issues) > 0 {
					smokeFixAttempts++
					var b strings.Builder
					for _, is := range issues {
						fmt.Fprintf(&b, "- %s: %s\n", is.Route, is.Problem)
					}
					msg := fmt.Sprintf("⚠️ RUNTIME ISSUES — these routes do not render correctly when loaded in a browser:\n\n%s\nFix them so each page renders without errors.", b.String())
					r.logger.WithFields(logrus.Fields{
						"session_id": session.ID,
						"attempt":    smokeFixAttempts,
						"issues":     len(issues),
					}).Warn("Smoke gate: runtime issues found, re-entering loop to fix")
					messages = append(messages, models.Message{
						Role:    "user",
						Content: msg,
					})
					streamer.SendMessage("Some pages didn't render correctly. Let me fix that...")
					streamer.SendStatus("fixing", "Fixing runtime issues...")
					// Edits below will invalidate the build flag, so the build gate
					// re-runs on the next pass before we smoke-test again.
					lastAIContent = ""
					lastAIContentSent = false
					continue
				}
			}

			// No integration issues (or max fix attempts reached), we're done
			streamer.SendStatus("completing", "Almost there...")
			break
		}

		// Build batch of tool call requests and stream events
		batchRequests := make([]executor.ToolCallRequest, 0, len(response.ToolCalls))

		// Pre-computed results for session-level tools (not handled by executor)
		sessionToolResults := make(map[string]executor.ToolResult)

		for _, toolCall := range response.ToolCalls {
			// Handle getProjectCapabilities specially (needs session access)
			if toolCall.Name == "getProjectCapabilities" {
				caps := session.GetProjectCapabilities()
				var content string
				if caps == nil {
					content = `{"supabase":{"enabled":false,"url":"","publishable_key":"","schema":""},"storage":{"enabled":false,"max_file_size_mb":0},"message":"No project capabilities configured."}`
				} else {
					content = formatProjectCapabilities(caps)
				}

				// Log session-level tool execution
				r.logger.WithFields(logrus.Fields{
					"session_id":  session.ID,
					"tool":        toolCall.Name,
					"caps_is_nil": caps == nil,
					"content":     content,
				}).Debug("Session-level tool: getProjectCapabilities")

				sessionToolResults[toolCall.ID] = executor.ToolResult{
					ToolCallID: toolCall.ID,
					Success:    true,
					Content:    content,
				}
				// Still stream the tool call event
				streamer.SendToolCall(toolCall.ID, toolCall.Name, toolCall.Arguments)
				action, target, category := getActionDescription(toolCall.Name, toolCall.Arguments)
				streamer.SendAction(action, target, "", category)
				// Stream the result immediately
				streamer.SendToolResult(toolCall.ID, toolCall.Name, true, content, 0, iterationsRun)
				continue
			}

			// Handle defineTable specially: it proxies to Laravel (which authors
			// and executes the safe SQL + RLS) using the builder session id. The
			// agent never writes SQL. Result is returned to the agent so it can
			// react to validation errors. NEVER expose secret_key/db_connection.
			if toolCall.Name == "defineTable" {
				content := r.executeDefineTable(session, toolCall.Arguments)
				sessionToolResults[toolCall.ID] = executor.ToolResult{
					ToolCallID: toolCall.ID,
					Success:    true,
					Content:    content,
				}
				streamer.SendToolCall(toolCall.ID, toolCall.Name, toolCall.Arguments)
				action, target, category := getActionDescription(toolCall.Name, toolCall.Arguments)
				streamer.SendAction(action, target, "", category)
				streamer.SendToolResult(toolCall.ID, toolCall.Name, true, content, 0, iterationsRun)
				continue
			}

			// Handle gitLog specially: read-only history awareness for the linked
			// repo. Registered only when the GitHub capability is enabled. Errors
			// degrade gracefully to an empty commit list so the agent can still
			// answer ("no history yet") rather than failing the turn.
			if toolCall.Name == "gitLog" {
				branch := "main"
				if caps := session.GetProjectCapabilities(); caps != nil && caps.Github != nil && caps.Github.DefaultBranch != "" {
					branch = caps.Github.DefaultBranch
				}
				content := `{"commits":""}`
				repo := &gitpkg.Repo{Dir: r.executor.GetWorkspacePath(), Branch: branch}
				if log, err := repo.Log(context.Background(), 20); err == nil {
					content = fmt.Sprintf(`{"commits":%q}`, log)
				}
				sessionToolResults[toolCall.ID] = executor.ToolResult{
					ToolCallID: toolCall.ID,
					Success:    true,
					Content:    content,
				}
				streamer.SendToolCall(toolCall.ID, toolCall.Name, toolCall.Arguments)
				action, target, category := getActionDescription(toolCall.Name, toolCall.Arguments)
				streamer.SendAction(action, target, "", category)
				streamer.SendToolResult(toolCall.ID, toolCall.Name, true, content, 0, iterationsRun)
				continue
			}

			// Handle pushToGithub specially: a WRITE tool the agent invokes only
			// on explicit user request (auto-push-off mode). Commits + pushes the
			// current workspace to the linked repo via the shared PushGithubNow
			// core, reusing the AI commit-message generator.
			if toolCall.Name == "pushToGithub" {
				ok := false
				summary := "GitHub is not enabled for this project."
				if caps := session.GetProjectCapabilities(); caps != nil && caps.Github != nil && caps.Github.Enabled {
					laravelURL := session.GetLaravelURL()
					serverKey := r.executor.GetServerKey()
					if laravelURL == "" || serverKey == "" {
						summary = "GitHub service is not reachable."
					} else {
						lc := laravel.NewClient(laravelURL, serverKey)
						msgGen := func(mctx context.Context, diffStat string) string { return r.githubCommitMessage(mctx, diffStat, goal) }
						pctx, pcancel := context.WithTimeout(context.Background(), 60*time.Second)
						ok, summary = PushGithubNow(pctx, r.executor.GetWorkspacePath(), session.ID, goal, caps.Github, lc, streamer, msgGen)
						pcancel()
					}
				}
				content := fmt.Sprintf(`{"ok":%t,"result":%q}`, ok, summary)
				sessionToolResults[toolCall.ID] = executor.ToolResult{ToolCallID: toolCall.ID, Success: true, Content: content}
				streamer.SendToolCall(toolCall.ID, toolCall.Name, toolCall.Arguments)
				action, target, category := getActionDescription(toolCall.Name, toolCall.Arguments)
				streamer.SendAction(action, target, "", category)
				streamer.SendToolResult(toolCall.ID, toolCall.Name, true, content, 0, iterationsRun)
				continue
			}

			// Pre-flight: editFile requires the file to have been read first.
			// This blocks the AI from hallucinating file contents and burning
			// circuit-breaker budget on edits whose search strings don't exist.
			// readFile/createFile/successful editFile all mark a file as read
			// (see the per-result loop further below).
			if toolCall.Name == "editFile" {
				if path, _ := toolCall.Arguments["path"].(string); path != "" && !session.HasReadFile(path) {
					errMsg := fmt.Sprintf(
						"Cannot edit %s — you have not read this file in this session. "+
							"Call readFile(%q) first to see its current content, then make your edit based on the actual file. "+
							"Do not guess what the file contains.",
						path, path,
					)
					sessionToolResults[toolCall.ID] = executor.ToolResult{
						ToolCallID: toolCall.ID,
						Success:    false,
						Content:    errMsg,
					}
					// Stream the attempted call so the user sees what happened
					streamer.SendToolCall(toolCall.ID, toolCall.Name, toolCall.Arguments)
					action, target, category := getActionDescription(toolCall.Name, toolCall.Arguments)
					streamer.SendAction(action, target, "", category)
					streamer.SendToolResult(toolCall.ID, toolCall.Name, false, errMsg, 0, iterationsRun)
					r.logger.WithFields(logrus.Fields{
						"session_id": session.ID,
						"path":       path,
					}).Debug("Read-before-edit guard: blocked editFile on unread file")
					continue
				}
			}

			// Stream tool call event
			streamer.SendToolCall(toolCall.ID, toolCall.Name, toolCall.Arguments)

			// Special handling for createPlan - send structured plan event
			if toolCall.Name == "createPlan" {
				summary, _ := toolCall.Arguments["summary"].(string)
				stepsArray, _ := toolCall.Arguments["steps"].([]interface{})

				var steps []models.PlanStep
				for _, stepInterface := range stepsArray {
					if step, ok := stepInterface.(map[string]interface{}); ok {
						// Use comma-ok assertions: the model may omit fields or
						// send null, in which case an unchecked .(string) panics.
						file, _ := step["file"].(string)
						action, _ := step["action"].(string)
						description, _ := step["description"].(string)
						steps = append(steps, models.PlanStep{
							File:        file,
							Action:      action,
							Description: description,
						})
					}
				}

				var deps, risks []string
				if depsRaw, ok := toolCall.Arguments["dependencies"].([]interface{}); ok {
					for _, dep := range depsRaw {
						if s, ok := dep.(string); ok {
							deps = append(deps, s)
						}
					}
				}
				if risksRaw, ok := toolCall.Arguments["risks"].([]interface{}); ok {
					for _, risk := range risksRaw {
						if r, ok := risk.(string); ok {
							risks = append(risks, r)
						}
					}
				}

				streamer.SendPlan(summary, steps, deps, risks)
			}

			// Send human-friendly action event
			action, target, category := getActionDescription(toolCall.Name, toolCall.Arguments)
			streamer.SendAction(action, target, "", category)

			// Log tool arguments
			r.logger.WithFields(logrus.Fields{
				"session_id": session.ID,
				"tool":       toolCall.Name,
				"args":       logging.FormatArgsForLog(toolCall.Arguments, 200),
			}).Debug("Tool arguments")

			// Add to batch
			batchRequests = append(batchRequests, executor.ToolCallRequest{
				ID:        toolCall.ID,
				Name:      toolCall.Name,
				Arguments: toolCall.Arguments,
			})
		}

		// Log batch execution start
		readOnlyCount := 0
		writeCount := 0
		for _, req := range batchRequests {
			if executor.IsReadOnly(req.Name) {
				readOnlyCount++
			} else {
				writeCount++
			}
		}
		r.logger.WithFields(logrus.Fields{
			"session_id":      session.ID,
			"total_tools":     len(batchRequests),
			"read_only_tools": readOnlyCount,
			"write_tools":     writeCount,
			"parallel":        features.ParallelTools,
		}).Debug("Executing tool batch")

		// Execute tools (batch/parallel or sequential based on feature flag)
		var toolResults []executor.ToolResult
		if features.ParallelTools {
			// Parallel execution for read-only tools, sequential for write tools
			toolResults = r.executor.ExecuteBatch(ctx, batchRequests)
		} else {
			// Sequential execution for all tools
			toolResults = make([]executor.ToolResult, len(batchRequests))
			for i, req := range batchRequests {
				result, err := r.executor.Execute(ctx, req.Name, req.Arguments)
				if err != nil {
					toolResults[i] = executor.ToolResult{
						ToolCallID: req.ID,
						Success:    false,
						Content:    fmt.Sprintf("Error: %s", err.Error()),
					}
				} else {
					result.ToolCallID = req.ID
					toolResults[i] = *result
				}
			}
		}

		// Stream results in original order.
		// Index batchRequests (NOT response.ToolCalls): session-level tools
		// (getProjectCapabilities/defineTable) and pre-flight-blocked editFiles
		// `continue` out of the batch-building loop into sessionToolResults, so
		// response.ToolCalls can be longer than toolResults/batchRequests and
		// indexing it here would misattribute the tool name (and silently break
		// write detection, file tracking, and build-flag sync below).
		for i, result := range toolResults {
			toolName := batchRequests[i].Name

			// Log tool result
			r.logger.WithFields(logrus.Fields{
				"session_id": session.ID,
				"tool":       toolName,
				"success":    result.Success,
			}).Debug("Tool completed")

			// Stream tool result
			streamer.SendToolResult(
				result.ToolCallID,
				toolName,
				result.Success,
				truncateOutput(result.Content, 1500),
				result.DurationMs,
				iterationsRun,
			)

			// Track build results from verifyBuild calls
			if toolName == "verifyBuild" {
				success := result.Success
				var message string
				if !success {
					message = result.Content
				} else {
					message = "Build succeeded"
				}
				session.SetBuildResult(success, message)
				// Sync the gate-skip flag: a successful agent-run verifyBuild means
				// the build is currently OK (a later write resets it via SetBuildOK
				// (false)), so the done-branch gate and the post-loop net can skip a
				// redundant rebuild. A failure marks it not-OK so the gate re-checks.
				session.SetBuildOK(success)
			}

			// Track verifyIntegration results so the post-loop auto-check can
			// defer to the AI when it has already validated. The auto-check
			// uses scanPageFiles() which includes pre-existing template files
			// and was producing false-positive contradictions with the AI's
			// own success message.
			if toolName == "verifyIntegration" {
				session.MarkVerifyIntegrationResult(result.Success)
			}
		}

		// Circuit breaker: detect repeated failures and prevent infinite loops.
		// batchHadWrite tracks whether this iteration made any successful write
		// (create/edit/delete) — used by the idle-completion guard at loop end.
		var shouldExit bool
		batchHadWrite := false
		for i, result := range toolResults {
			// Index batchRequests, not response.ToolCalls — see note above; the
			// two diverge when session-level/blocked tools are present.
			toolName := batchRequests[i].Name
			filePath, _ := batchRequests[i].Arguments["path"].(string)
			if !result.Success {
				// Skip circuit-breaker accounting for known-benign controlled
				// failures — these are deliberate executor-side guardrails, not
				// agent mistakes:
				//   - Duplicate-path edits within one batch (executor only runs
				//     the first; the others are intentionally skipped with a
				//     message telling the AI to serialize or use batchEditFiles)
				if strings.Contains(result.Content, "Another edit to this file was already executed in this batch") {
					continue
				}
				action := circuitBreaker.RecordFileFailure(toolName, filePath, result.Content)
				switch action {
				case ActionGracefulExit:
					// Too many failures - stop the session gracefully
					r.logger.WithFields(logrus.Fields{
						"session_id": session.ID,
						"tool":       toolName,
						"stats":      circuitBreaker.GetStats(),
					}).Warn("Circuit breaker: forcing graceful exit")
					shouldExit = true

				case ActionInjectGuidance:
					// Inject recovery guidance. For editFile failures we also
					// fetch the actual current file contents and append them to
					// the guidance so the AI can see exactly what's there
					// instead of guessing — this addresses the hallucinated
					// search-string failure mode observed in E2E testing.
					guidance := getToolRecoveryGuidance(toolName)
					// Append error-content-specific guidance when the failure can
					// be classified — far more actionable than generic per-tool text.
					if kind := ClassifyFailure(toolName, result.Content); kind != FailureUnknown {
						if hint := RecoveryHint(kind); hint != "" {
							guidance += "\n\n" + hint
						}
					}
					if toolName == "editFile" && filePath != "" {
						readResult, readErr := r.executor.Execute(ctx, "readFile", map[string]interface{}{
							"path": filePath,
						})
						if readErr == nil && readResult != nil && readResult.Success {
							guidance += fmt.Sprintf(
								"\n\nCURRENT CONTENT of %s (truncated to 4000 chars):\n```\n%s\n```\n\nUse the EXACT text from above as your search string.",
								filePath, truncateOutput(readResult.Content, 4000),
							)
						}
					}
					circuitMsg := fmt.Sprintf(
						"⚠️ SYSTEM: %s has failed repeatedly.\n\n%s",
						toolName, guidance,
					)
					messages = append(messages, models.Message{
						Role:    "system",
						Content: circuitMsg,
					})
					r.logger.WithFields(logrus.Fields{
						"session_id": session.ID,
						"tool":       toolName,
						"stats":      circuitBreaker.GetStats(),
					}).Warn("Circuit breaker: injected recovery guidance")
				}
			} else {
				circuitBreaker.RecordFileSuccess(toolName, filePath)
				// Mark the file as "seen" so subsequent editFile calls on it
				// pass the read-before-edit pre-flight check. readFile is the
				// canonical case; createFile and successful editFile both
				// imply the agent has authoritative knowledge of the contents.
				if filePath != "" && (toolName == "readFile" || toolName == "createFile" || toolName == "editFile") {
					session.MarkFileRead(filePath)
				}
				// Track files the agent has WRITTEN (not just read) for the
				// post-loop integration auto-check. Only createFile and editFile
				// count — readFile is read-only. The auto-check uses the touched
				// set to filter scanPageFiles output, so pre-existing template
				// files aren't flagged as "unimported".
				if filePath != "" && (toolName == "createFile" || toolName == "editFile") {
					session.MarkFileTouched(filePath)
				}
				// On successful deleteFile, stop tracking the file so the
				// auto-check doesn't try to verify a non-existent file.
				if filePath != "" && toolName == "deleteFile" {
					session.UnmarkFileTouched(filePath)
				}
				// Any successful write/edit/delete invalidates a previously
				// passing build gate — the build must be re-verified before the
				// session can complete. batchEditFiles has no single path arg but
				// is still a write, so check by tool name regardless of filePath.
				if toolName == "createFile" || toolName == "editFile" || toolName == "deleteFile" || toolName == "batchEditFiles" {
					session.SetBuildOK(false)
					writesSinceTypecheck++
					batchHadWrite = true
				}
			}
		}

		// Handle graceful exit due to circuit breaker
		if shouldExit {
			hasChanges := r.executor.HasFileChanges() || session.GetFilesChanged()
			session.SetFilesChanged(hasChanges)
			session.SetStatus(models.StatusCompleted)
			streamer.SendStatus("circuit_breaker", "Too many failures detected, stopping gracefully")
			streamer.SendMessage("I encountered repeated errors and couldn't complete the task. Please try again with a simpler request or review the changes made so far.")

			cbRunPrompt, cbRunCompletion, cbRunTotal := session.GetRunTokenStats()
			completeData := models.CompleteData{
				Iterations:          iterationsRun,
				TokensUsed:          session.TokensUsed,
				FilesChanged:        hasChanges,
				Message:             "Session stopped due to repeated errors. Some changes may have been made - please review.",
				BuildStatus:         session.GetBuildStatus(),
				BuildMessage:        session.GetBuildMessage(),
				BuildRequired:       session.IsBuildRequired(),
				PromptTokens:        session.PromptTokens,
				CompletionTokens:    session.CompletionTokens,
				RunTokensUsed:       cbRunTotal,
				RunPromptTokens:     cbRunPrompt,
				RunCompletionTokens: cbRunCompletion,
				Model:               r.aiConfig.Model,
			}
			session.MarkCompleteSent()
			streamer.SendComplete(completeData)
			return nil
		}

		// Add assistant message with tool calls
		messages = append(messages, models.Message{
			Role:      "assistant",
			Content:   response.Content,
			ToolCalls: response.ToolCalls,
		})

		// Add tool results as separate messages (truncated to save context window)

		// First add session-level tool results (e.g., getProjectCapabilities)
		for toolCallID, result := range sessionToolResults {
			messages = append(messages, models.Message{
				Role:       "tool",
				Content:    result.Content,
				ToolCallID: toolCallID,
			})
		}

		// Then add executor tool results
		for i, result := range toolResults {
			toolName := batchRequests[i].Name
			maxLen := maxResultForTool(toolName)
			content := result.Content
			if len(content) > maxLen {
				half := maxLen / 2
				content = content[:half] +
					fmt.Sprintf("\n\n... (%d chars truncated) ...\n\n", len(result.Content)-maxLen) +
					content[len(content)-half+50:]
			}
			messages = append(messages, models.Message{
				Role:       "tool",
				Content:    content,
				ToolCallID: result.ToolCallID,
			})
		}

		// When the agent selects a template mid-loop via useTemplate (the
		// Automatic flow), the template's KNOWLEDGE.md was not in the workspace
		// when the system prompt was built. Inject it once, now, so the agent
		// gets the template's design/logic guidance in every flow.
		if !templateKnowledgeInjected {
			for i, result := range toolResults {
				if batchRequests[i].Name != "useTemplate" || !result.Success {
					continue
				}
				if kn := r.loadTemplateKnowledge(r.executor.GetWorkspacePath()); kn != "" {
					messages = append(messages, models.Message{
						Role:    "user",
						Content: "## Template Knowledge (authored for the selected template — authoritative)\n\n" + kn,
					})
				}
				templateKnowledgeInjected = true
				break
			}
		}

		// Inject the design-system playbook once the Automatic-template flow has
		// run useTemplate (the executor overlaid the system + captured DESIGN.md).
		if !designPlaybookInjected {
			for i, result := range toolResults {
				if batchRequests[i].Name != "useTemplate" || !result.Success {
					continue
				}
				if pb := r.executor.GetDesignPlaybook(); pb != "" {
					messages = append(messages, models.Message{
						Role:    "user",
						Content: "## Design System (authoritative — build on this):\n\n" + pb,
					})
				}
				designPlaybookInjected = true
				break
			}
		}

		// Layer A: debounced in-flight type check. Once enough writes have
		// accumulated, run a whole-program `tsc --noEmit` and surface real type
		// errors so the agent fixes them in context (esbuild only caught syntax).
		// Transient import-resolution errors (a module created a moment from now)
		// are filtered out here — the end-of-loop build gate catches everything;
		// this is early warning for stable type errors like wrong props.
		if writesSinceTypecheck >= typecheckEvery {
			writesSinceTypecheck = 0
			if workspaceHasPackageJSON(r.executor.GetWorkspacePath()) {
				if tcres, _ := r.executor.TypeCheck(ctx); !tcres.OK {
					// Only inject when there is a genuine, recognizable type error.
					// This skips tsc's non-type failures (malformed tsconfig, runner
					// noise) whose raw output would otherwise confuse the agent — the
					// authoritative end-of-loop build gate still catches those.
					if stable := filterStableTypeErrors(tcres.Errors); strings.Contains(stable, "error TS") {
						r.logger.WithFields(logrus.Fields{
							"session_id": session.ID,
							"iteration":  iterationsRun,
						}).Info("In-flight typecheck found type errors, injecting feedback")
						messages = append(messages, models.Message{
							Role: "user",
							Content: "⚠️ TYPE ERRORS — these will fail the production build (`tsc`):\n\n" +
								stable +
								"\n\nFix them as you go. (If any refer to files you haven't created yet, keep building; otherwise correct them now.)",
						})
						streamer.SendMessage("I spotted some type errors. Let me address those...")
					}
				}
			}
		}

		// Auto-compaction: check if context is getting too large (if enabled)
		contextTokens := summarizer.EstimateConversationTokens(messages)
		session.SetContextTokens(contextTokens)

		if features.AutoCompaction {
			contextWindow := r.aiConfig.ContextWindow
			if contextWindow == 0 {
				contextWindow = 128000 // Default
			}
			threshold := r.aiConfig.CompactionThreshold
			if threshold == 0 {
				threshold = 0.7 // Default: 70%
			}

			thresholdTokens := int(float64(contextWindow) * threshold)
			if contextTokens > thresholdTokens {
				r.logger.WithFields(logrus.Fields{
					"session_id":     session.ID,
					"context_tokens": contextTokens,
					"threshold":      thresholdTokens,
				}).Debug("Context threshold reached, triggering compaction")

				streamer.SendStatus("compacting", "Compressing history...")

				// Convert messages to turns for summarization
				turns := make([]summarizer.Turn, 0, len(messages))
				for _, msg := range messages {
					if msg.Role == "system" {
						continue // Skip system messages
					}
					turns = append(turns, summarizer.Turn{
						Role:    msg.Role,
						Content: msg.Content,
					})
				}

				// Process with summarization
				state, err := r.summarizer.Process(ctx, turns)
				if err == nil && state.Summary != "" {
					// Track auto-compaction tokens for credit depletion
					if state.TotalTokens > 0 {
						session.UpdateTokens(state.PromptTokens, state.CompletionTokens, state.TotalTokens)
						r.logger.WithFields(logrus.Fields{
							"session_id":            session.ID,
							"summarizer_prompt":     state.PromptTokens,
							"summarizer_completion": state.CompletionTokens,
							"summarizer_total":      state.TotalTokens,
						}).Debug("Added auto-compaction tokens to session")
					}

					// Build compacted history for Laravel
					var compactedHistory []models.HistoryMessage
					compactedHistory = append(compactedHistory, models.HistoryMessage{
						Role:    "assistant",
						Content: "[Previous conversation summary]\n" + state.Summary,
					})

					// Rebuild messages with summary
					// Re-load template prompts in case the AI selected a template during the run
					compactTemplatePrompts := r.loadTemplatePrompts(r.executor.GetWorkspacePath())
					compactCfg := PromptConfig{
						ProjectName:     projectNameOrDefault(session.GetProjectName()),
						WorkspacePath:   r.executor.GetWorkspacePath(),
						TemplatePrompts: compactTemplatePrompts,
						Capabilities:    session.GetProjectCapabilities(),
						ThemePreset:     session.GetThemePreset(),
						OutputType:      session.GetOutputType(),
					}
					systemPrompt, promptErr := BuildSystemPrompt(compactCfg)
					if promptErr != nil {
						r.logger.WithField("error", promptErr.Error()).Warn("Failed to build compact prompt during compaction, using current prompt")
						break
					}
					newMessages := []models.Message{
						{Role: "system", Content: systemPrompt},
						{Role: "system", Content: "CONVERSATION HISTORY:\n" + state.Summary},
					}

					// Add recent turns back
					for _, t := range state.RecentTurns {
						compactedHistory = append(compactedHistory, models.HistoryMessage{
							Role:    t.Role,
							Content: t.Content,
						})
						newMessages = append(newMessages, models.Message{
							Role:    t.Role,
							Content: t.Content,
						})
					}

					messages = newMessages
					newContextTokens := summarizer.EstimateConversationTokens(messages)
					session.SetContextTokens(newContextTokens)

					r.logger.WithFields(logrus.Fields{
						"session_id":    session.ID,
						"old_tokens":    contextTokens,
						"new_tokens":    newContextTokens,
						"reduction_pct": fmt.Sprintf("%.1f%%", float64(contextTokens-newContextTokens)/float64(contextTokens)*100),
					}).Debug("Context compacted")

					// Send summarization complete event with compacted history
					turnsCompacted := len(turns) - len(state.RecentTurns)
					reductionPct := float64(0)
					if contextTokens > 0 {
						reductionPct = float64(contextTokens-newContextTokens) / float64(contextTokens) * 100
					}
					streamer.SendSummarizationComplete(models.SummarizationEventData{
						OldTokens:        contextTokens,
						NewTokens:        newContextTokens,
						ReductionPercent: reductionPct,
						TurnsCompacted:   turnsCompacted,
						TurnsKept:        len(state.RecentTurns),
						Message:          fmt.Sprintf("Auto-compaction: reduced context from %d to %d tokens (%.1f%% reduction)", contextTokens, newContextTokens, reductionPct),
						CompactedHistory: compactedHistory,
					})
				} else {
					// Mid-turn compaction failed
					errMsg := "empty summary returned"
					if err != nil {
						errMsg = err.Error()
					}
					r.logger.WithFields(logrus.Fields{
						"session_id":     session.ID,
						"error":          errMsg,
						"context_tokens": contextTokens,
						"turns":          len(turns),
					}).Warn("Mid-turn compaction failed, truncating to recent messages")

					// Fallback: preserve all system messages + last N non-system messages
					if len(messages) > 20 {
						var systemMsgs []models.Message
						var otherMsgs []models.Message
						for _, msg := range messages {
							if msg.Role == "system" {
								systemMsgs = append(systemMsgs, msg)
							} else {
								otherMsgs = append(otherMsgs, msg)
							}
						}
						keepCount := 20 - len(systemMsgs)
						if keepCount < 4 {
							keepCount = 4
						}
						if len(otherMsgs) > keepCount {
							otherMsgs = otherMsgs[len(otherMsgs)-keepCount:]
						}
						messages = append(systemMsgs, otherMsgs...)
					}
					newContextTokens := summarizer.EstimateConversationTokens(messages)
					session.SetContextTokens(newContextTokens)
					streamer.SendStatus("compaction_warning",
						fmt.Sprintf("Context compaction failed (%s), using truncated history", errMsg))
				}
			}
		}

		// Idle-completion guard (see declaration above). Count consecutive
		// post-verify iterations that produced no writes; a write resets the
		// streak. When the build is currently verified-good and the agent is just
		// re-reading/re-verifying an already-finished project, nudge it once to
		// finish, then force-break into finalization so a chatty model can't spin
		// the loop to hardCeiling.
		if session.WasBuildOK() && !batchHadWrite {
			idleAfterVerify++
		} else {
			idleAfterVerify = 0
			idleNudged = false
		}

		if idleAfterVerify >= idleBreakThreshold {
			r.logger.WithFields(logrus.Fields{
				"session_id":  session.ID,
				"iteration":   iterationsRun,
				"idle_streak": idleAfterVerify,
			}).Info("Idle-completion guard: build verified and no writes for several iterations — finalizing")
			streamer.SendStatus("completing", "Almost there...")
			break
		}

		if idleAfterVerify >= idleNudgeThreshold && !idleNudged {
			idleNudged = true
			r.logger.WithFields(logrus.Fields{
				"session_id":  session.ID,
				"iteration":   iterationsRun,
				"idle_streak": idleAfterVerify,
			}).Info("Idle-completion guard: nudging agent to finish")
			messages = append(messages, models.Message{
				Role: "user",
				Content: "The project is built and verified — everything is in place and there are no errors. " +
					"Do not call any more tools. Reply now with a brief final summary of what was built and what the user can do next.",
			})
		}
	}

	// Authoritative final build verification — the guarantee that no "completed"
	// session ever ships a project that does not compile. The in-loop gates repair
	// while the agent is active; this net covers the case where the loop's budget
	// (including the finalization tail) was spent before a clean gate pass. If the
	// last gate already confirmed a good build (WasBuildOK), we skip the extra
	// build. Otherwise we run one authoritative `npm run build` and record honest
	// status so Laravel's /api/build-workspace never surprises the user with a 500
	// on a build we reported as done.
	// Cover both targets: the website net needs package.json (npm build); the
	// WordPress net needs none (verifyBuild routes to ValidateWordPressTheme when
	// !NeedsNodeBuild). Without the WordPress clause a theme whose agent never
	// reached an in-loop verifyBuild (hard-ceiling/circuit-breaker exit) would
	// ship structurally unvalidated and report build_status="not_run".
	needsFinalVerify := !session.WasBuildOK() &&
		(workspaceHasPackageJSON(r.executor.GetWorkspacePath()) || !r.executor.GetOutputTarget().NeedsNodeBuild())
	if needsFinalVerify {
		streamer.SendStatus("verifying", "Final build verification...")
		if bres, _ := r.executor.Execute(ctx, "verifyBuild", map[string]interface{}{}); bres != nil {
			if bres.Success {
				session.SetBuildOK(true)
				session.SetBuildResult(true, "Build succeeded")
			} else {
				session.SetBuildResult(false, bres.Content)
				r.logger.WithFields(logrus.Fields{
					"session_id": session.ID,
				}).Warn("Final build verification failed — reporting honest build status")
				streamer.SendMessage("⚠️ Heads up: the project still has build errors and may not preview correctly:\n\n" +
					bres.Content +
					"\n\nTell me to keep fixing and I'll continue from here.")
			}
		}
	}

	// Send the AI's final response as a message.
	// This handles three cases:
	//   1. lastAIContent is empty → use a contextual fallback
	//   2. lastAIContent was already sent in the loop (final iteration with no
	//      tool calls + content >100 chars) → don't double-send
	//   3. lastAIContent has not been sent yet (short content, OR long content
	//      that was suppressed because the LLM accompanied it with tool calls
	//      that invalidated it) → send it now as the closing summary
	if lastAIContent == "" {
		// Context-aware fallback: distinguish between agent modifications and template-only projects
		if r.executor.HasFileChanges() {
			lastAIContent = "Done! I've made the changes to your website."
		} else {
			lastAIContent = "Your website is ready! You can preview it now."
		}
		streamer.SendMessage(lastAIContent)
	} else if !lastAIContentSent {
		// Either short content (was never gated for in-loop send) or long
		// content that was suppressed because the LLM included tool calls.
		// Either way, this is the final closing summary.
		streamer.SendMessage(lastAIContent)
	}

	// Wait for pending webhook events (e.g., message) to be sent before sync complete
	streamer.Flush()

	// Sync file changes to session so IsBuildRequired() reads the correct value
	// OR with session flag to preserve pre-agent changes (e.g. template initialization)
	hasChanges := r.executor.HasFileChanges() || session.GetFilesChanged()
	session.SetFilesChanged(hasChanges)

	// Run post-completion quality checks (warnings only, never block)
	var qualityResult *models.QualityCheckResult
	if hasChanges {
		qualityResult = r.executor.RunQualityChecks()
		if qualityResult != nil && r.logger != nil {
			r.logger.WithFields(logrus.Fields{
				"session_id":  session.ID,
				"passed":      qualityResult.Passed,
				"issue_count": len(qualityResult.Issues),
			}).Info("Quality checks completed")
		}
	}

	// Deterministic AEO generation — call programmatically instead of relying on AI.
	// Website-only: AEO writes public/llms.txt + robots.txt, which are React-world
	// artifacts that don't belong in a WordPress theme.
	var aeoGenerated bool
	if session.GetBuildStatus() != "failed" && r.executor.HasMemoryContent() && r.executor.GetOutputTarget().NeedsNodeBuild() {
		success, msg := r.executor.GenerateAEOAuto(ctx)
		aeoGenerated = success
		if r.logger != nil {
			r.logger.WithFields(logrus.Fields{
				"session_id":    session.ID,
				"aeo_generated": success,
				"aeo_message":   logging.TruncateForLog(msg, 200),
			}).Info("Deterministic AEO generation")
		}
	}

	// GitHub auto-push: mirror the successful build to the linked repo. Best-effort
	// — never fails the build (mirrors reconcileFirecrawl).
	if caps := session.GetProjectCapabilities(); caps != nil && caps.Github != nil && caps.Github.Enabled && session.WasBuildOK() {
		laravelURL := session.GetLaravelURL()
		serverKey := r.executor.GetServerKey()
		if laravelURL != "" && serverKey != "" {
			lc := laravel.NewClient(laravelURL, serverKey)
			msgGen := func(mctx context.Context, diffStat string) string { return r.githubCommitMessage(mctx, diffStat, goal) }
			syncCtx, syncCancel := context.WithTimeout(context.Background(), 60*time.Second)
			SyncGithub(syncCtx, r.executor.GetWorkspacePath(), session.ID, goal, caps.Github, lc, streamer, msgGen)
			syncCancel()
		}
	}

	// Send completion (synchronous - blocks until Pusher confirms delivery)
	session.MarkCompleteSent()
	finalRunPrompt, finalRunCompletion, finalRunTotal := session.GetRunTokenStats()
	streamer.SendComplete(models.CompleteData{
		Iterations:          session.Iterations,
		TokensUsed:          session.TokensUsed,
		FilesChanged:        hasChanges,
		Message:             lastAIContent,
		BuildStatus:         session.GetBuildStatus(),
		BuildMessage:        session.GetBuildMessage(),
		BuildRequired:       session.IsBuildRequired(),
		PromptTokens:        session.PromptTokens,
		CompletionTokens:    session.CompletionTokens,
		RunTokensUsed:       finalRunTotal,
		RunPromptTokens:     finalRunPrompt,
		RunCompletionTokens: finalRunCompletion,
		Model:               r.aiConfig.Model,
		QualityCheck:        qualityResult,
		AEOGenerated:        aeoGenerated,
	})

	// Promote a normally-finished run to Completed BEFORE logging. The deferred
	// cleanup also does this, but it runs after LogSessionEnd reads the status,
	// so without this the session-end log would misreport a successful run as
	// "running". Failure/cancel/credit/circuit paths set their own terminal
	// status and return earlier, so they never reach here.
	if session.GetStatus() == models.StatusRunning {
		session.SetStatus(models.StatusCompleted)
	}

	// Log session complete with visual separator
	status := string(session.GetStatus())
	logging.LogSessionEnd(r.logger, session.ID, session.Iterations, session.TokensUsed, r.executor.HasFileChanges(), status)

	return nil
}

// buildSmokeAppConfig produces the client-safe window.__APP_CONFIG__ JSON the
// smoke gate injects into the served index.html, mirroring the live preview's
// injection so config-dependent apps boot during the render check. Only fields
// safe for the browser are included — SecretKey and DBConnection are NEVER
// emitted (they are server-side only). Returns "" when no Supabase/storage
// config is present (the gate then serves the app unmodified).
func buildSmokeAppConfig(caps *models.ProjectCapabilities) string {
	if caps == nil {
		return ""
	}
	cfg := map[string]interface{}{}
	if s := caps.Supabase; s != nil && s.Enabled {
		cfg["supabase"] = map[string]interface{}{
			"url":            s.URL,
			"publishableKey": s.PublishableKey,
			"schema":         s.Schema,
		}
	}
	if st := caps.Storage; st != nil && st.Enabled {
		cfg["storage"] = map[string]interface{}{
			"enabled":          true,
			"maxFileSizeMb":    st.MaxFileSizeMB,
			"allowedFileTypes": st.AllowedFileTypes,
		}
	}
	if len(cfg) == 0 {
		return ""
	}
	b, err := json.Marshal(cfg)
	if err != nil {
		return ""
	}
	return string(b)
}

// filterStableTypeErrors drops import-resolution errors that are commonly
// transient mid-scaffold (a module the agent is about to create, an export not
// added yet) from the IN-FLIGHT type-check feedback. It keeps genuine
// type-correctness errors (wrong props, bad assignments) that the agent should
// fix immediately. The end-of-loop build gate runs the full `tsc && vite build`
// and catches everything, so nothing is permanently hidden — this only avoids
// nagging the agent about half-finished scaffolding on every debounce tick.
func filterStableTypeErrors(summary string) string {
	if summary == "" {
		return ""
	}
	transient := []string{
		"TS2307", // Cannot find module
		"TS2305", // Module has no exported member
		"TS2304", // Cannot find name
		"TS6059", // File is not under rootDir (transient during file moves)
		"Cannot find module",
	}
	var kept []string
	for _, line := range strings.Split(summary, "\n") {
		skip := false
		for _, code := range transient {
			if strings.Contains(line, code) {
				skip = true
				break
			}
		}
		if !skip && strings.TrimSpace(line) != "" {
			kept = append(kept, line)
		}
	}
	return strings.Join(kept, "\n")
}

// scanPageFiles returns relative paths of all .tsx page files in src/pages/.
// Excludes NotFound.tsx since it doesn't need routing.
// workspaceHasPackageJSON reports whether the workspace contains a package.json
// at its root. The build gate runs `npm run build`, which is only meaningful for
// a real (template-initialized) project — so the gate is skipped for workspaces
// that have no package.json (e.g. unit-test temp dirs).
func workspaceHasPackageJSON(workspacePath string) bool {
	_, err := os.Stat(filepath.Join(workspacePath, "package.json"))
	return err == nil
}

// baseHrefRe extracts the value of the <base href="..."> tag from dist/index.html.
var baseHrefRe = regexp.MustCompile(`<base\s+href="([^"]+)"`)

// readBaseHref parses the <base href="..."> from dist/index.html so the smoke
// gate navigates routes under the same base path the built app expects (e.g.
// "/preview/<id>/"). Defaults to "/" when index.html is missing or has no base
// tag. The returned value always begins and ends with "/".
func readBaseHref(distDir string) string {
	html, err := os.ReadFile(filepath.Join(distDir, "index.html"))
	if err != nil {
		return "/"
	}
	m := baseHrefRe.FindSubmatch(html)
	if len(m) < 2 {
		return "/"
	}
	base := strings.TrimSpace(string(m[1]))
	if base == "" {
		return "/"
	}
	if !strings.HasPrefix(base, "/") {
		base = "/" + base
	}
	if !strings.HasSuffix(base, "/") {
		base += "/"
	}
	return base
}

func scanPageFiles(workspacePath string) []string {
	pagesDir := filepath.Join(workspacePath, "src", "pages")
	entries, err := os.ReadDir(pagesDir)
	if err != nil {
		return nil
	}

	var pages []string
	for _, entry := range entries {
		if entry.IsDir() {
			continue
		}
		name := entry.Name()
		if strings.HasSuffix(name, ".tsx") && name != "NotFound.tsx" {
			pages = append(pages, "src/pages/"+name)
		}
	}
	return pages
}

// filterToTouched returns only the files from `paths` that the session has
// recorded as touched (created or edited by the agent). Used by the post-loop
// integration auto-check to ignore pre-existing template files that the agent
// never modified — those aren't the agent's responsibility, and flagging them
// produced false-positive "I found integration issues" contradictions to the
// agent's own success messages.
func filterToTouched(paths []string, session *Session) []string {
	out := make([]string, 0, len(paths))
	for _, p := range paths {
		if session.HasTouchedFile(p) {
			out = append(out, p)
		}
	}
	return out
}

// maxResultForTool returns the per-tool context window truncation limit.
// Build tools need more context (error messages at end of long output).
// Read/analysis tools benefit from larger results. Other tools use a default.
func maxResultForTool(toolName string) int {
	switch toolName {
	case "verifyBuild":
		return 12000
	case "readFile", "analyzeProject", "searchFiles":
		return 8000
	default:
		return 6000
	}
}

func truncateOutput(s string, maxLen int) string {
	// Strip HTML tags first to prevent HTML in logs/streamed output
	s = logging.StripHTMLTags(s)

	if len(s) <= maxLen {
		return s
	}
	// For longer outputs, show beginning + end (errors usually at end)
	if maxLen > 500 {
		headLen := 200
		tailLen := maxLen - headLen - 50
		return s[:headLen] + "\n\n...(truncated)...\n\n" + s[len(s)-tailLen:]
	}
	return s[:maxLen] + "..."
}

// getActionDescription returns a fun, human-friendly action description and category for icon
// loadSiteMemory reads memory.json from workspace root if it exists.
// Returns the formatted content or empty string if not present.
func loadSiteMemory(workspacePath string) string {
	data, err := executor.ReadJSONFile(filepath.Join(workspacePath, "memory.json"))
	if err != nil || len(data) == 0 {
		return ""
	}
	content, err := json.MarshalIndent(data, "", "  ")
	if err != nil {
		return ""
	}
	return string(content)
}

// loadDesignIntelligence reads design-intelligence.json from workspace root if it exists.
// Returns the formatted content or empty string if not present.
func loadDesignIntelligence(workspacePath string) string {
	data, err := executor.ReadJSONFile(filepath.Join(workspacePath, "design-intelligence.json"))
	if err != nil || len(data) == 0 {
		return ""
	}
	content, err := json.MarshalIndent(data, "", "  ")
	if err != nil {
		return ""
	}
	return string(content)
}

func getActionDescription(toolName string, args map[string]interface{}) (action, target, category string) {
	path, _ := args["path"].(string)

	switch toolName {
	case "createFile":
		return randomPhrase(createPhrases), path, "creating"
	case "editFile":
		return randomPhrase(editPhrases), path, "editing"
	case "readFile":
		return randomPhrase(readPhrases), path, "reading"
	case "listFiles":
		// The explore phrases are complete standalone sentences (e.g. "Scouting
		// the project"), so no target is appended — otherwise it reads e.g.
		// "Scouting the project project structure".
		return randomPhrase(explorePhrases), "", "exploring"
	case "listComponents":
		return "Browsing", "available components", "exploring"
	case "getComponentUsage":
		component, _ := args["component"].(string)
		if component != "" {
			return "Learning about", component + " component", "reading"
		}
		return "Learning about", "component usage", "reading"
	case "verifyBuild":
		return "Verifying", "build", "reading"
	case "verifyIntegration":
		return "Checking", "page integration", "reading"
	case "createPlan":
		return "Planning", "your project", "planning"
	case "getProjectCapabilities":
		return "Checking", "available features", "reading"
	case "gitLog":
		return "Reviewing", "commit history", "reading"
	case "pushToGithub":
		return "Pushing", "to GitHub", "creating"
	case "defineTable":
		table, _ := args["table"].(string)
		if table != "" {
			return "Creating database table", table, "creating"
		}
		return "Creating", "database table", "creating"
	case "writeDesignIntelligence":
		return "Saving", "design decisions", "creating"
	case "readDesignIntelligence":
		return "Recalling", "design decisions", "reading"
	case "updateSiteMemory":
		return "Saving", "business context", "creating"
	case "readSiteMemory":
		return "Recalling", "business context", "reading"
	case "generateAEO":
		return "Generating", "AI discovery files", "creating"
	case "listImages":
		return "Browsing", "stock images", "exploring"
	case "getImageUsage":
		return "Selecting", "stock image", "reading"
	// webby-plugin-webagent tools — surfaced in the chat so the user can
	// see when the agent is reaching out to external sites.
	case "webFetchHttp":
		url, _ := args["url"].(string)
		if url != "" {
			return "Fetching", url, "exploring"
		}
		return "Fetching", "external page", "exploring"
	case "webBrowserOpen":
		url, _ := args["url"].(string)
		if url != "" {
			return "Opening browser at", url, "exploring"
		}
		return "Opening", "headless browser", "exploring"
	case "webBrowserClick":
		selector, _ := args["selector"].(string)
		return "Clicking", selector, "exploring"
	case "webBrowserType":
		selector, _ := args["selector"].(string)
		return "Typing into", selector, "exploring"
	case "webBrowserScroll":
		return "Scrolling", "page", "exploring"
	case "webBrowserFillForm":
		return "Filling out", "form", "exploring"
	case "webBrowserWaitFor":
		return "Waiting for", "page to settle", "exploring"
	case "webBrowserNavigate":
		direction, _ := args["direction"].(string)
		return "Navigating", direction, "exploring"
	case "webBrowserGetDom":
		return "Reading", "page contents", "exploring"
	case "webBrowserClose":
		return "Closing", "browser session", "exploring"
	default:
		if path != "" {
			return "Working on", path, ""
		}
		return "Working on", "your website", ""
	}
}
