import { streamText, stepCountIs, type ModelMessage, type Tool } from "ai";
import { getLanguageModel, getProviderDisplayName, getConfiguredProvider, ensureAntigravityTokenValid } from "@/lib/ai/providers";
import { createDocsSearchTool, createRetrieveFullContentTool } from "@/lib/ai/tools";
import { createWebSearchTool } from "@/lib/ai/web-search";
import { createWebBrowseTool, createWebQueryTool } from "@/lib/ai/web-browse";
import { createVectorSearchToolV2, createReadFileTool } from "@/lib/ai/vector-search";
import { createLocalGrepTool } from "@/lib/ai/ripgrep";
import { createExecuteCommandTool } from "@/lib/ai/tools/execute-command-tool";
import { ToolRegistry, registerAllTools, createToolSearchTool, createListToolsTool } from "@/lib/ai/tool-registry";
import { getSystemPrompt, AI_CONFIG } from "@/lib/ai/config";
import { buildCharacterSystemPrompt, getCharacterAvatarUrl } from "@/lib/ai/character-prompt";
import { compactIfNeeded } from "@/lib/sessions/compaction";
import { triggerExtraction } from "@/lib/agent-memory";
import { generateSessionTitle } from "@/lib/ai/title-generator";
import { createSession, createMessage, getSession, getOrCreateLocalUser, updateSession, getToolResultsForSession } from "@/lib/db/queries";
import { getCharacterFull } from "@/lib/characters/queries";
import { buildInterruptionMessage, buildInterruptionMetadata } from "@/lib/messages/interruption";
import { requireAuth } from "@/lib/auth/local-auth";
import { loadSettings } from "@/lib/settings/settings-manager";
import { sessionHasTruncatedContent } from "@/lib/ai/truncated-content-store";
import {
  withRunContext,
  createAgentRun,
  completeAgentRun,
  appendRunEvent,
  initializeToolEventHandler,
} from "@/lib/observability";
import fs from "fs/promises";
import path from "path";

// ============================================================================
// System Prompt Injection Optimization
// ============================================================================
// Instead of sending the system prompt with every message, we only send it:
// 1. On the first message of a conversation
// 2. Every 75,000 tokens of conversation history
// 3. Every 7 user messages
// This reduces token usage significantly while maintaining context.
//
// NOTE: Tools CANNOT be optimized the same way. Unlike the system prompt (which
// the AI "remembers" from conversation history), tools are function definitions
// that must be present for the AI to actually invoke them. Without the tools
// parameter, the AI will just output fake tool call syntax as plain text.

interface ContextInjectionTrackingMetadata {
  tokensSinceLastInjection: number;
  messagesSinceLastInjection: number;
  lastInjectedAt?: string;
  toolLoadingMode?: "deferred" | "always";
}

/**
 * Discovered tools tracking metadata.
 * Persists tools discovered via searchTools across requests so the model
 * can continue using them in subsequent turns.
 */
interface DiscoveredToolsMetadata {
  /** Tool names discovered via searchTools */
  toolNames: string[];
  /** When the tools were last discovered */
  lastUpdatedAt?: string;
}

// Thresholds for re-injecting context (system prompt + tools)
const CONTEXT_INJECTION_TOKEN_THRESHOLD = 75000;
const CONTEXT_INJECTION_MESSAGE_THRESHOLD = 7;

/**
 * Determines whether context (system prompt + tools) should be injected based on:
 * - First message in conversation (no tracking metadata yet)
 * - Token threshold exceeded (75,000 tokens since last injection)
 * - Message threshold exceeded (7 user messages since last injection)
 */
function shouldInjectContext(
  trackingMetadata: ContextInjectionTrackingMetadata | null,
  isFirstMessage: boolean,
  toolLoadingMode: "deferred" | "always"
): boolean {
  // Always inject on first message
  if (isFirstMessage || !trackingMetadata) {
    return true;
  }

  // Inject if tool loading mode changed (prompt guidance differs)
  if (!trackingMetadata.toolLoadingMode || trackingMetadata.toolLoadingMode !== toolLoadingMode) {
    return true;
  }

  // Inject if token threshold exceeded
  if (trackingMetadata.tokensSinceLastInjection >= CONTEXT_INJECTION_TOKEN_THRESHOLD) {
    return true;
  }

  // Inject if message threshold exceeded
  if (trackingMetadata.messagesSinceLastInjection >= CONTEXT_INJECTION_MESSAGE_THRESHOLD) {
    return true;
  }

  return false;
}

/**
 * Extract context injection tracking metadata from session metadata
 */
function getContextInjectionTracking(
  sessionMetadata: Record<string, unknown> | null
): ContextInjectionTrackingMetadata | null {
  if (!sessionMetadata) return null;

  const tracking = sessionMetadata.contextInjectionTracking as ContextInjectionTrackingMetadata | undefined;
  if (!tracking) return null;

  return {
    tokensSinceLastInjection: tracking.tokensSinceLastInjection ?? 0,
    messagesSinceLastInjection: tracking.messagesSinceLastInjection ?? 0,
    lastInjectedAt: tracking.lastInjectedAt,
    toolLoadingMode: tracking.toolLoadingMode ?? undefined,
  };
}

/**
 * Extract discovered tools from conversation history.
 * Ground truth for tool discovery - parses searchTools results from history.
 * This ensures tools discovered in previous turns remain active.
 */
function getDiscoveredToolsFromMessages(messages: any[]): Set<string> {
  const discovered = new Set<string>();
  for (const msg of messages) {
    if (msg.parts) {
      for (const part of msg.parts) {
        // dynamic-tool is for historical results loaded from DB
        // tool-searchTools is for streaming results (handled by AI SDK)
        const toolName = part.type === "dynamic-tool"
          ? part.toolName
          : (part.type.startsWith("tool-") ? part.type.replace("tool-", "") : null);

        if (toolName === "searchTools") {
          const output = part.output || part.result;
          if (output && Array.isArray(output.results)) {
            for (const res of output.results) {
              if (res.isAvailable && res.name) {
                discovered.add(res.name);
              }
            }
          }
        }
      }
    }
  }
  return discovered;
}

function getDiscoveredToolsFromMetadata(
  sessionMetadata: Record<string, unknown> | null
): Set<string> {
  if (!sessionMetadata) return new Set();

  const discovered = sessionMetadata.discoveredTools as DiscoveredToolsMetadata | undefined;
  if (!discovered?.toolNames) return new Set();

  return new Set(discovered.toolNames);
}

// Initialize tool event handler for observability (once per runtime)
initializeToolEventHandler();

// Maximum request duration in seconds
// Vercel Pro/Enterprise: 300s max, Hobby: 60s max
// Set to 300 (5 minutes) for longer agent operations
export const maxDuration = 300;

// Ensure settings are loaded (syncs provider selection to process.env)
loadSettings();

// Initialize tool registry once per runtime
registerAllTools();

// Check if Styly AI API is configured (for tool discovery instructions)
const hasStylyApiKey = () => !!process.env.STYLY_AI_API_KEY;

// Helper to convert relative image URLs to base64 data URIs for AI providers
async function imageUrlToBase64(imageUrl: string): Promise<string> {
  // If already a data URI or absolute URL, return as-is
  if (imageUrl.startsWith("data:") || imageUrl.startsWith("http")) {
    return imageUrl;
  }

  // Handle relative /api/media/ paths
  if (imageUrl.startsWith("/api/media/")) {
    try {
      // Extract path after /api/media/
      const relativePath = imageUrl.replace("/api/media/", "");
      const filePath = path.join(
        process.env.LOCAL_DATA_PATH || ".local-data",
        "media",
        relativePath
      );

      const fileBuffer = await fs.readFile(filePath);
      const base64 = fileBuffer.toString("base64");

      // Determine mime type from extension
      const ext = path.extname(filePath).toLowerCase();
      const mimeTypes: Record<string, string> = {
        ".png": "image/png",
        ".jpg": "image/jpeg",
        ".jpeg": "image/jpeg",
        ".gif": "image/gif",
        ".webp": "image/webp",
      };
      const mimeType = mimeTypes[ext] || "image/png";

      console.log(`[CHAT API] Converted image to base64: ${imageUrl} (${mimeType})`);
      return `data:${mimeType};base64,${base64}`;
    } catch (error) {
      console.error(`[CHAT API] Failed to convert image to base64: ${imageUrl}`, error);
      // Fall through to return original URL
    }
  }

  return imageUrl;
}

// Maximum length for any single text content to prevent base64 data from leaking into context
// Maximum text content length before smart truncation kicks in
// Content exceeding this limit is truncated, with full content stored for on-demand retrieval
const MAX_TEXT_CONTENT_LENGTH = 10000;

// Maximum length for ephemeral tool results (webSearch, webBrowse, webQuery) in subsequent turns
// These tools' results are consumed in the turn they're generated - subsequent turns only need a brief reference
const MAX_EPHEMERAL_TOOL_RESULT_LENGTH = 300;
const EPHEMERAL_TOOLS = ["webSearch", "webBrowse", "webQuery"];

// Import the truncated content store for smart truncation
import { storeFullContent } from "@/lib/ai/truncated-content-store";

/**
 * Check if a string looks like base64 image data that shouldn't be in text context
 * This is a safeguard against accidentally including base64 in conversation
 */
function looksLikeBase64ImageData(text: string): boolean {
  // Base64 image data is typically very long and contains specific patterns
  if (text.length < 1000) return false;

  // Check for data URL pattern
  if (text.includes("data:image/") && text.includes(";base64,")) return true;

  // Check for long base64-like strings (mostly alphanumeric with + and /)
  // A typical base64 image would have very high ratio of base64 chars
  const base64Chars = text.match(/[A-Za-z0-9+/=]/g);
  if (base64Chars && base64Chars.length / text.length > 0.95 && text.length > 5000) {
    return true;
  }

  return false;
}

/**
 * Sanitize text content with smart truncation:
 * - Detects and removes base64 image data (safety mechanism)
 * - When content exceeds MAX_TEXT_CONTENT_LENGTH:
 *   - Stores full content in session store with unique ID
 *   - Returns truncated content with instructions on how to retrieve full content
 *
 * @param text - The text content to sanitize
 * @param context - Description of where this content came from (for logging)
 * @param sessionId - Optional session ID for storing full content (enables smart truncation)
 */
function sanitizeTextContent(text: string, context: string, sessionId?: string): string {
  if (looksLikeBase64ImageData(text)) {
    console.warn(`[CHAT API] Detected base64 image data in ${context}, stripping to prevent token overflow`);
    return `[Base64 image data removed - use image URL instead]`;
  }

  // Smart truncation: store full content and provide retrieval instructions
  if (text.length > MAX_TEXT_CONTENT_LENGTH) {
    console.warn(`[CHAT API] Truncating long text in ${context}: ${text.length} chars`);

    // If sessionId is provided, store full content for later retrieval
    if (sessionId) {
      const contentId = storeFullContent(sessionId, context, text, MAX_TEXT_CONTENT_LENGTH);

      const truncatedText = text.slice(0, MAX_TEXT_CONTENT_LENGTH);
      const truncationNotice = `

---
⚠️ CONTENT TRUNCATED: This content was truncated at ${MAX_TEXT_CONTENT_LENGTH.toLocaleString()} characters (original: ${text.length.toLocaleString()} chars).
📦 FULL CONTENT AVAILABLE: Reference ID: ${contentId}
🔧 TO RETRIEVE FULL CONTENT: Use the "retrieveFullContent" tool with contentId="${contentId}"
💡 Only retrieve full content if the truncated portion above is insufficient for your task.
---`;

      return truncatedText + truncationNotice;
    } else {
      // No sessionId - simple truncation without storage (fallback behavior)
      return text.slice(0, MAX_TEXT_CONTENT_LENGTH) + `\n\n[Content truncated at ${MAX_TEXT_CONTENT_LENGTH.toLocaleString()} chars - sessionId not available for full content retrieval]`;
    }
  }

  return text;
}

// Helper to extract content from assistant-ui message format
// assistant-ui sends messages with `parts` array, but AI SDK expects `content`
// Also handles `experimental_attachments` from AI SDK format
// includeUrlHelpers: when true, adds [Image URL: ...] text for AI context (not for DB storage)
// convertUserImagesToBase64: when true, converts USER-uploaded image URLs to base64 (not tool-generated images)
// sessionId: when provided, enables smart truncation with full content retrieval
async function extractContent(
  msg: {
    role?: string;
    content?: string | unknown;
    parts?: Array<{
      type: string;
      text?: string;
      image?: string;
      url?: string;
      mediaType?: string;
      filename?: string;
      // For dynamic-tool parts (historical tool calls from DB)
      toolName?: string;
      toolCallId?: string;
      input?: unknown;
      output?: unknown;
      // For streaming tool parts from assistant-ui (format: "tool-{toolName}")
      result?: unknown;
    }>;
    // AI SDK experimental_attachments format
    experimental_attachments?: Array<{
      name?: string;
      contentType?: string;
      url?: string;
    }>;
  },
  includeUrlHelpers = false,
  convertUserImagesToBase64 = false,
  sessionId?: string
): Promise<string | Array<{ type: string; text?: string; image?: string }>> {
  // If content exists and is a string, use it directly (with sanitization)
  if (typeof msg.content === "string" && msg.content) {
    return sanitizeTextContent(msg.content, "string content", sessionId);
  }

  // Determine if this is a user message (only user images should be converted to base64)
  const isUserMessage = msg.role === "user";

  // If parts array exists (assistant-ui format), convert it
  if (msg.parts && Array.isArray(msg.parts)) {
    const contentParts: Array<{ type: string; text?: string; image?: string }> =
      [];

    for (const part of msg.parts) {
      if (part.type === "text" && part.text?.trim()) {
        // Sanitize text to prevent base64 leakage (with smart truncation if sessionId provided)
        const sanitizedText = sanitizeTextContent(part.text, `text part in ${msg.role} message`, sessionId);
        contentParts.push({ type: "text", text: sanitizedText });
      } else if (part.type === "image" && (part.image || part.url)) {
        const imageUrl = (part.image || part.url) as string;
        // ONLY convert to base64 for USER-uploaded images
        // Assistant/tool-generated images should NOT be converted (they're just URLs for reference)
        const shouldConvert = convertUserImagesToBase64 && isUserMessage;
        const finalImageUrl = shouldConvert ? await imageUrlToBase64(imageUrl) : imageUrl;

        if (shouldConvert) {
          // User uploaded image - add as actual image for Claude to see
          contentParts.push({ type: "image", image: finalImageUrl });
        }
        // Add URL as text so Claude can use it in tool calls
        if (includeUrlHelpers) {
          contentParts.push({
            type: "text",
            text: `[Image URL: ${imageUrl}]`,
          });
        }
      } else if (
        part.type === "file" &&
        part.url &&
        part.mediaType?.startsWith("image/")
      ) {
        // ONLY convert to base64 for USER-uploaded files
        const shouldConvert = convertUserImagesToBase64 && isUserMessage;
        const finalImageUrl = shouldConvert ? await imageUrlToBase64(part.url) : part.url;

        if (shouldConvert) {
          // User uploaded image - add as actual image for Claude to see
          contentParts.push({ type: "image", image: finalImageUrl });
        }
        // Add URL as text so Claude can use it in tool calls
        if (includeUrlHelpers) {
          const label = part.filename || "uploaded image";
          contentParts.push({
            type: "text",
            text: `[${label} URL: ${part.url}]`,
          });
        }
        // ALWAYS preserve the file reference (for DB storage when flags are off)
        if (!shouldConvert && !includeUrlHelpers) {
          contentParts.push({
            type: "image",
            image: part.url,
          });
        }
      } else if (part.type === "dynamic-tool" && part.toolName) {
        // Handle historical tool calls from DB - include result as text so AI can reference it
        // This is crucial for the AI to remember previous image/video generation results
        console.log(`[EXTRACT] Found dynamic-tool: ${part.toolName}, output:`, JSON.stringify(part.output, null, 2));
        const output = part.output as { images?: Array<{ url: string }>; videos?: Array<{ url: string }>; text?: string; status?: string } | null;
        if (output?.images && output.images.length > 0) {
          // Include generated image URLs so AI can reference them
          // CRITICAL: Format URLs clearly so AI uses them EXACTLY as provided (no domain hallucination)
          const urlList = output.images.map((img, idx) => `  ${idx + 1}. ${img.url}`).join("\n");
          console.log(`[EXTRACT] Adding generated image URLs to context: ${urlList}`);
          contentParts.push({
            type: "text",
            text: `Previously generated ${output.images.length} image(s) using ${part.toolName}:\n${urlList}\nUse these URLs for EDITING requests. For NEW image generation, call the tool.`,
          });
        } else if (output?.videos && output.videos.length > 0) {
          // Include generated video URLs so AI can reference them
          // CRITICAL: Format URLs clearly so AI uses them EXACTLY as provided (no domain hallucination)
          const urlList = output.videos.map((vid, idx) => `  ${idx + 1}. ${vid.url}`).join("\n");
          console.log(`[EXTRACT] Adding generated video URLs to context: ${urlList}`);
          contentParts.push({
            type: "text",
            text: `Previously generated ${output.videos.length} video(s) using ${part.toolName}:\n${urlList}\nUse these URLs for EDITING requests. For NEW video generation, call the tool.`,
          });
        } else if (output?.text) {
          // Truncate ephemeral tool results (webSearch, webBrowse, webQuery) to reduce context bloat
          let resultText = output.text;
          if (EPHEMERAL_TOOLS.includes(part.toolName) && resultText.length > MAX_EPHEMERAL_TOOL_RESULT_LENGTH) {
            resultText = resultText.substring(0, MAX_EPHEMERAL_TOOL_RESULT_LENGTH) + "... [truncated - full result was used in original response]";
          }
          contentParts.push({
            type: "text",
            text: `Previous ${part.toolName} result: ${resultText}`,
          });
        } else if (part.toolName === "searchTools") {
          // Preserve searchTools results so AI remembers discovered tools
          const searchOutput = output as { status?: string; query?: string; results?: Array<{ name?: string; displayName?: string; isAvailable?: boolean }> } | null;
          if (searchOutput?.results && searchOutput.results.length > 0) {
            const toolNames = searchOutput.results
              .filter((t) => t.isAvailable)
              .map((t) => t.displayName || t.name)
              .join(", ");
            // No synthetic marker needed - searchTools result is already in conversation history
            // Just log for debugging
            console.log(`[EXTRACT] searchTools found: ${toolNames}`);
          }
        } else if (part.toolName === "webSearch") {
          // Preserve webSearch results so AI remembers search findings
          // This is CRITICAL for preventing redundant searches in virtual try-on workflows
          const webSearchOutput = output as {
            status?: string;
            query?: string;
            sources?: Array<{ url: string; title: string; snippet: string }>;
            answer?: string;
            formattedResults?: string;
          } | null;

          if (webSearchOutput?.sources && webSearchOutput.sources.length > 0) {
            // No synthetic marker - tool result already in conversation history
            console.log(`[EXTRACT] webSearch completed: ${webSearchOutput.query} (${webSearchOutput.sources.length} sources)`);
          }
        } else if (part.toolName === "webBrowse") {
          // Preserve webBrowse synthesis - contains product details for virtual try-on
          const webBrowseOutput = output as {
            status?: string;
            synthesis?: string;
            fetchedUrls?: string[];
            sourcesUsed?: string[];
          } | null;

          if (webBrowseOutput?.synthesis) {
            // No synthetic marker - tool result already in conversation history
            const urls = webBrowseOutput.fetchedUrls || webBrowseOutput.sourcesUsed;
            console.log(`[EXTRACT] webBrowse completed: fetched ${urls?.length || 0} URLs`);
          }
        } else if (part.toolName === "vectorSearch") {
          // Preserve vectorSearch results so AI remembers code search findings
          const vectorSearchOutput = output as {
            status?: string;
            strategy?: string;
            reasoning?: string;
            findings?: Array<{ filePath: string; lineRange?: string; snippet: string; explanation: string; confidence: number }>;
            summary?: string;
            suggestedRefinements?: string[];
          } | null;

          if (vectorSearchOutput?.findings && vectorSearchOutput.findings.length > 0) {
            // No synthetic marker - tool result already in conversation history
            console.log(`[EXTRACT] vectorSearch completed: ${vectorSearchOutput.findings.length} findings`);
          }
        } else if (part.toolName === "showProductImages") {
          // Preserve showProductImages results so AI can reference displayed products
          const productGalleryOutput = output as {
            status?: string;
            query?: string;
            products?: Array<{
              id: string;
              name: string;
              imageUrl: string;
              price?: string;
              sourceUrl?: string;
              description?: string;
            }>;
          } | null;

          if (productGalleryOutput?.products && productGalleryOutput.products.length > 0) {
            // No synthetic marker - tool result already in conversation history
            console.log(`[EXTRACT] showProductImages completed: ${productGalleryOutput.products.length} products for "${productGalleryOutput.query}"`);
          }
        } else {
          console.log(`[EXTRACT] dynamic-tool ${part.toolName} has no images, videos, or text in output, adding generic summary`);
          // Provide fallback so AI knows the tool was called and what it returned
          // This prevents "memory gaps" and satisfying Anthropic's non-empty requirement
          const stringifiedOutput = typeof part.output === "string"
            ? part.output
            : (part.output ? JSON.stringify(part.output) : "null");

          contentParts.push({
            type: "text",
            text: `Previous ${part.toolName} result: ${stringifiedOutput}`,
          });
        }
      } else if (part.type.startsWith("tool-")) {
        // Handle streaming tool calls from assistant-ui (format: "tool-{toolName}")
        // These come with result property instead of output
        const toolName = part.type.replace("tool-", "");
        const partWithResult = part as typeof part & { result?: { images?: Array<{ url: string }>; videos?: Array<{ url: string }>; text?: string } };
        console.log(`[EXTRACT] Found tool-${toolName}, result:`, JSON.stringify(partWithResult.result, null, 2));

        if (partWithResult.result?.images && partWithResult.result.images.length > 0) {
          // CRITICAL: Format URLs clearly so AI uses them EXACTLY as provided (no domain hallucination)
          const urlList = partWithResult.result.images.map((img, idx) => `  ${idx + 1}. ${img.url}`).join("\n");
          console.log(`[EXTRACT] Adding generated image URLs to context: ${urlList}`);
          contentParts.push({
            type: "text",
            text: `Previously generated ${partWithResult.result.images.length} image(s) using ${toolName}:\n${urlList}\nUse these URLs for EDITING requests. For NEW image generation, call the tool.`,
          });
        } else if (partWithResult.result?.videos && partWithResult.result.videos.length > 0) {
          // CRITICAL: Format URLs clearly so AI uses them EXACTLY as provided (no domain hallucination)
          const urlList = partWithResult.result.videos.map((vid, idx) => `  ${idx + 1}. ${vid.url}`).join("\n");
          console.log(`[EXTRACT] Adding generated video URLs to context: ${urlList}`);
          contentParts.push({
            type: "text",
            text: `Previously generated ${partWithResult.result.videos.length} video(s) using ${toolName}:\n${urlList}\nUse these URLs for EDITING requests. For NEW video generation, call the tool.`,
          });
        } else if (partWithResult.result?.text) {
          // Truncate ephemeral tool results (webSearch, webBrowse, webQuery) to reduce context bloat
          let resultText = partWithResult.result.text;
          if (EPHEMERAL_TOOLS.includes(toolName) && resultText.length > MAX_EPHEMERAL_TOOL_RESULT_LENGTH) {
            resultText = resultText.substring(0, MAX_EPHEMERAL_TOOL_RESULT_LENGTH) + "... [truncated - full result was used in original response]";
          }
          contentParts.push({
            type: "text",
            text: `Previous ${toolName} result: ${resultText}`,
          });
        } else if (toolName === "searchTools") {
          // Preserve searchTools results so AI remembers discovered tools
          const searchResult = partWithResult.result as { status?: string; query?: string; results?: Array<{ name?: string; displayName?: string; isAvailable?: boolean }> } | undefined;
          if (searchResult?.results && searchResult.results.length > 0) {
            const toolNames = searchResult.results
              .filter((t) => t.isAvailable)
              .map((t) => t.displayName || t.name)
              .join(", ");
            // No synthetic marker - tool result already in conversation history
            console.log(`[EXTRACT] searchTools found: ${toolNames}`);
          }
        } else if (toolName === "webSearch") {
          // Preserve webSearch results from streaming format
          const webSearchResult = partWithResult.result as {
            status?: string;
            query?: string;
            sources?: Array<{ url: string; title: string; snippet: string }>;
            answer?: string;
          } | undefined;

          if (webSearchResult?.sources && webSearchResult.sources.length > 0) {
            // No synthetic marker - tool result already in conversation history
            console.log(`[EXTRACT] webSearch completed: ${webSearchResult.query} (${webSearchResult.sources.length} sources)`);
          }
        } else if (toolName === "webBrowse") {
          // Preserve webBrowse synthesis from streaming format
          const webBrowseResult = partWithResult.result as {
            status?: string;
            synthesis?: string;
            fetchedUrls?: string[];
            sourcesUsed?: string[];
          } | undefined;

          if (webBrowseResult?.synthesis) {
            // No synthetic marker - tool result already in conversation history
            const urls = webBrowseResult.fetchedUrls || webBrowseResult.sourcesUsed;
            console.log(`[EXTRACT] webBrowse completed: fetched ${urls?.length || 0} URLs`);
          }
        } else if (toolName === "vectorSearch") {
          // Preserve vectorSearch results from streaming format
          const vectorSearchResult = partWithResult.result as {
            status?: string;
            strategy?: string;
            reasoning?: string;
            findings?: Array<{ filePath: string; lineRange?: string; snippet: string; explanation: string; confidence: number }>;
            summary?: string;
            suggestedRefinements?: string[];
          } | undefined;

          if (vectorSearchResult?.findings && vectorSearchResult.findings.length > 0) {
            // No synthetic marker - tool result already in conversation history
            console.log(`[EXTRACT] vectorSearch completed: ${vectorSearchResult.findings.length} findings`);
          }
        } else if (toolName === "showProductImages") {
          // Preserve showProductImages results so AI can reference displayed products
          const productGalleryResult = partWithResult.result as {
            status?: string;
            query?: string;
            products?: Array<{
              id: string;
              name: string;
              imageUrl: string;
              price?: string;
              sourceUrl?: string;
              description?: string;
            }>;
          } | undefined;

          if (productGalleryResult?.products && productGalleryResult.products.length > 0) {
            // No synthetic marker - tool result already in conversation history
            console.log(`[EXTRACT] showProductImages completed: ${productGalleryResult.products.length} products for "${productGalleryResult.query}"`);
          }
        } else {
          console.log(`[EXTRACT] tool-${toolName} has no images, videos, or text in result, adding generic summary`);
          const resultText = partWithResult.result ? JSON.stringify(partWithResult.result) : "null";
          contentParts.push({
            type: "text",
            text: `Previous ${toolName} result: ${resultText}`,
          });
        }
      }
    }

    // Also process experimental_attachments (AI SDK format for file uploads)
    if (msg.experimental_attachments && Array.isArray(msg.experimental_attachments)) {
      console.log(`[EXTRACT] Processing ${msg.experimental_attachments.length} experimental_attachments`);
      for (const attachment of msg.experimental_attachments) {
        if (attachment.url && attachment.contentType?.startsWith("image/")) {
          console.log(`[EXTRACT] Found image attachment: ${attachment.name}, url: ${attachment.url}`);
          const shouldConvert = convertUserImagesToBase64 && isUserMessage;
          const finalImageUrl = shouldConvert ? await imageUrlToBase64(attachment.url) : attachment.url;

          if (shouldConvert) {
            // User uploaded image - add as actual image for Claude to see
            contentParts.push({ type: "image", image: finalImageUrl });
          }
          // Add URL as text so Claude can use it in tool calls
          if (includeUrlHelpers) {
            const label = attachment.name || "uploaded image";
            contentParts.push({
              type: "text",
              text: `[${label} URL: ${attachment.url}]`,
            });
          }
        }
      }
    }

    // If no content parts, return non-empty fallback string for AI providers
    if (contentParts.length === 0) {
      return "[Message content not available]";
    }

    // If only one text part, return as string for simplicity
    if (contentParts.length === 1 && contentParts[0].type === "text") {
      return contentParts[0].text || "";
    }

    return contentParts;
  }

  // Also check for experimental_attachments even without parts array
  if (msg.experimental_attachments && Array.isArray(msg.experimental_attachments)) {
    const contentParts: Array<{ type: string; text?: string; image?: string }> = [];
    const isUserMessage = msg.role === "user";

    // If there's string content, add it first (with smart truncation if sessionId provided)
    if (typeof msg.content === "string" && msg.content) {
      contentParts.push({ type: "text", text: sanitizeTextContent(msg.content, "string content with attachments", sessionId) });
    }

    console.log(`[EXTRACT] Processing ${msg.experimental_attachments.length} experimental_attachments (no parts)`);
    for (const attachment of msg.experimental_attachments) {
      if (attachment.url && attachment.contentType?.startsWith("image/")) {
        console.log(`[EXTRACT] Found image attachment: ${attachment.name}, url: ${attachment.url}`);
        const shouldConvert = convertUserImagesToBase64 && isUserMessage;
        const finalImageUrl = shouldConvert ? await imageUrlToBase64(attachment.url) : attachment.url;

        if (shouldConvert) {
          contentParts.push({ type: "image", image: finalImageUrl });
        }
        if (includeUrlHelpers) {
          const label = attachment.name || "uploaded image";
          contentParts.push({
            type: "text",
            text: `[${label} URL: ${attachment.url}]`,
          });
        }
      }
    }

    if (contentParts.length > 0) {
      if (contentParts.length === 1 && contentParts[0].type === "text") {
        return contentParts[0].text || "";
      }
      return contentParts;
    }
  }

  // If content is an array, pass it through
  if (Array.isArray(msg.content)) {
    return msg.content as Array<{ type: string; text?: string; image?: string }>;
  }

  // Fallback
  return "[Message content not available]";
}

// Frontend message part type (from assistant-ui)
interface FrontendMessagePart {
  type: string;
  text?: string;
  image?: string;
  url?: string;
  // Tool call parts (from assistant-ui streaming format)
  toolName?: string;
  toolCallId?: string;
  args?: unknown;
  argsText?: string;
  result?: unknown;
}

// Frontend message type (from assistant-ui / AssistantChatTransport)
interface FrontendMessage {
  id?: string;
  role: string;
  content?: string | unknown;
  parts?: FrontendMessagePart[];
  experimental_attachments?: Array<{ name?: string; contentType?: string; url?: string }>;
}

/**
 * HYBRID APPROACH: Enhance frontend messages with tool results from database.
 *
 * This solves the ID mismatch problem between frontend (runtime IDs) and database (UUIDs).
 *
 * The frontend's messages are the source of truth for:
 * - Conversation structure (which messages exist, in what order)
 * - Message content (especially important for EDITED messages)
 *
 * The database provides:
 * - Tool results (which the frontend's streaming messages may not have properly)
 *
 * How it works:
 * 1. Start with the frontend messages exactly as sent
 * 2. For assistant messages with tool-call parts, look up results from DB
 * 3. Add tool results to the parts array
 *
 * This respects edits (frontend has correct truncated state) while getting tool results.
 */
async function enhanceFrontendMessagesWithToolResults(
  frontendMessages: FrontendMessage[],
  sessionId: string
): Promise<FrontendMessage[]> {
  // Fetch all tool results from the database for this session
  const toolResults = await getToolResultsForSession(sessionId);

  console.log(`[CHAT API] Hybrid approach: ${frontendMessages.length} frontend messages, ${toolResults.size} tool results from DB`);

  // If no tool results, just return frontend messages as-is
  if (toolResults.size === 0) {
    return frontendMessages;
  }

  // Enhance each assistant message with tool results
  const enhancedMessages = frontendMessages.map(msg => {
    // Only enhance assistant messages
    if (msg.role !== 'assistant') {
      return msg;
    }

    // Check if this message has tool-call parts that need results
    if (!msg.parts || !Array.isArray(msg.parts)) {
      return msg;
    }

    // Look for tool-call parts and enhance with results from DB
    let hasEnhancements = false;
    const enhancedParts = msg.parts.map(part => {
      // Handle tool-call parts (from assistant-ui streaming format)
      // These have type like "tool-call" with toolCallId
      if (part.type === 'tool-call' && part.toolCallId) {
        const result = toolResults.get(part.toolCallId);
        if (result !== undefined && part.result === undefined) {
          hasEnhancements = true;
          console.log(`[CHAT API] Enhanced tool call ${part.toolCallId} (${part.toolName}) with DB result`);
          return { ...part, result };
        }
      }
      return part;
    });

    if (hasEnhancements) {
      return { ...msg, parts: enhancedParts };
    }

    return msg;
  });

  return enhancedMessages;
}

export async function POST(req: Request) {
  try {
    // Get local user for offline mode
    const userId = await requireAuth(req);
    const settings = loadSettings();
    const dbUser = await getOrCreateLocalUser(userId, settings.localUserEmail);

    // CRITICAL: If using Antigravity provider, ensure token is valid/refreshed BEFORE making API calls
    // This prevents authentication failures when token expires during normal usage
    if (getConfiguredProvider() === "antigravity") {
      const tokenValid = await ensureAntigravityTokenValid();
      if (!tokenValid) {
        return new Response(
          JSON.stringify({ error: "Antigravity authentication expired. Please re-authenticate in Settings." }),
          { status: 401, headers: { "Content-Type": "application/json" } }
        );
      }
    }

    const body = await req.json();
    const { messages, sessionId: bodySessionId } = body as {
      messages: Array<{
        id?: string;
        role: string;
        content?: string | unknown;
        parts?: Array<{ type: string; text?: string; image?: string; url?: string }>;
        experimental_attachments?: Array<{ name?: string; contentType?: string; url?: string }>;
      }>;
      sessionId?: string;
    };

    // Get session ID from header (AssistantChatTransport) or body
    const headerSessionId = req.headers.get("X-Session-Id");
    const providedSessionId = headerSessionId || bodySessionId;

    // Get character ID from header (for character-specific chats)
    const characterId = req.headers.get("X-Character-Id");

    // DEBUG: Log session ID sources and last message structure
    console.log(`[CHAT API] Session ID: header=${headerSessionId}, body=${bodySessionId}, using=${providedSessionId}, characterId=${characterId}`);

    // DEBUG: Log full structure of last message to understand attachment handling
    const lastMsg = messages[messages.length - 1];
    console.log(`[CHAT API] Last message structure:`, JSON.stringify({
      id: lastMsg?.id,
      role: lastMsg?.role,
      hasContent: !!lastMsg?.content,
      contentType: typeof lastMsg?.content,
      hasParts: !!lastMsg?.parts,
      partsCount: lastMsg?.parts?.length,
      parts: lastMsg?.parts?.map(p => ({ type: p.type, hasImage: !!p.image, hasUrl: !!p.url })),
      hasAttachments: !!(lastMsg as any)?.experimental_attachments,
      attachmentsCount: (lastMsg as any)?.experimental_attachments?.length,
      attachments: (lastMsg as any)?.experimental_attachments,
    }, null, 2));

    // Validate messages
    if (!messages || !Array.isArray(messages) || messages.length === 0) {
      return new Response(
        JSON.stringify({ error: "Messages array is required" }),
        { status: 400, headers: { "Content-Type": "application/json" } }
      );
    }

    // Get or create session (always associated with user)
    // Also track system prompt injection state
    let sessionId = providedSessionId;
    let isNewSession = false;
    let sessionMetadata: Record<string, unknown> = {};

    if (!sessionId) {
      const session = await createSession({
        title: "New Design Session",
        userId: dbUser.id,
        metadata: {},
      });
      sessionId = session.id;
      isNewSession = true;
    } else {
      // Verify session exists and belongs to user
      const session = await getSession(sessionId);
      if (!session) {
        const newSession = await createSession({
          id: sessionId,
          title: "New Design Session",
          userId: dbUser.id,
          metadata: {},
        });
        sessionId = newSession.id;
        isNewSession = true;
      } else if (session.userId !== dbUser.id) {
        // Session exists but belongs to another user
        return new Response(
          JSON.stringify({ error: "Forbidden" }),
          { status: 403, headers: { "Content-Type": "application/json" } }
        );
      } else {
        // Session exists and belongs to user - extract metadata for system prompt tracking
        sessionMetadata = (session.metadata as Record<string, unknown>) || {};
      }
    }

    const appSettings = loadSettings();
    const toolLoadingMode = appSettings.toolLoadingMode ?? "deferred";

    // Determine if we should inject context (system prompt + tools)
    // This reduces token usage by only sending these on first message and periodically thereafter
    const contextTracking = getContextInjectionTracking(sessionMetadata);
    const injectContext = shouldInjectContext(contextTracking, isNewSession, toolLoadingMode);
    console.log(`[CHAT API] Context injection: isNew=${isNewSession}, tracking=${JSON.stringify(contextTracking)}, inject=${injectContext}`);

    // Run auto-compaction if needed
    await compactIfNeeded(sessionId);

    // Create agent run for observability
    const agentRun = await createAgentRun({
      sessionId,
      userId: dbUser.id,
      pipelineName: "chat",
      triggerType: "chat",
      metadata: {
        characterId: characterId || null,
        messageCount: messages.length,
      },
    });

    // Only save the NEW user message (the last one in the array)
    // Previous messages have already been saved in earlier requests
    // The assistant response will be saved in onFinish callback
    const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

    const lastMessage = messages[messages.length - 1];
    const userMessageCount = messages.filter((msg) => msg.role === "user").length;
    let savedUserMessageId: string | undefined;

    if (lastMessage && lastMessage.role === 'user') {
      // Extract content using the helper (handles parts array from assistant-ui)
      // Don't convert to base64 for DB storage (keeps URLs compact)
      const extractedContent = await extractContent(lastMessage);

      // Normalize content to array format for JSONB storage
      let normalizedContent: unknown[];
      if (typeof extractedContent === "string") {
        normalizedContent = [{ type: "text", text: extractedContent }];
      } else if (Array.isArray(extractedContent)) {
        normalizedContent = extractedContent;
      } else {
        normalizedContent = [{ type: "text", text: "" }];
      }

      // Only use the message ID if it's a valid UUID, otherwise let DB generate one
      const isValidUUID = lastMessage.id && uuidRegex.test(lastMessage.id);

      const result = await createMessage({
        ...(isValidUUID && { id: lastMessage.id }),
        sessionId,
        role: 'user',
        content: normalizedContent,
        metadata: {},
      });

      savedUserMessageId = result?.id;
      console.log(`[CHAT API] Saved new user message: ${lastMessage.id} -> ${savedUserMessageId || 'SKIPPED (conflict)'}`);

      const plainTextContent = getPlainTextFromContent(extractedContent);
      const shouldAutoNameSession = (isNewSession || userMessageCount === 1) && plainTextContent.length > 0;

      if (shouldAutoNameSession) {
        void generateSessionTitle(sessionId, plainTextContent);
      }
    }

    // ==========================================================================
    // HYBRID APPROACH: Use frontend messages enhanced with DB tool results
    // ==========================================================================
    //
    // WHY: The old approach loaded all messages from DB, then tried to filter
    // by frontend IDs. This failed because:
    // - Frontend uses runtime-generated IDs (e.g., "ugcMV6iZqVklzR4b")
    // - Database uses UUIDs (e.g., "c4fef2b7-9d87-4a49-9273-6a335acd08cb")
    // - These NEVER match, so filtering never worked
    //
    // NEW APPROACH:
    // 1. Use frontend messages directly (they have correct structure after edits)
    // 2. Enhance with tool results from DB (which frontend may lack)
    // 3. Convert to core format for AI
    //
    // This correctly handles message editing because the frontend has the
    // truncated conversation state with the edited content.
    // ==========================================================================

    console.log(`[CHAT API] Using HYBRID approach: ${messages.length} frontend messages`);

    // Enhance frontend messages with tool results from database
    // This adds tool results to assistant messages that have tool-call parts
    const enhancedMessages = await enhanceFrontendMessagesWithToolResults(
      messages as FrontendMessage[],
      sessionId
    );

    console.log(`[CHAT API] Enhanced ${enhancedMessages.length} messages with DB tool results`);

    // Convert to core format for the AI SDK
    // includeUrlHelpers=true so Claude gets URL text like [Image URL: /api/media/...] for tool calls
    // convertUserImagesToBase64=false - DON'T send base64 to Claude (wastes ~200k tokens per image!)
    // sessionId enables smart truncation - long content is truncated but full version is retrievable
    // Tools like editRoomImage, describeImage handle base64 conversion themselves when needed
    const coreMessages: ModelMessage[] = await Promise.all(
      enhancedMessages.map(async (msg, idx) => {
        const content = await extractContent(
          msg as Parameters<typeof extractContent>[0],
          true,   // includeUrlHelpers - Claude needs URL text for tool calls
          false,  // convertUserImagesToBase64 - DON'T send base64 to Claude (tools handle it)
          sessionId  // sessionId - enables smart truncation with full content retrieval
        );
        // DEBUG: Log what we're sending to Claude (avoid logging full content to prevent log spam)
        console.log(`[CHAT API] Message ${idx} (${msg.role}):`, JSON.stringify({
          hasParts: !!(msg as { parts?: unknown[] }).parts,
          partsCount: (msg as { parts?: unknown[] }).parts?.length,
          partTypes: (msg as { parts?: Array<{ type: string }> }).parts?.map(p => p.type),
          contentType: typeof content === 'string' ? 'string' : 'array',
          contentLength: typeof content === 'string' ? content.length : (content as unknown[]).length,
        }, null, 2));
        return {
          role: msg.role as "user" | "assistant" | "system",
          content,
        } as ModelMessage;
      })
    );

    // Build system prompt and get character context for tools
    // NOTE: Tool instructions are now embedded in tool descriptions (fullInstructions)
    // and discovered via searchTools. No need to concatenate them to system prompt.
    let systemPrompt: string;
    let characterAvatarUrl: string | null = null;
    let characterAppearanceDescription: string | null = null;
    let enabledTools: string[] | undefined;

    if (characterId) {
      const character = await getCharacterFull(characterId);
      if (character && character.userId === dbUser.id) {
        // Build character-specific system prompt (includes shared blocks)
        systemPrompt = buildCharacterSystemPrompt(character, { toolLoadingMode });

        // Get character avatar and appearance for tool context
        characterAvatarUrl = getCharacterAvatarUrl(character);
        characterAppearanceDescription = character.tagline || null;

        // Extract enabled tools from character metadata
        const metadata = character.metadata as { enabledTools?: string[] } | null;
        enabledTools = metadata?.enabledTools;

        console.log(`[CHAT API] Using character: ${character.name} (${characterId}), avatar: ${characterAvatarUrl || "none"}, enabledTools: ${enabledTools?.join(", ") || "all"}`);
      } else {
        // Character not found or doesn't belong to user, use default
        systemPrompt = getSystemPrompt({
          stylyApiEnabled: hasStylyApiKey(),
          toolLoadingMode,
        });
        console.log(`[CHAT API] Character not found or unauthorized, using default prompt`);
      }
    } else {
      // No character specified, use default professional agent prompt
      systemPrompt = getSystemPrompt({
        stylyApiEnabled: hasStylyApiKey(),
        toolLoadingMode,
      });
    }

    // Create tools via the centralized Tool Registry
    // We load ALL tools (including deferred ones) but use activeTools to control visibility
    const registry = ToolRegistry.getInstance();

    // CRITICAL: Create agentEnabledTools Set for strict filtering
    // If the agent has specific tools selected, ONLY those tools (plus core utilities) will be loaded
    // Deduplicate the array to prevent duplicates in logs
    const agentEnabledTools = enabledTools
      ? new Set(Array.from(new Set(enabledTools))) // Dedupe before creating Set
      : undefined;


    // First, get non-deferred tools to build the initial active set
    // CRITICAL: We do NOT pass includeTools: enabledTools here!
    // Doing so would force all agent-enabled tools to be active from step 0,
    // defeating the purpose of deferred loading and token savings.
    // 'agentEnabledTools' filter still ensures only authorized tools are candidates.
    const nonDeferredTools = registry.getTools({
      sessionId,
      characterAvatarUrl: characterAvatarUrl || undefined,
      characterAppearanceDescription: characterAppearanceDescription || undefined,
      includeDeferredTools: false, // Only non-deferred tools for initial active set
      agentEnabledTools, // Filter by authorized tools
    });
    const initialActiveTools = new Set(Object.keys(nonDeferredTools));

    // CRITICAL FIX: Initialize with tools discovered in PREVIOUS requests.
    // We use both session metadata (fast) and message history (robust ground truth).
    const historicallyDiscoveredTools = getDiscoveredToolsFromMessages(enhancedMessages);
    const metadataDiscoveredTools = getDiscoveredToolsFromMetadata(sessionMetadata);
    const previouslyDiscoveredTools = new Set([
      ...historicallyDiscoveredTools,
      ...metadataDiscoveredTools
    ]);

    // Check user preference for tool loading mode
    const useDeferredLoading = appSettings.toolLoadingMode !== "always";

    // Load tools needed for this request:
    // - Non-deferred (alwaysLoad) tools: searchTools, listAllTools, retrieveFullContent, describeImage
    // - Previously discovered tools (from session metadata)
    // - If toolLoadingMode="always": all agent-enabled tools load upfront
    // - If toolLoadingMode="deferred": agent-enabled tools require discovery via searchTools
    //
    // CRITICAL: We always set includeDeferredTools: true here so that all AUTHORIZED tools
    // are present in the 'tools' implementation map. Their visibility to the AI is 
    // strictly controlled by 'activeTools'. This ensures discovered tools have schemas.
    const allTools = registry.getTools({
      sessionId,
      characterAvatarUrl: characterAvatarUrl || undefined,
      characterAppearanceDescription: characterAppearanceDescription || undefined,
      // agentEnabledTools filter handles which tools can actually be used
      agentEnabledTools,
      // Always include authorized tools in implementation map for AI SDK consistency
      includeDeferredTools: true,
    });

    // Mutable set to track tools discovered via searchTools during this request
    // When searchTools finds a deferred tool, it adds it here
    // The prepareStep callback reads this to dynamically enable discovered tools
    // NOTE: previouslyDiscoveredTools was already loaded above for includeTools
    const discoveredTools = new Set<string>(previouslyDiscoveredTools);

    if (previouslyDiscoveredTools.size > 0) {
      console.log(`[CHAT API] Restored ${previouslyDiscoveredTools.size} previously discovered tools: ${[...previouslyDiscoveredTools].join(", ")}`);
    }


    // Context for search/list tools
    // CRITICAL: Pass enabledTools to filter search results by agent-specific permissions
    const toolSearchContext = {
      initialActiveTools,
      discoveredTools,
      enabledTools: enabledTools ? new Set(enabledTools) : undefined,
    };

    // Build tools object with context-aware overrides
    // CRITICAL: Only add overrides for tools that were already loaded via registry
    // This ensures deferred loading is respected - we don't add tools that should be discovered
    const tools: Record<string, Tool> = {
      ...allTools,
      // searchTools and listAllTools ALWAYS override (they're alwaysLoad: true)
      searchTools: createToolSearchTool(toolSearchContext),
      listAllTools: createListToolsTool(toolSearchContext),
      // retrieveFullContent ALWAYS overrides (alwaysLoad: true)
      retrieveFullContent: createRetrieveFullContentTool({
        sessionId,
      }),
      // Context-aware overrides: ONLY add if the tool was loaded in allTools
      // This respects deferred loading - tools must be discovered first
      ...(allTools.docsSearch && {
        docsSearch: createDocsSearchTool({
          userId: dbUser.id,
          characterId: characterId || null,
        }),
      }),
      ...(allTools.vectorSearch && {
        vectorSearch: createVectorSearchToolV2({
          sessionId,
          userId: dbUser.id,
          characterId: characterId || null,
        }),
      }),
      ...(allTools.readFile && {
        readFile: createReadFileTool({
          sessionId,
          userId: dbUser.id,
          characterId: characterId || null,
        }),
      }),
      ...(allTools.localGrep && {
        localGrep: createLocalGrepTool({
          sessionId,
          characterId: characterId || null,
        }),
      }),
      ...(allTools.webSearch && {
        webSearch: createWebSearchTool({
          userId: dbUser.id,
          characterId: characterId || null,
        }),
      }),
      ...(allTools.webBrowse && {
        webBrowse: createWebBrowseTool({
          sessionId,
          userId: dbUser.id,
          characterId: characterId || null,
        }),
      }),
      ...(allTools.webQuery && {
        webQuery: createWebQueryTool({
          sessionId,
          userId: dbUser.id,
          characterId: characterId || null,
        }),
      }),
      ...(allTools.executeCommand && {
        executeCommand: createExecuteCommandTool({
          sessionId,
          characterId: characterId || null,
        }),
      }),
    };

    // Load MCP tools for this character (if configured)
    let mcpTools: Record<string, Tool> = {};
    try {
      const { loadMCPToolsForCharacter } = await import("@/lib/mcp/chat-integration");
      const character = characterId ? await getCharacterFull(characterId) : undefined;
      mcpTools = await loadMCPToolsForCharacter(character || undefined);

      if (Object.keys(mcpTools).length > 0) {
        console.log(`[CHAT API] Loaded ${Object.keys(mcpTools).length} MCP tools: ${Object.keys(mcpTools).join(", ")}`);

        // CRITICAL: Ensure MCP tools are strictly allowed in searchTools and listAllTools
        // If enabledTools is configured (agent has specific tool list), we MUST add MCP tools to it
        // otherwise they will be hidden from discovery.
        if (toolSearchContext.enabledTools) {
          Object.keys(mcpTools).forEach(name => toolSearchContext.enabledTools!.add(name));
          console.log(`[CHAT API] Added ${Object.keys(mcpTools).length} MCP tools to enabledTools set for discovery`);
        }
      }
    } catch (error) {
      console.error("[CHAT API] Failed to load MCP tools:", error);
    }

    // Merge MCP tools with regular tools
    const allToolsWithMCP = {
      ...tools,
      ...mcpTools,
    };


    // Build the initial activeTools array (tool names that are active from the start)
    // When toolLoadingMode="always", ALL tools are active from step 0 (no discovery needed)
    // When toolLoadingMode="deferred", only non-deferred + previously discovered tools are active
    const initialActiveToolNames = useDeferredLoading
      ? [...new Set([...initialActiveTools, ...previouslyDiscoveredTools])]
      : Object.keys(allToolsWithMCP); // "Always Include" mode: all tools active immediately

    console.log(`[CHAT API] Loaded ${Object.keys(allToolsWithMCP).length} tools (including ${Object.keys(mcpTools).length} MCP tools): ${Object.keys(allToolsWithMCP).join(", ")}`);
    console.log(`[CHAT API] Tool loading mode: ${useDeferredLoading ? "deferred" : "always-include"}, initial active tools: ${initialActiveToolNames.length}`);
    if (useDeferredLoading) {
      console.log(`[CHAT API] Previously discovered (restored): ${previouslyDiscoveredTools.size > 0 ? [...previouslyDiscoveredTools].join(", ") : "none"}`);
    }

    // Stream the response using the configured provider
    // Wrap with run context for observability (tool events will be linked to this run)
    // System prompt is conditionally included to reduce token usage (first message + periodic re-injection)
    // NOTE: Tools MUST always be passed - unlike system prompt, tools are function definitions
    // that must be present for the AI to actually invoke them. Without tools, AI just outputs fake tool calls.
    console.log(`[CHAT API] Using LLM: ${getProviderDisplayName()}, system prompt injected: ${injectContext}`);
    let runFinalized = false;
    const result = await withRunContext(
      {
        runId: agentRun.id,
        sessionId,
        pipelineName: "chat",
        characterId: characterId || undefined,
      },
      async () => streamText({
        model: getLanguageModel(),
        // Conditionally include system prompt to reduce token usage
        // It's sent on first message, then periodically based on token/message thresholds
        ...(injectContext && { system: systemPrompt }),
        messages: coreMessages,
        // Tools MUST always be passed - they are function definitions required for actual invocation
        tools: allToolsWithMCP,
        // Use activeTools to control which tools are visible to the model
        // Initially: non-deferred tools + previously discovered tools from session metadata
        activeTools: initialActiveToolNames as (keyof typeof allToolsWithMCP)[],
        abortSignal: req.signal,
        stopWhen: stepCountIs(AI_CONFIG.maxSteps),
        // Use slightly lower temperature when tools are available to reduce
        // "fake tool call" issues where model outputs tool syntax as text
        // Tool operations benefit from more deterministic behavior
        temperature: initialActiveToolNames.length > 0 ? AI_CONFIG.toolTemperature : AI_CONFIG.temperature,
        // Tool choice: "auto" allows model to decide between tools and text
        // Could be set to "required" to force tool use, but "auto" is more flexible
        toolChoice: AI_CONFIG.toolChoice,
        // prepareStep is called before each step - we use it to dynamically enable discovered tools
        //
        // CRITICAL FIX for timing gap bug:
        // Tools discovered via searchTools in step N should be available in step N+1.
        // We ensure consistency by ALWAYS combining:
        // 1. initialActiveTools (non-deferred, always-load tools)
        // 2. previouslyDiscoveredTools (tools from previous requests, restored from session)
        // 3. discoveredTools (tools discovered in THIS request, includes previouslyDiscoveredTools)
        //
        // The discoveredTools Set is initialized with previouslyDiscoveredTools, so spreading it
        // is sufficient to include both. But we explicitly deduplicate for safety.
        prepareStep: async ({ stepNumber }) => {
          // Build the active tools list by combining all sources
          // Use a Set to deduplicate in case tools appear in multiple sources
          let activeToolSet: Set<string>;

          if (useDeferredLoading) {
            // Deferred mode: start with core + restored + newly discovered in this request
            activeToolSet = new Set<string>([
              ...initialActiveTools,          // Non-deferred, always-load tools
              ...previouslyDiscoveredTools,   // Tools from previous requests (session metadata)
              ...discoveredTools,             // Tools discovered in this request (may overlap with previous)
            ]);
          } else {
            // "Always Include" mode: ALL available tools should be active from step 0
            activeToolSet = new Set<string>(Object.keys(allToolsWithMCP));
          }

          // Dynamically inject retrieveFullContent if session has truncated content
          // This prevents agent from misusing the tool when no truncation has occurred
          if (sessionHasTruncatedContent(sessionId) && !activeToolSet.has("retrieveFullContent")) {
            activeToolSet.add("retrieveFullContent");
          }

          const currentActiveTools = [...activeToolSet];

          // Log only when tools change (for debugging)
          if (stepNumber === 0) {
            console.log(`[CHAT API] Step 0: Starting with ${currentActiveTools.length} active tools (mode: ${useDeferredLoading ? "deferred" : "always-include"})`);
          } else if (useDeferredLoading && discoveredTools.size > previouslyDiscoveredTools.size) {
            // New tools were discovered in a previous step (only relevant in deferred mode)
            const newlyDiscovered = [...discoveredTools].filter(t => !previouslyDiscoveredTools.has(t));
            if (newlyDiscovered.length > 0) {
              console.log(`[CHAT API] Step ${stepNumber}: Active tools now include newly discovered: ${newlyDiscovered.join(", ")}`);
            }
          }

          return {
            activeTools: currentActiveTools as (keyof typeof tools)[],
          };
        },
        onFinish: async ({ text, steps, usage }) => {
          if (runFinalized) return;
          runFinalized = true;
          // Save assistant message to database
          // Build content by iterating through steps in order to preserve
          // the interleaved sequence: tool-call → tool-result → text per step
          const content: Array<{ type: string; text?: string; toolCallId?: string; toolName?: string; args?: unknown; result?: unknown }> = [];

          if (steps && steps.length > 0) {
            for (const step of steps) {
              // Add tool calls from this step (if any)
              if (step.toolCalls) {
                for (const call of step.toolCalls) {
                  content.push({
                    type: "tool-call",
                    toolCallId: call.toolCallId,
                    toolName: call.toolName,
                    args: call.input,
                  });
                }
              }

              // Add tool results from this step (if any)
              if (step.toolResults) {
                for (const res of step.toolResults) {
                  content.push({
                    type: "tool-result",
                    toolCallId: res.toolCallId,
                    result: res.output,
                  });
                }
              }

              // Add text from this step (if any and non-empty)
              if (step.text?.trim()) {
                content.push({ type: "text", text: step.text });
              }
            }
          }

          // Fallback: if no steps but we have final text, add it
          // (this handles simple responses without tool calls)
          if (content.length === 0 && text?.trim()) {
            content.push({ type: "text", text });
          }

          // DEFENSIVE CHECK: Detect "fake tool calls" where model outputs tool syntax as text
          // This helps monitor for cases where the model attempts to call a tool but outputs
          // the invocation as plain text instead of using structured tool calls.
          // Pattern matches: toolName{"param": "value"} or toolName{ ... }
          const fakeToolCallPattern = /\b([a-zA-Z][a-zA-Z0-9]*)\s*\{[\s\S]*?"[^"]+"\s*:/;
          for (const step of steps || []) {
            if (step.text && fakeToolCallPattern.test(step.text)) {
              const match = step.text.match(fakeToolCallPattern);
              const suspectedToolName = match?.[1] || "unknown";
              const textSnippet = step.text.substring(0, 150).replace(/\n/g, " ");
              console.warn(
                `[CHAT API] FAKE TOOL CALL DETECTED: Model output tool-like syntax as text. ` +
                `Suspected tool: "${suspectedToolName}". Text: "${textSnippet}..."`
              );
              // Log additional context for debugging
              console.warn(
                `[CHAT API] Fake tool call context: ` +
                `activeTools at start: ${initialActiveToolNames.length}, ` +
                `discoveredTools: ${discoveredTools.size}, ` +
                `previouslyDiscovered: ${previouslyDiscoveredTools.size}`
              );
            }
          }

          await createMessage({
            sessionId,
            role: "assistant",
            content: content,  // Always store as array for consistency
            model: AI_CONFIG.model,
            tokenCount: usage?.totalTokens,
            metadata: usage ? {
              usage: {
                inputTokens: usage.inputTokens,
                outputTokens: usage.outputTokens,
                totalTokens: usage.totalTokens,
              }
            } : {},
          });

          // Trigger memory extraction in background (only for character-specific chats)
          if (characterId) {
            triggerExtraction(characterId, sessionId).catch((err) => {
              console.error("[CHAT API] Memory extraction error:", err);
            });
          }

          // Complete the agent run with success
          await completeAgentRun(agentRun.id, "succeeded", {
            stepCount: steps?.length || 0,
            toolCallCount: steps?.reduce((acc, s) => acc + (s.toolCalls?.length || 0), 0) || 0,
            usage: usage ? {
              inputTokens: usage.inputTokens,
              outputTokens: usage.outputTokens,
              totalTokens: usage.totalTokens,
            } : undefined,
          });

          // Update context injection tracking in session metadata
          // If we injected context (system prompt + tools), reset the counters
          // Otherwise, increment them based on this request's usage
          const tokensUsedThisRequest = usage?.totalTokens || 0;
          let newTracking: ContextInjectionTrackingMetadata;

          if (injectContext) {
            // Reset counters after context injection
            newTracking = {
              tokensSinceLastInjection: tokensUsedThisRequest,
              messagesSinceLastInjection: 1, // Count this message
              lastInjectedAt: new Date().toISOString(),
              toolLoadingMode,
            };
          } else {
            // Increment existing counters
            const currentTracking = contextTracking || {
              tokensSinceLastInjection: 0,
              messagesSinceLastInjection: 0,
            };
            newTracking = {
              tokensSinceLastInjection: currentTracking.tokensSinceLastInjection + tokensUsedThisRequest,
              messagesSinceLastInjection: currentTracking.messagesSinceLastInjection + 1,
              lastInjectedAt: currentTracking.lastInjectedAt,
              toolLoadingMode: currentTracking.toolLoadingMode ?? toolLoadingMode,
            };
          }

          // Persist newly discovered tools to session metadata
          // This ensures tools discovered via searchTools remain active in subsequent requests
          let discoveredToolsMetadata: DiscoveredToolsMetadata | undefined;
          if (discoveredTools.size > 0) {
            discoveredToolsMetadata = {
              toolNames: [...discoveredTools],
              lastUpdatedAt: new Date().toISOString(),
            };
          }

          // Update session metadata with tracking and discovered tools
          const updatedSession = await updateSession(sessionId, {
            metadata: {
              ...sessionMetadata,
              contextInjectionTracking: newTracking,
              ...(discoveredToolsMetadata && { discoveredTools: discoveredToolsMetadata }),
            },
          });

          console.log(`[CHAT API] session metadata updated: ${!!updatedSession}`);
          if (updatedSession) {
            const updatedMeta = updatedSession.metadata as Record<string, any>;
            console.log(`[CHAT API] updated metadata keys: ${Object.keys(updatedMeta).join(", ")}`);
            console.log(`[CHAT API] updated discoveredTools: ${JSON.stringify(updatedMeta.discoveredTools)}`);
          }

        },
        onAbort: async ({ steps }) => {
          if (runFinalized) return;
          runFinalized = true;
          try {
            const interruptionTimestamp = new Date();

            // === SAVE PARTIAL ASSISTANT MESSAGE (FIX) ===
            // Build content from completed steps using the same logic as onFinish
            // This preserves the partial response so AI has context on subsequent messages
            const content: Array<{ type: string; text?: string; toolCallId?: string; toolName?: string; args?: unknown; result?: unknown }> = [];

            if (steps && steps.length > 0) {
              for (const step of steps) {
                // Add tool calls from this step (if any)
                if (step.toolCalls) {
                  for (const call of step.toolCalls) {
                    content.push({
                      type: "tool-call",
                      toolCallId: call.toolCallId,
                      toolName: call.toolName,
                      args: call.input,
                    });
                  }
                }

                // Add tool results from this step (if any)
                if (step.toolResults) {
                  for (const res of step.toolResults) {
                    content.push({
                      type: "tool-result",
                      toolCallId: res.toolCallId,
                      result: res.output,
                    });
                  }
                }

                // Add text from this step (if any and non-empty)
                if (step.text?.trim()) {
                  content.push({ type: "text", text: step.text });
                }
              }
            }

            // Save partial assistant message IF there was any content generated
            if (content.length > 0) {
              await createMessage({
                sessionId,
                role: "assistant",
                content: content,
                model: AI_CONFIG.model,
                metadata: { interrupted: true }, // Mark as partial/interrupted response
              });
              console.log(`[CHAT API] Saved partial assistant message (${content.length} parts) before interruption`);
            }
            // === END FIX ===

            // Save system interruption message (existing behavior)
            await createMessage({
              sessionId,
              role: "system",
              content: [
                {
                  type: "text",
                  text: buildInterruptionMessage("chat", interruptionTimestamp),
                },
              ],
              metadata: buildInterruptionMetadata("chat", interruptionTimestamp),
            });

            await completeAgentRun(agentRun.id, "cancelled", {
              reason: "user_cancelled",
              stepCount: steps.length,
            });

            await appendRunEvent({
              runId: agentRun.id,
              eventType: "run_completed",
              level: "info",
              pipelineName: "chat",
              data: { status: "cancelled", reason: "user_cancelled", stepCount: steps.length },
            });
          } catch (error) {
            console.error("[CHAT API] Failed to record cancellation:", error);
          }
        },
      })
    ); // End withRunContext

    // Return streaming response with session ID header
    // AI SDK v5: use toUIMessageStreamResponse for assistant-ui compatibility
    // messageMetadata extracts token usage from finish-step events to send to client
    const response = result.toUIMessageStreamResponse({
      messageMetadata: ({ part }) => {
        // finish-step includes usage: LanguageModelUsage
        if (part.type === 'finish-step' && part.usage) {
          return {
            custom: {
              usage: {
                inputTokens: part.usage.inputTokens,
                outputTokens: part.usage.outputTokens,
                totalTokens: part.usage.totalTokens,
              },
            },
          };
        }
        return undefined;
      },
    });
    response.headers.set("X-Session-Id", sessionId);
    return response;
  } catch (error) {
    console.error("Chat API error:", error);

    // Note: agentRun may not exist if error occurred before run creation
    // The run will remain in "running" status and can be cleaned up by a background job
    // or marked as failed on next request if we track the run ID

    return new Response(
      JSON.stringify({
        error: error instanceof Error ? error.message : "Internal server error",
      }),
      {
        status: 500,
        headers: { "Content-Type": "application/json" },
      }
    );
  }
}

function getPlainTextFromContent(content: unknown): string {
  if (typeof content === "string") {
    return content;
  }

  if (Array.isArray(content)) {
    return content
      .map((part) => {
        if (typeof part === "string") {
          return part;
        }

        if (part && typeof part === "object" && "text" in part) {
          return String((part as { text?: unknown }).text ?? "");
        }

        return "";
      })
      .join(" ")
      .trim();
  }

  return "";
}
