import type { BaseMessage } from '@langchain/core/messages';
import { HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages';
import type { FastifyBaseLogger } from 'fastify';
import type { License } from '../types/user.js';
import { ChannelType } from '../types/user.js';
import type { ConversationStore } from './memory/conversation-store.js';
import type { BlobStore } from './memory/blob-store.js';
import type { InboundMessage, OutboundMessage } from '../types/messages.js';
import { MCPClientConnector } from './mcp-client.js';
import { LLMProviderFactory, type ProviderConfig } from '../llm/provider.js';
import { ModelRouter, RoutingStrategy } from '../llm/router.js';
import type { ModelMiddleware } from '../llm/middleware.js';
import type { WorkspaceManager } from '../workspace/workspace-manager.js';
import type { ChannelAdapter } from '../workspace/index.js';
import type { DynamicStructuredTool } from '@langchain/core/tools';
import { getToolRegistry } from '../tools/tool-registry.js';
import type { MCPToolInfo } from '../tools/mcp/mcp-tool-wrapper.js';
import { createSpawnTool } from '../tools/platform/spawn.tool.js';
import { createMemoryLookupTool } from '../tools/platform/memory-lookup.tool.js';
import { WikiLoader } from './spawn/wiki-loader.js';
import { SpawnService } from './spawn/spawn-service.js';
import type { HarnessEvent } from './harness-events.js';
import { getToolLabel } from './tool-labels.js';
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

/**
 * Session-specific config provided by channel handlers.
 * Contains only per-connection details — no infrastructure dependencies.
 */
export interface HarnessSessionConfig {
  userId: string;
  sessionId: string;
  license: License;
  mcpServerUrl: string;
  logger: FastifyBaseLogger;
  workspaceManager?: WorkspaceManager;
  channelAdapter?: ChannelAdapter;
  channelType?: ChannelType;
  channelUserId?: string;
}

/**
 * Factory function type for creating AgentHarness instances.
 * Created in main.ts with infrastructure (storage, providerConfig) captured in closure.
 * Channel handlers call this factory without knowing about Redis or Iceberg.
 */
export type HarnessFactory = (sessionConfig: HarnessSessionConfig) => AgentHarness;

export interface AgentHarnessConfig extends HarnessSessionConfig {
  providerConfig: ProviderConfig;
  conversationStore?: ConversationStore;
  blobStore?: BlobStore;
  historyLimit: number;
}

/**
 * Agent harness orchestrates between LLM and user's MCP server.
 *
 * This is a STATELESS orchestrator - all conversation history, RAG, and context
 * lives in the user's MCP server container. The harness only:
 * 1. Fetches context from user's MCP resources
 * 2. Routes to appropriate LLM model
 * 3. Calls LLM with embedded context
 * 4. Routes tool calls to platform tools or user's MCP tools
 * 5. Saves messages back to user's MCP
 */
export class AgentHarness {
  private static welcomePrompt: string | null = null;

  private config: AgentHarnessConfig;
  private modelFactory: LLMProviderFactory;
  private modelRouter: ModelRouter;
  private middleware: ModelMiddleware | undefined;
  private mcpClient: MCPClientConnector;
  private workspaceManager?: WorkspaceManager;
  private channelAdapter?: ChannelAdapter;
  private availableMCPTools: MCPToolInfo[] = [];
  private conversationStore?: ConversationStore;
  private blobStore?: BlobStore;
  private abortController: AbortController | null = null;
  private wikiLoader: WikiLoader;
  private spawnService: SpawnService;

  constructor(config: AgentHarnessConfig) {
    this.config = config;
    this.workspaceManager = config.workspaceManager;
    this.channelAdapter = config.channelAdapter;

    this.modelFactory = new LLMProviderFactory(config.providerConfig, config.logger);
    this.modelRouter = new ModelRouter(this.modelFactory, config.logger);
    this.conversationStore = config.conversationStore;
    this.blobStore = config.blobStore;

    this.mcpClient = new MCPClientConnector({
      userId: config.userId,
      mcpServerUrl: config.mcpServerUrl,
      logger: config.logger,
    });

    this.wikiLoader = new WikiLoader();
    this.spawnService = new SpawnService(
      this.wikiLoader,
      getToolRegistry(),
      async (maxTokens?: number) => {
        const { model } = await this.modelRouter.route(
          'analyze and backtest research data',
          this.config.license,
          RoutingStrategy.COMPLEXITY,
          this.config.userId,
          maxTokens,
        );
        return model;
      },
      config.logger,
    );

    // Register the custom-indicators virtual wiki page
    this.wikiLoader.registerVirtual('custom-indicators', async (ctx) => {
      if (!ctx.mcpClient) return '';
      return this.fetchCustomIndicatorsSection(ctx.mcpClient);
    });

    // Register conda environment packages as a virtual wiki page
    this.wikiLoader.registerVirtual('conda-environment', async (ctx) => {
      if (!ctx.mcpClient) return '';
      try {
        const uri = `dexorder://user/${ctx.mcpClient.userId}/environment.yml`;
        const resource = await ctx.mcpClient.readResource(uri);
        if (!resource.text) return '';
        return `## Available Python Packages (Conda Environment)\n\nThe following packages are pre-installed and available to all scripts:\n\n\`\`\`yaml\n${resource.text}\n\`\`\``;
      } catch {
        return '';
      }
    });

    // Register the user-preferences virtual wiki page (loaded fresh each turn)
    this.wikiLoader.registerVirtual('user-preferences', async (ctx) => {
      if (!ctx.mcpClient) return '';
      try {
        const result = await ctx.mcpClient.callTool('PreferencesRead', {});
        const parsed = JSON.parse(String(result));
        if (!parsed.exists || !parsed.content?.trim()) return '';
        return `## User Preferences\n\n${parsed.content}`;
      } catch {
        return '';
      }
    });
  }

  /**
   * Load welcome prompt from file (cached)
   */
  private static async loadWelcomePrompt(): Promise<string> {
    if (!AgentHarness.welcomePrompt) {
      const promptPath = join(__dirname, 'prompts', 'welcome.md');
      AgentHarness.welcomePrompt = (await readFile(promptPath, 'utf-8')).trim();
    }
    return AgentHarness.welcomePrompt;
  }

  /**
   * Set the channel adapter (can be called after construction)
   */
  setChannelAdapter(adapter: ChannelAdapter): void {
    this.channelAdapter = adapter;
  }

  interrupt(): void {
    this.abortController?.abort();
  }

  /**
   * Initialize harness and connect to user's MCP server
   */
  async initialize(): Promise<void> {
    this.config.logger.info(
      { userId: this.config.userId, sessionId: this.config.sessionId },
      'Initializing agent harness'
    );

    try {
      await this.mcpClient.connect();

      // Discover available MCP tools from user's server
      await this.discoverMCPTools();

      this.config.logger.info('Agent harness initialized');
    } catch (error) {
      this.config.logger.error({ error }, 'Failed to initialize agent harness');
      throw error;
    }
  }

  /**
   * Discover available MCP tools from user's server
   */
  private async discoverMCPTools(): Promise<void> {
    try {
      this.config.logger.debug('Discovering MCP tools from user server');

      // Call MCP client to list tools
      const tools = await this.mcpClient.listTools();

      // Convert to MCPToolInfo format
      this.availableMCPTools = tools.map(tool => ({
        name: tool.name,
        description: tool.description,
        inputSchema: tool.inputSchema as any,
      }));

      this.config.logger.info(
        {
          toolCount: this.availableMCPTools.length,
          toolNames: this.availableMCPTools.map(t => t.name),
        },
        'MCP tools discovered'
      );
    } catch (error) {
      this.config.logger.warn(
        {
          error,
          errorMessage: (error as Error)?.message,
          errorName: (error as Error)?.name,
          errorCode: (error as any)?.code,
        },
        'Failed to discover MCP tools - continuing without remote tools'
      );
      // Don't throw - MCP tools are optional, agent can still work with platform tools
      this.availableMCPTools = [];
    }
  }

  /**
   * Fetch custom indicators from the sandbox and return a formatted markdown section.
   * Used as the virtual wiki page 'custom-indicators'.
   */
  private async fetchCustomIndicatorsSection(mcpClient: MCPClientConnector): Promise<string> {
    try {
      const raw = await mcpClient.callTool('PythonList', { category: 'indicator' });
      const r = raw as any;
      const text = r?.content?.[0]?.text ?? r?.[0]?.text;
      const parsed = typeof text === 'string' ? JSON.parse(text) : raw;
      const items: any[] = parsed?.items ?? [];
      if (items.length === 0) return '';

      const lines: string[] = ['\n\n## Custom Indicators\n'];
      lines.push('The user has defined the following custom indicators. Use `ta.custom_<name>` where `<name>` is the lowercase sanitized function name shown below.\n');

      for (const item of items) {
        const displayName: string = item.name ?? 'unknown';
        const description: string = item.description ?? '';
        const meta: any = item.metadata ?? {};
        const taAttr = `custom_${displayName.toLowerCase().replace(/[^\w]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '')}`;
        const inputSeries: string[] = meta.input_series ?? ['close'];
        const params: Record<string, any> = meta.parameters ?? {};
        const pane: string = meta.pane ?? 'separate';

        const inputStr = inputSeries.map((s: string) => `df['${s}']`).join(', ');
        const paramStr = Object.entries(params)
          .map(([k, v]: [string, any]) => `${k}=${JSON.stringify(v?.default ?? null)}`)
          .join(', ');
        const callExample = paramStr
          ? `ta.${taAttr}(${inputStr}, ${paramStr})`
          : `ta.${taAttr}(${inputStr})`;

        const outputNames = (meta.output_columns ?? [{ name: 'value' }])
          .map((c: any) => c.name)
          .join(', ');

        lines.push(`### ${displayName}`);
        if (description) lines.push(description);
        lines.push(`- **Call**: \`${callExample}\``);
        lines.push(`- **Outputs**: ${outputNames}  |  **Pane**: ${pane}`);
        lines.push('');
      }

      return lines.join('\n');
    } catch (err) {
      this.config.logger.warn({ err }, 'Failed to fetch custom indicators for wiki page');
      return '';
    }
  }

  /**
   * Execute model with tool calling loop
   * Handles multi-turn tool calls until the model produces a final text response
   */
  private async *executeWithToolCalling(
    model: any,
    messages: BaseMessage[],
    tools: DynamicStructuredTool[],
    maxIterations: number = 2,
    signal?: AbortSignal
  ): AsyncGenerator<HarnessEvent> {
    this.config.logger.info(
      { toolCount: tools.length, maxIterations },
      'Starting tool calling loop'
    );

    const messagesCopy = [...messages];
    let iterations = 0;

    while (iterations < maxIterations) {
      if (signal?.aborted) break;
      iterations++;
      this.config.logger.info(
        {
          iteration: iterations,
          messageCount: messagesCopy.length,
          lastMessageType: messagesCopy[messagesCopy.length - 1]?.constructor.name,
        },
        'Tool calling loop iteration'
      );

      this.config.logger.debug('Streaming model response...');
      let response: any = null;
      try {
        const stream = await model.stream(messagesCopy, { signal });
        for await (const chunk of stream) {
          if (typeof chunk.content === 'string' && chunk.content.length > 0) {
            this.config.logger.trace({ content: chunk.content }, 'raw chunk');
            yield { type: 'chunk', content: chunk.content };
          } else if (Array.isArray(chunk.content)) {
            for (const block of chunk.content) {
              if (block.type === 'text' && block.text) {
                this.config.logger.trace({ content: block.text }, 'raw chunk');
                yield { type: 'chunk', content: block.text };
              }
            }
          }
          response = response ? response.concat(chunk) : chunk;
        }
      } catch (invokeError: any) {
        this.config.logger.error(
          {
            error: invokeError,
            errorMessage: invokeError?.message,
            errorStack: invokeError?.stack,
            iteration: iterations,
            messageCount: messagesCopy.length,
          },
          'Model streaming failed in tool calling loop'
        );
        throw invokeError;
      }

      this.config.logger.info(
        {
          hasContent: !!response.content,
          contentLength: typeof response.content === 'string' ? response.content.length : 0,
          hasToolCalls: !!response.tool_calls,
          toolCallCount: response.tool_calls?.length || 0,
          usageMetadata: (response as any).usage_metadata,
          finishReason: (response as any).response_metadata?.finish_reason,
        },
        'Model response received'
      );

      // Check if model wants to call tools
      if (!response.tool_calls || response.tool_calls.length === 0) {
        // No tool calls - return final response
        let finalContent: string;
        if (typeof response.content === 'string') {
          finalContent = response.content;
        } else if (Array.isArray(response.content)) {
          finalContent = response.content
            .filter((block: any) => block.type === 'text')
            .map((block: any) => block.text || '')
            .join('');
        } else {
          finalContent = JSON.stringify(response.content);
        }
        this.config.logger.info(
          { finalContentLength: finalContent.length, iterations },
          'Tool calling loop complete - no more tool calls'
        );
        yield { type: 'done', content: finalContent };
        return;
      }

      this.config.logger.info(
        { toolCalls: response.tool_calls.map((tc: any) => tc.name) },
        'Processing tool calls'
      );

      // Add assistant message with tool calls to history
      messagesCopy.push(response);

      // Execute each tool call
      for (const toolCall of response.tool_calls) {
        this.config.logger.info(
          { tool: toolCall.name, args: toolCall.args },
          'Executing tool call'
        );

        const tool = tools.find(t => t.name === toolCall.name);

        if (!tool) {
          this.config.logger.warn({ tool: toolCall.name }, 'Tool not found');
          messagesCopy.push(
            new ToolMessage({
              content: `Error: Tool '${toolCall.name}' not found`,
              tool_call_id: toolCall.id,
            })
          );
          continue;
        }

        try {
          yield { type: 'tool_call', toolName: toolCall.name, label: getToolLabel(toolCall.name, toolCall.args) };

          // Use streamFunc when available (subagent tools) to forward intermediate events inline
          let result: string;
          const streamFunc = (tool as any).streamFunc as ((args: any, signal?: AbortSignal) => AsyncGenerator<import('./harness-events.js').HarnessEvent, string>) | undefined;
          if (streamFunc) {
            const gen = streamFunc(toolCall.args, signal);
            let next = await gen.next();
            while (!next.done) {
              if (signal?.aborted) {
                gen.return?.('');
                break;
              }
              yield next.value;
              next = await gen.next();
            }
            result = next.done ? next.value : '';
          } else {
            result = await tool.func(toolCall.args);
          }

          // Extract images from result and yield them; get text-only version for LLM
          const { cleanedResult: processedResult, images } = this.extractImagesFromToolResult(result, toolCall.name);
          for (const img of images) {
            yield { type: 'image', data: img.data, mimeType: img.mimeType, caption: img.caption };
          }

          this.config.logger.debug(
            {
              tool: toolCall.name,
              originalResultLength: result.length,
              processedResultLength: processedResult.length,
            },
            'Tool result processed'
          );

          messagesCopy.push(
            new ToolMessage({
              content: processedResult,
              tool_call_id: toolCall.id,
            })
          );

          this.config.logger.info(
            { tool: toolCall.name, resultLength: processedResult.length },
            'Tool execution completed'
          );
        } catch (error) {
          // Clean stop — abort signal fired during tool execution; exit without error message
          if (signal?.aborted || (error as Error)?.name === 'AbortError') {
            this.config.logger.info({ tool: toolCall.name }, 'Tool execution aborted by stop signal');
            return;
          }

          this.config.logger.error(
            {
              error,
              errorMessage: (error as Error)?.message,
              errorStack: (error as Error)?.stack,
              tool: toolCall.name,
              args: toolCall.args,
            },
            'Tool execution failed'
          );

          yield { type: 'error' as const, source: toolCall.name, fatal: false };

          messagesCopy.push(
            new ToolMessage({
              content: `Error: ${error}`,
              tool_call_id: toolCall.id,
            })
          );
        }
      }

      // After all tool calls complete, emit a space separator before the next LLM streaming pass
      yield { type: 'chunk', content: ' ' };
    }

    // Max iterations reached - yield done with apology
    this.config.logger.warn('Max tool calling iterations reached');
    yield { type: 'done', content: 'I apologize, but I encountered an issue processing your request. Please try rephrasing your question.' };
  }

  /**
   * Call a tool on the user's MCP server directly (bypasses the agent/LLM).
   * Used by channel handlers for direct data requests (e.g. EvaluateIndicator).
   */
  async callMcpTool(name: string, args: Record<string, unknown>): Promise<unknown> {
    return this.mcpClient.callTool(name, args);
  }

  /**
   * Read the `details` field for a category item directly from the sandbox.
   * Returns null if the item doesn't exist or has no details.
   */
  async readDetails(category: string, name: string): Promise<string | null> {
    try {
      const raw = await this.mcpClient.callTool('PythonRead', { category, name });
      const content = (raw as any)?.content;
      if (!Array.isArray(content)) return null;
      for (const item of content) {
        if (item.type === 'text' && item.text) {
          try {
            const parsed = JSON.parse(item.text);
            if (parsed?.exists && parsed?.details !== undefined) {
              return parsed.details as string;
            }
          } catch { /* ignore */ }
        }
      }
      return null;
    } catch {
      return null;
    }
  }

  /**
   * Read persisted output files (analysis + images) for a research item.
   * Returns null if the item has no output yet.
   */
  async readOutput(
    category: string,
    name: string,
  ): Promise<{ analysis?: string; images?: Array<{ mimeType: string; data: string }> } | null> {
    try {
      const raw = await this.mcpClient.callTool('PythonReadOutput', { category, name });
      const content = (raw as any)?.content;
      if (!Array.isArray(content)) return null;
      const result: { analysis?: string; images?: Array<{ mimeType: string; data: string }> } = {};
      for (const item of content) {
        if (item.type === 'text' && item.text && !item.text.startsWith('output_dir:')) {
          result.analysis = item.text;
        } else if (item.type === 'image' && item.data) {
          (result.images ??= []).push({ mimeType: item.mimeType ?? 'image/png', data: item.data });
        }
      }
      return result;
    } catch {
      return null;
    }
  }

  /**
   * Stream a details-driven code update for a category item.
   *
   * Computes a unified diff between the stored details and `newDetails`,
   * then instructs the appropriate subagent to update the Python code to
   * match the revised specification.  Yields HarnessEvents from the subagent
   * so the WebSocket handler can stream progress to the web client.
   */
  async *streamDetailsUpdate(
    category: string,
    name: string,
    newDetails: string,
    signal?: AbortSignal,
  ): AsyncGenerator<HarnessEvent> {
    const logger = this.config.logger;

    // 1. Read current details
    const oldDetails = await this.readDetails(category, name) ?? '';

    // 2. Compute a simple unified diff
    const diff = buildUnifiedDiff(oldDetails, newDetails, `${category}/${name} details`);

    if (!diff.trim()) {
      // No change — nothing to do
      yield { type: 'done', content: 'No changes detected in the details.' };
      return;
    }

    // 3. Build instruction for the subagent
    const instruction = buildDetailsUpdateInstruction(category, name, newDetails, diff);

    // 4. Determine the agent name for this category
    const agentName = category === 'indicator' ? 'indicator'
      : category === 'strategy' ? 'strategy'
      : category === 'research' ? 'research'
      : null;

    if (!agentName) {
      yield { type: 'error', source: 'harness', fatal: false };
      return;
    }

    // 5. Delegate to SpawnService
    logger.info({ category, name, agentName }, 'Streaming details update via spawn');
    const gen = this.spawnService.streamSpawn({
      agentName,
      instruction,
      mcpClient: this.mcpClient,
      availableMCPTools: this.availableMCPTools,
      workspaceManager: this.workspaceManager,
      signal,
    });

    let step = await gen.next();
    while (!step.done) {
      yield step.value;
      step = await gen.next();
    }
    // Final text is the return value of the generator — emit as done event
    const finalText = step.value;
    if (finalText) {
      yield { type: 'done', content: finalText };
    }
  }

  /**
   * Expose MCP client so channel handlers can wire ContainerSync after harness init.
   */
  getMcpClient(): MCPClientConnector {
    return this.mcpClient;
  }

  /**
   * Set workspace manager after construction (used when ContainerSync requires MCP to be connected first).
   */
  setWorkspaceManager(workspace: WorkspaceManager): void {
    this.workspaceManager = workspace;
  }

  /**
   * Stream events for an incoming user message.
   * Yields typed HarnessEvents (chunk, tool_call, image, done) and saves the
   * conversation to the store once the done event has been emitted.
   */
  async *streamMessage(message: InboundMessage, options?: { saveUserMessage?: boolean }): AsyncGenerator<HarnessEvent> {
    this.config.logger.info(
      { messageId: message.messageId, userId: message.userId, content: message.content.substring(0, 100) },
      'Processing user message'
    );

    // 1. Build system prompt from template
    this.config.logger.debug('Building system prompt');
    const systemPrompt = await this.buildSystemPrompt();
    this.config.logger.debug({ systemPromptLength: systemPrompt.length }, 'System prompt built');

    // 2. Load recent conversation history
    const channelKey = this.config.channelType ?? ChannelType.WEBSOCKET;
    let storedMessages = this.conversationStore
      ? await this.conversationStore.getFullHistory(
          this.config.userId, this.config.sessionId, this.config.historyLimit, channelKey
        )
      : [];

    const history = this.conversationStore
      ? this.conversationStore.toLangChainMessages(storedMessages)
      : [];
    this.config.logger.debug({ historyLength: history.length }, 'Conversation history loaded');

    // Inject license info and workspace state as a fresh HumanMessage on every turn
    const licenseInfo = `[License]\nlicenseType: ${this.config.license.licenseType}\nfeatures: ${JSON.stringify(this.config.license.features)}`;
    const workspaceContext = this.workspaceManager
      ? `[Workspace State]\n\`\`\`json\n${this.workspaceManager.serializeState()}\n\`\`\``
      : undefined;

    // Load dynamic imports from agent-main.md frontmatter (fresh per turn, after cache marker)
    const agentMainPage = await this.wikiLoader.loadAgentPage('main').catch(() => null);
    const dynamicContext = agentMainPage?.frontmatter.dynamic_imports?.length
      ? await this.wikiLoader.loadDynamicImports(
          agentMainPage.frontmatter.dynamic_imports,
          { mcpClient: this.mcpClient, workspaceManager: this.workspaceManager ?? undefined }
        )
      : '';

    // 4. Get the configured model
    this.config.logger.debug('Routing to model');
    const { model, middleware } = await this.modelRouter.route(
      message.content,
      this.config.license,
      RoutingStrategy.COMPLEXITY,
      this.config.userId
    );
    this.middleware = middleware;
    this.config.logger.info({ modelName: model.constructor.name }, 'Model selected');

    // 5. Build LangChain messages
    const langchainMessages = this.buildLangChainMessages(systemPrompt, history, licenseInfo, workspaceContext, dynamicContext, message.content);
    this.config.logger.debug({ messageCount: langchainMessages.length }, 'LangChain messages built');

    // 6. Get tools for main agent from registry
    const toolRegistry = getToolRegistry();
    const tools = await toolRegistry.getToolsForAgent(
      'main',
      this.mcpClient,
      this.availableMCPTools,
      this.workspaceManager,
      undefined, // no image callback
      (storeName, newState) => {
        this.workspaceManager?.setState(storeName, newState).catch((err) =>
          this.config.logger.error({ err, storeName }, 'Failed to sync workspace after main agent mutation')
        );
      }
    );

    // Add spawn and memory_lookup tools
    tools.push(createSpawnTool({
      spawnService: this.spawnService,
      mcpClient: this.mcpClient,
      availableMCPTools: this.availableMCPTools,
      workspaceManager: this.workspaceManager,
      logger: this.config.logger,
    }));

    tools.push(createMemoryLookupTool({
      wikiLoader: this.wikiLoader,
      logger: this.config.logger,
    }));

    this.config.logger.info(
      { toolCount: tools.length, toolNames: tools.map(t => t.name) },
      'Tools loaded for main agent'
    );

    // Apply middleware (e.g. Anthropic prompt caching)
    const processedMessages = this.middleware
      ? this.middleware.processMessages(langchainMessages, tools)
      : langchainMessages;

    // 7. Bind tools to model
    const modelWithTools = tools.length > 0 && model.bindTools ? model.bindTools(tools) : model;

    if (tools.length > 0) {
      this.config.logger.info(
        { modelType: modelWithTools.constructor.name, toolsBound: tools.length > 0 && !!model.bindTools },
        'Model bound with tools'
      );
    }

    // 8. Stream tool calling loop and save conversation on completion
    this.config.logger.info('Invoking LLM with tool support');
    this.abortController = new AbortController();
    let finalContent = '';
    const collectedImages: Array<{ data: string; mimeType: string; caption?: string }> = [];
    try {
      for await (const event of this.executeWithToolCalling(modelWithTools, processedMessages, tools, 10, this.abortController.signal)) {
        if (event.type === 'done') {
          finalContent = event.content;
          this.config.logger.info({ responseLength: finalContent.length }, 'LLM response received');
        } else if (event.type === 'image') {
          collectedImages.push({ data: event.data, mimeType: event.mimeType, caption: event.caption });
        }
        yield event;
      }
    } catch (error) {
      if ((error as Error)?.name === 'AbortError') {
        this.config.logger.info('Agent harness interrupted by stop signal');
      } else {
        this.config.logger.error({ error }, 'Fatal error in agent harness');
        yield { type: 'error' as const, source: 'agent harness', fatal: true };
      }
    } finally {
      this.abortController = null;
      if (finalContent && this.conversationStore) {
        // Write blobs to S3 and capture their IDs for message metadata
        let blobRefs: Array<{ id: string; mimeType: string; caption?: string }> = [];
        if (collectedImages.length > 0 && this.blobStore) {
          const assistantMsgId = `${this.config.userId}:${this.config.sessionId}:${Date.now()}`;
          const blobIds = await this.blobStore.writeBlobs(
            this.config.userId, this.config.sessionId, assistantMsgId,
            collectedImages.map(img => ({ blobType: 'image' as const, mimeType: img.mimeType, data: img.data, caption: img.caption }))
          );
          blobRefs = blobIds.map((id, i) => ({ id, mimeType: collectedImages[i].mimeType, caption: collectedImages[i].caption }));
        }

        if (options?.saveUserMessage !== false) {
          await this.conversationStore.saveMessage(
            this.config.userId, this.config.sessionId, 'user', message.content, undefined, channelKey
          );
        }
        await this.conversationStore.saveMessage(
          this.config.userId, this.config.sessionId, 'assistant', finalContent,
          blobRefs.length > 0 ? { blobs: blobRefs } : undefined,
          channelKey
        );
      }
    }
  }

  /**
   * Stream a greeting response for first-time users.
   * Sends "Who are you and what can you do?" through the normal message pipeline.
   */
  async *streamGreeting(): AsyncGenerator<HarnessEvent> {
    const content = await AgentHarness.loadWelcomePrompt();
    const greetingMessage: InboundMessage = {
      messageId: `greeting_${Date.now()}`,
      userId: this.config.userId,
      sessionId: this.config.sessionId,
      content,
      timestamp: new Date(),
    };
    yield* this.streamMessage(greetingMessage, { saveUserMessage: false });
  }

  /**
   * Handle incoming message from user.
   * Consumes streamMessage and dispatches events to the channel adapter for
   * backward compatibility with Telegram and other non-streaming callers.
   */
  async handleMessage(message: InboundMessage): Promise<OutboundMessage> {
    let finalContent = '';
    try {
      for await (const event of this.streamMessage(message)) {
        switch (event.type) {
          case 'chunk':
            this.channelAdapter?.sendChunk(event.content);
            break;
          case 'tool_call':
            this.channelAdapter?.sendToolCall?.(event.toolName, event.label);
            break;
          case 'image':
            this.channelAdapter?.sendImage({ data: event.data, mimeType: event.mimeType, caption: event.caption });
            break;
          case 'error':
            this.channelAdapter?.sendText?.({ text: `An unrecoverable error occurred in the ${event.source}.` });
            break;
          case 'done':
            finalContent = event.content;
            break;
        }
      }
    } catch (error) {
      this.config.logger.error({ error }, 'Error processing message');
      throw error;
    }
    return {
      messageId: `msg_${Date.now()}`,
      sessionId: message.sessionId,
      content: finalContent,
      timestamp: new Date(),
    };
  }

  /**
   * Convert to LangChain message format
   */
  private buildLangChainMessages(
    systemPrompt: string,
    history: BaseMessage[],
    licenseInfo: string,
    workspaceContext: string | undefined,
    dynamicContext: string,
    currentUserMessage: string
  ): BaseMessage[] {
    // License, workspace, and dynamic context are injected as HumanMessages so they are never cached
    const contextParts = [licenseInfo, workspaceContext, dynamicContext || undefined].filter(Boolean).join('\n\n');
    return [
      new SystemMessage(systemPrompt),
      ...history,
      ...(contextParts ? [new HumanMessage(contextParts)] : []),
      new HumanMessage(currentUserMessage),
    ];
  }

  /**
   * Build system prompt from wiki knowledge base.
   * Loads index.md + tools.md (tier-1 cache) and agent-main.md (tier-2 cache).
   */
  private async buildSystemPrompt(): Promise<string> {
    const [basePrompt, agentMain] = await Promise.all([
      this.wikiLoader.getBasePrompt(),
      this.wikiLoader.loadAgentPage('main').catch(() => null),
    ]);

    const parts = [basePrompt];
    if (agentMain) parts.push(agentMain.body);
    return parts.join('\n\n---\n\n');
  }

  /**
   * Process tool result to extract images and send via channel adapter.
   * Returns text-only version for LLM context (no base64 image data).
   */
  private extractImagesFromToolResult(
    result: string,
    toolName: string
  ): { cleanedResult: string; images: Array<{ data: string; mimeType: string; caption?: string }> } {
    const noImages = { cleanedResult: String(result || ''), images: [] };

    // Most tools return plain strings - only process JSON results
    if (!result || typeof result !== 'string') {
      return noImages;
    }

    // Try to parse as JSON
    let parsedResult: any;
    try {
      parsedResult = JSON.parse(result);
    } catch {
      // Not JSON, return as-is
      return noImages;
    }

    // Check if result has images array (from ResearchSubagent)
    if (parsedResult && Array.isArray(parsedResult.images) && parsedResult.images.length > 0) {
      this.config.logger.info(
        { tool: toolName, imageCount: parsedResult.images.length },
        'Extracting images from tool result'
      );

      const images: Array<{ data: string; mimeType: string; caption?: string }> = [];
      for (const image of parsedResult.images) {
        if (image.data && image.mimeType) {
          this.config.logger.debug({ mimeType: image.mimeType }, 'Extracted image from tool result');
          images.push({ data: image.data, mimeType: image.mimeType, caption: undefined });
        }
      }

      // Create text-only version for LLM
      const textOnlyResult = {
        ...parsedResult,
        images: undefined,
        imageCount: parsedResult.images.length,
      };
      Object.keys(textOnlyResult).forEach(key => {
        if (textOnlyResult[key] === undefined) {
          delete textOnlyResult[key];
        }
      });

      return { cleanedResult: JSON.stringify(textOnlyResult), images };
    }

    // Check for nested chart_images object
    if (parsedResult && parsedResult.chart_images && typeof parsedResult.chart_images === 'object') {
      this.config.logger.info(
        { tool: toolName, chartCount: Object.keys(parsedResult.chart_images).length },
        'Extracting chart images from tool result'
      );

      const images: Array<{ data: string; mimeType: string; caption?: string }> = [];
      for (const [chartId, chartData] of Object.entries(parsedResult.chart_images)) {
        const chart = chartData as any;
        if (chart.type === 'image' && chart.data) {
          this.config.logger.debug({ chartId }, 'Extracted chart image from tool result');
          images.push({ data: chart.data, mimeType: 'image/png', caption: undefined });
        }
      }

      // Create text-only version for LLM
      const textOnlyResult = {
        ...parsedResult,
        chart_images: undefined,
        chartCount: Object.keys(parsedResult.chart_images).length,
      };
      Object.keys(textOnlyResult).forEach(key => {
        if (textOnlyResult[key] === undefined) {
          delete textOnlyResult[key];
        }
      });

      return { cleanedResult: JSON.stringify(textOnlyResult), images };
    }

    // No images found, return as-is
    return { cleanedResult: result, images: [] };
  }

  /**
   * End the session: flush conversation to cold storage, then release resources.
   * Called by channel handlers on disconnect, session expiry, or graceful shutdown.
   */
  async cleanup(): Promise<void> {
    this.config.logger.info('Cleaning up agent harness');

    if (this.conversationStore) {
      const channelKey = this.config.channelType ?? ChannelType.WEBSOCKET;
      try {
        await this.conversationStore.flushToIceberg(
          this.config.userId, this.config.sessionId, this.config.historyLimit, channelKey
        );
      } catch (error) {
        this.config.logger.error({ error }, 'Failed to flush conversation to Iceberg during cleanup');
      }
    }

    await this.mcpClient.disconnect();
  }
}

// =============================================================================
// Details update helpers (module-level, no class dependency)
// =============================================================================

/**
 * Produce a minimal unified diff between two strings, suitable for passing to
 * an LLM as a change description. Returns an empty string when there is no diff.
 */
function buildUnifiedDiff(oldText: string, newText: string, label: string): string {
  const oldLines = oldText.split('\n');
  const newLines = newText.split('\n');

  if (oldLines.join('\n') === newLines.join('\n')) return '';

  const lines: string[] = [];
  lines.push(`--- a/${label}`);
  lines.push(`+++ b/${label}`);

  // Simple LCS-based diff — sufficient for the LLM to understand structural changes
  const lcs = computeLCS(oldLines, newLines);
  let oi = 0, ni = 0, li = 0;

  while (oi < oldLines.length || ni < newLines.length) {
    if (li < lcs.length && oi < oldLines.length && ni < newLines.length &&
        oldLines[oi] === lcs[li] && newLines[ni] === lcs[li]) {
      lines.push(` ${oldLines[oi]}`);
      oi++; ni++; li++;
    } else if (ni < newLines.length && (li >= lcs.length || newLines[ni] !== lcs[li])) {
      lines.push(`+${newLines[ni]}`);
      ni++;
    } else {
      lines.push(`-${oldLines[oi]}`);
      oi++;
    }
  }

  return lines.join('\n');
}

/** Compute longest common subsequence of two string arrays. */
function computeLCS(a: string[], b: string[]): string[] {
  const m = a.length, n = b.length;
  const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
    }
  }
  const result: string[] = [];
  let i = m, j = n;
  while (i > 0 && j > 0) {
    if (a[i - 1] === b[j - 1]) { result.unshift(a[i - 1]); i--; j--; }
    else if (dp[i - 1][j] > dp[i][j - 1]) { i--; } else { j--; }
  }
  return result;
}

/**
 * Build the instruction string passed to the subagent when the user edits
 * the details field of a category item.
 */
function buildDetailsUpdateInstruction(
  category: string,
  name: string,
  newDetails: string,
  diff: string,
): string {
  const categoryLabel = category === 'indicator' ? 'custom indicator'
    : category === 'strategy' ? 'trading strategy'
    : 'research script';

  return `The user has edited the specification (details) for the ${categoryLabel} named "${name}".

Your task: update the Python implementation to match the revised specification. Use \`PythonEdit\` with targeted patches — make only the changes implied by the diff below. Also update the \`details\` field via the \`details\` parameter on \`PythonEdit\` to store the new specification text.

## Revised specification

${newDetails}

## What changed (unified diff of the details text)

\`\`\`diff
${diff}
\`\`\`

Instructions:
- Read the current implementation first with \`PythonRead(category="${category}", name="${name}")\` to understand what exists.
- Apply only the changes described by the diff above — do not rewrite unrelated parts of the code.
- Pass \`details\` as the full revised specification text shown above.
- After editing, confirm the change was applied and validation passed.`;
}
