// ABOUTME: OpenAI GPT provider implementation
// ABOUTME: Wraps OpenAI SDK in the common provider interface

import OpenAI, { ClientOptions } from 'openai';
import type {
  Response,
  ResponseCreateParams,
  ResponseStreamEvent,
  Tool as ResponseTool,
} from 'openai/resources/responses/responses';

// Import tiktoken WASM for embedding in bun compile (skip in test environment)
if (process.env.NODE_ENV !== 'test') {
  // Use require with try-catch to avoid breaking test environments that don't support import attributes
  try {
    // This will only work in bun compile, but that's fine
    // eslint-disable-next-line @typescript-eslint/no-require-imports -- Required for WASM embedding in bun compile
    require('tiktoken/tiktoken_bg.wasm');
  } catch {
    // Ignore - this is expected in non-bun environments
  }
}

// Dynamic tiktoken import to handle WASM loading failures gracefully
type Tiktoken = import('tiktoken').Tiktoken;
import { AIProvider, type WireTool } from './base-provider';
import {
  ProviderMessage,
  ProviderResponse,
  ProviderConfig,
  ProviderInfo,
  ConversationState,
  RequestOptions,
} from './base-provider';
import { normalizeOpenAIChatStop, normalizeOpenAIResponsesStop } from './stop-reason';
import type { LaceStopReason, LaceStopDetails } from './stop-reason';
import { tryClassifyAsContextWindow } from './utils/error-classifier';
import { getTextContent } from '@lace/agent/providers/utils/content-helpers';
import { ToolCall } from '@lace/agent/tools/types';
import { logger } from '@lace/agent/utils/logger';
import { logProviderRequest, logProviderResponse } from '@lace/agent/utils/provider-logging';
import { convertToOpenAIFormat } from './format-converters';

interface OpenAIProviderConfig extends ProviderConfig {
  apiKey: string | null;
  [key: string]: unknown; // Allow for additional properties
}

export class OpenAIProvider extends AIProvider {
  private _openai: OpenAI | null = null;
  private _encoderCache = new Map<string, Tiktoken>();
  private _tiktokenModule: typeof import('tiktoken') | null = null;
  private _tiktokenAvailable: boolean | undefined = undefined;

  constructor(config: OpenAIProviderConfig) {
    super(config);
  }

  private getOpenAIClient(): OpenAI {
    if (!this._openai) {
      const config = this._config as OpenAIProviderConfig;
      const configBaseURL = config.baseURL as string | undefined;

      // Allow no API key for local OpenAI-compatible endpoints
      if (!config.apiKey && !configBaseURL) {
        throw new Error(
          'Missing API key for OpenAI provider. Please ensure the provider instance has valid credentials.'
        );
      }

      if (!config.apiKey && configBaseURL) {
        logger.info('Using OpenAI-compatible endpoint without API key', {
          baseURL: configBaseURL,
        });
      }

      const openaiConfig: ClientOptions = {
        apiKey: config.apiKey || 'not-required-for-local',
        dangerouslyAllowBrowser: process.env.NODE_ENV === 'test',
      };

      // Support custom base URL for OpenAI-compatible APIs
      if (configBaseURL) {
        openaiConfig.baseURL = configBaseURL;
        logger.info('Using custom OpenAI base URL', {
          baseURL: configBaseURL,
        });
      }

      this._openai = new OpenAI(openaiConfig);
    }
    return this._openai;
  }

  get providerName(): string {
    return 'openai';
  }

  get supportsStreaming(): boolean {
    return true;
  }

  /**
   * Loads tiktoken with embedded WASM support for bun compile
   * Uses scoped fs patching - only active during import, then fully restored
   */
  private async _loadTiktokenWithEmbeddedWasm(): Promise<typeof import('tiktoken')> {
    // Return cached module if already loaded
    if (this._tiktokenModule) {
      return this._tiktokenModule;
    }

    // Check if we have embedded WASM available
    let wasmBuffer: Buffer | null = null;
    if (typeof globalThis.Bun !== 'undefined') {
      const bunGlobal = globalThis.Bun as unknown;
      if (
        bunGlobal &&
        typeof bunGlobal === 'object' &&
        'embeddedFiles' in bunGlobal &&
        Array.isArray((bunGlobal as { embeddedFiles: unknown }).embeddedFiles)
      ) {
        const embeddedFiles = (
          bunGlobal as { embeddedFiles: Array<{ name: string; arrayBuffer(): ArrayBuffer }> }
        ).embeddedFiles;
        const wasmFile = embeddedFiles.find((file) => file.name === 'tiktoken_bg.wasm');
        if (wasmFile) {
          // This is sync in the compiled context since embeddedFiles are pre-extracted
          wasmBuffer = Buffer.from(wasmFile.arrayBuffer());
        }
      }
    }

    // If no embedded WASM, fall back to regular import
    if (!wasmBuffer) {
      logger.debug('[OpenAIProvider] No embedded WASM, attempting regular tiktoken import');
      try {
        // Use dynamic import for ESM compatibility (web server)
        this._tiktokenModule = await import('tiktoken');
        logger.debug('[OpenAIProvider] Tiktoken loaded successfully via import()');
        return this._tiktokenModule;
      } catch (importError) {
        logger.error('[OpenAIProvider] Failed to import tiktoken', {
          error: importError,
          errorMessage: importError instanceof Error ? importError.message : String(importError),
          errorStack: importError instanceof Error ? importError.stack : undefined,
        });
        throw importError; // Re-throw to be caught by outer try-catch
      }
    }

    // Use scoped fs patching for embedded WASM
    // eslint-disable-next-line @typescript-eslint/no-require-imports -- Required for fs patching
    const fs = require('fs') as typeof import('fs');
    const originalReadFileSync = fs.readFileSync;

    // Patch fs only during tiktoken import

    const patchedReadFileSync = function (this: any, path: any, options?: any) {
      if (typeof path === 'string' && path.includes('tiktoken_bg.wasm')) {
        return wasmBuffer;
      }
      return originalReadFileSync.call(this, path, options);
    };
    fs.readFileSync = patchedReadFileSync as typeof fs.readFileSync;

    try {
      // Import tiktoken while patch is active
      // eslint-disable-next-line @typescript-eslint/no-require-imports -- Required for scoped patching
      this._tiktokenModule = require('tiktoken') as typeof import('tiktoken');
      return this._tiktokenModule;
    } finally {
      // Always restore original fs.readFileSync
      fs.readFileSync = originalReadFileSync;
    }
  }

  /**
   * Helper method for token counting with explicit control over system prompt and tools
   * Allows precise counting of individual components
   */
  private async countTokensExplicit(
    messages: ProviderMessage[],
    systemPrompt: string,
    tools: WireTool[],
    model: string
  ): Promise<number | null> {
    try {
      // Get or create cached encoder for this model
      let encoding: Tiktoken;
      if (this._encoderCache.has(model)) {
        encoding = this._encoderCache.get(model)!;
      } else {
        // Check if tiktoken is available (cached result)
        if (this._tiktokenAvailable === false) {
          // Previously failed to load, don't retry
          return null; // Return null to indicate unavailable
        }

        // Load tiktoken with embedded WASM support for bun compile
        let tiktoken: typeof import('tiktoken');
        try {
          tiktoken = await this._loadTiktokenWithEmbeddedWasm();
          // Mark as available on successful load
          this._tiktokenAvailable = true;
        } catch (importError) {
          // WASM loading failed - tiktoken unavailable
          this._tiktokenAvailable = false;
          logger.debug('Tiktoken WASM failed to load, token counting disabled', {
            error: importError,
          });
          return null; // Return null to indicate unavailable (not 0)
        }

        try {
          encoding = tiktoken.encoding_for_model(
            model as Parameters<typeof tiktoken.encoding_for_model>[0]
          );
        } catch (error) {
          // Fallback for unknown/custom/OpenAI-compatible models
          logger.debug(`Model ${model} not recognized by tiktoken, using default encoding`, {
            error,
          });
          encoding = tiktoken.get_encoding('cl100k_base'); // Default for most OpenAI-compatible models
        }
        this._encoderCache.set(model, encoding);
      }

      // Start with system prompt
      let messageText = systemPrompt ? `system: ${systemPrompt}\n` : '';

      // Convert messages to text for token counting, excluding system messages to avoid double-counting
      for (const message of messages) {
        if (message.role !== 'system') {
          messageText += `${message.role}: ${getTextContent(message.content)}\n`;
        }
      }

      // Count base message tokens
      let totalTokens = encoding.encode(messageText).length;

      // Add tool schema tokens if tools are provided
      if (tools.length > 0) {
        const toolsText = JSON.stringify(
          tools.map((tool) => ({
            name: tool.name,
            description: tool.description,
            parameters: tool.inputSchema,
          }))
        );
        totalTokens += encoding.encode(toolsText).length;
        totalTokens += tools.length * 3; // Overhead per tool
      }

      // Add conversation overhead (conservative estimate)
      totalTokens += 10;

      return totalTokens;
    } catch (error) {
      logger.debug('Token counting failed, gracefully degrading', { error });
      return null; // Return null when tiktoken fails entirely
    }
  }

  // Provider-specific token counting using tiktoken for OpenAI-compatible models
  // Returns null when tiktoken WASM fails to load
  protected async _countTokensImpl(
    messages: ProviderMessage[],
    tools: WireTool[] = [],
    model?: string
  ): Promise<number | null> {
    if (!model) {
      return null; // Can't count without model
    }

    const systemPrompt = this.getEffectiveSystemPrompt(messages);
    return this.countTokensExplicit(messages, systemPrompt, tools, model);
  }

  /**
   * Parses OpenAI tool call into our common ToolCall shape. Tool-name un-sanitization
   * is handled by AIProvider — this method just reports the wire-format name and the
   * base class wrapper maps it back to the original.
   */
  private parseToolCall(toolCall: {
    id: string;
    function: { name: string; arguments: string };
  }): ToolCall {
    try {
      return {
        id: toolCall.id,
        name: toolCall.function.name,
        arguments: JSON.parse(toolCall.function.arguments) as Record<string, unknown>,
      };
    } catch (error) {
      logger.error('Failed to parse tool call arguments', {
        toolCallId: toolCall.id,
        toolName: toolCall.function.name,
        arguments: toolCall.function.arguments,
        error: (error as Error).message,
      });
      throw new Error(
        `Invalid JSON in tool call arguments for ${toolCall.function.name}: ${
          (error as Error).message
        }`
      );
    }
  }

  /**
   * Checks if an error is due to streaming requiring organization verification
   * OpenAI returns: type='invalid_request_error', code='unsupported_value', param='stream'
   */
  private isStreamingVerificationError(error: unknown): boolean {
    if (!error || typeof error !== 'object') {
      return false;
    }

    const errorObj = error as {
      type?: string;
      code?: string;
      param?: string;
    };

    return (
      errorObj.type === 'invalid_request_error' &&
      errorObj.code === 'unsupported_value' &&
      errorObj.param === 'stream'
    );
  }

  /**
   * Builds OpenAI's tool-shape from a list of already-sanitized WireTools.
   * Sanitization itself lives in AIProvider — we just adapt name/description/inputSchema
   * into OpenAI's {type:'function', function:{...}} envelope.
   */
  private buildOpenAITools(tools: WireTool[]): OpenAI.Chat.ChatCompletionTool[] {
    return tools.map((tool) => ({
      type: 'function' as const,
      function: {
        name: tool.name,
        description: tool.description,
        parameters: tool.inputSchema,
      },
    }));
  }

  private _createRequestPayload(
    messages: ProviderMessage[],
    tools: WireTool[],
    model: string,
    stream: boolean,
    options?: RequestOptions
  ): OpenAI.Chat.ChatCompletionCreateParams {
    // Convert our enhanced generic messages to OpenAI format
    const openaiMessages = convertToOpenAIFormat(
      messages
    ) as unknown as OpenAI.Chat.ChatCompletionMessageParam[];

    // Extract system message if present
    const systemPrompt = this.getEffectiveSystemPrompt(messages);

    // Add system message at the beginning if not already present
    const messagesWithSystem = [
      { role: 'system' as const, content: systemPrompt },
      ...openaiMessages.filter((msg) => msg.role !== 'system'),
    ];

    return {
      model,
      messages: messagesWithSystem,
      max_completion_tokens: this._config.maxTokens || this.getModelMaxOutputTokens(model, 16384),
      stream,
      ...(tools.length > 0 && { tools: this.buildOpenAITools(tools) }),
      ...(options?.toolChoice && { tool_choice: options.toolChoice }),
    };
  }

  /**
   * Converts ProviderMessages to Responses API input items
   * Shared helper for both first turn (full history) and chained turns (incremental)
   */
  private _convertMessagesToInputItems(messages: ProviderMessage[]): Array<unknown> {
    const inputItems: Array<unknown> = [];

    for (const msg of messages) {
      if (msg.role === 'user' || msg.role === 'assistant') {
        const textContent = getTextContent(msg.content);
        // Add text message if it has content
        if (textContent.trim()) {
          inputItems.push({
            role: msg.role,
            content: textContent,
          });
        }

        // Tool-call name sanitization is handled by AIProvider — by the time messages
        // arrive here, msg.toolCalls[].name is already in wire form.
        if (msg.toolCalls) {
          for (const toolCall of msg.toolCalls) {
            inputItems.push({
              type: 'function_call',
              call_id: toolCall.id,
              name: toolCall.name,
              arguments: JSON.stringify(toolCall.arguments),
            });
          }
        }

        // Add tool results as separate items (user messages can have tool results)
        if (msg.toolResults) {
          for (const result of msg.toolResults) {
            inputItems.push({
              type: 'function_call_output',
              call_id: result.id,
              output: result.content.map((c) => c.text || '').join('\n'),
            });
          }
        }
      }
    }

    return inputItems;
  }

  /**
   * Creates a Responses API request payload
   * Used for models that require the new /v1/responses endpoint
   */
  private _createResponsesAPIPayload(
    messages: ProviderMessage[],
    tools: WireTool[],
    model: string,
    stream: boolean,
    previousResponseId?: string | null,
    options?: RequestOptions
  ): ResponseCreateParams {
    // Extract system/developer instructions via the base helper so both
    // the Chat Completions path and this Responses-API path produce identical
    // system prompts (same \n\n separator, same content-block handling).
    // The old inline join used '\n' and stringified content arrays naively,
    // producing '[object Object]' for block-style content.
    const systemText = this.getEffectiveSystemPrompt(messages);
    // instructions is optional in the SDK; map '' → undefined to omit the field.
    const instructions = systemText || undefined;

    // Transform tools to Responses API format (flatter structure, not nested in 'function')
    const responsesTools: ResponseTool[] = tools.map((tool) => ({
      type: 'function' as const,
      name: tool.name,
      description: tool.description,
      parameters: (tool.inputSchema as Record<string, unknown>) || null,
      // Note: strict mode disabled because our tool schemas have optional parameters.
      // With strict: true, ALL properties must be in required array.
      strict: false,
    }));

    // Convert ProviderMessages to Responses API input format
    // The Responses API uses a DIFFERENT format than Chat Completions:
    // - Tool calls are separate {type: 'function_call'} items (not part of assistant message)
    // - Tool results are separate {type: 'function_call_output'} items (not tool messages)
    let inputItems: Array<unknown> = [];

    if (previousResponseId) {
      // Chained response: Only include messages AFTER the last assistant message
      const lastAgentMessageIndex = messages.findLastIndex((m) => m.role === 'assistant');

      if (lastAgentMessageIndex >= 0) {
        const newMessages = messages.slice(lastAgentMessageIndex + 1);
        inputItems = this._convertMessagesToInputItems(newMessages);
      } else {
        logger.warn(
          'Responses API chaining: No assistant message found, falling back to full history',
          {
            previousResponseId,
            messageCount: messages.length,
          }
        );
        inputItems = this._convertMessagesToInputItems(messages.filter((m) => m.role !== 'system'));
      }
    } else {
      // First turn: Include all non-system messages
      inputItems = this._convertMessagesToInputItems(messages.filter((m) => m.role !== 'system'));
    }

    // Set reasoning effort for reasoning-capable models
    const reasoningEffort = this.getModelReasoningEffort(model);

    const requestPayload: ResponseCreateParams = {
      model,
      instructions,
      // Cast to ResponseCreateParams['input'] - types are complex but runtime compatible
      input: inputItems as unknown as ResponseCreateParams['input'],
      max_output_tokens: this._config.maxTokens || this.getModelMaxOutputTokens(model, 16384),
      stream,
      ...(previousResponseId && { previous_response_id: previousResponseId }),
      ...(tools.length > 0 && { tools: responsesTools }),
      ...(options?.toolChoice && { tool_choice: options.toolChoice }),
      ...(reasoningEffort && {
        reasoning: { effort: reasoningEffort } as ResponseCreateParams['reasoning'],
      }),
      ...(previousResponseId && {
        context_management: [{ type: 'compaction', compact_threshold: 100000 }],
      }),
      store: true, // Enable server-side storage for response chaining
    };

    // Log request details at INFO level for debugging
    logger.info('Responses API request details', {
      model,
      isChained: !!previousResponseId,
      previousResponseId: previousResponseId || 'none',
      inputItemCount: inputItems.length,
      maxOutputTokens: requestPayload.max_output_tokens,
      toolCount: tools.length,
      instructionsLength: instructions?.length || 0,
      hasStore: requestPayload.store,
      hasCompaction: !!requestPayload.context_management,
      reasoningEffort: reasoningEffort || 'none',
    });

    return requestPayload;
  }

  /**
   * Parses a Responses API response into our standard format
   */
  private _parseResponsesAPIResponse(response: Response): ProviderResponse {
    let textContent = '';
    const toolCalls: ToolCall[] = [];

    // Process output items
    for (const item of response.output) {
      if (item.type === 'message') {
        // Extract text from message content
        for (const content of item.content) {
          if (content.type === 'output_text') {
            textContent += content.text;
          }
        }
      } else if (item.type === 'function_call') {
        // Convert function call to our ToolCall format
        try {
          toolCalls.push({
            id: item.call_id,
            name: item.name,
            arguments: JSON.parse(item.arguments) as Record<string, unknown>,
          });
        } catch (error) {
          logger.error('Failed to parse Responses API tool call arguments', {
            toolName: item.name,
            arguments: item.arguments,
            error: (error as Error).message,
          });
        }
      }
      // Process reasoning items for thinking events (o1/o3 models)
      // Note: Reasoning items are emitted as events but not included in regular content
      else if (item.type === 'reasoning') {
        const reasoningItem = item as { summary: Array<{ text: string }> };
        if (reasoningItem.summary && reasoningItem.summary.length > 0) {
          this.emit('thinking_start', {});
          for (const summaryPart of reasoningItem.summary) {
            if (summaryPart.text) {
              this.emit('thinking_delta', { text: summaryPart.text });
            }
          }
          // Get reasoning tokens from usage if available
          const reasoningTokens = (
            response.usage as { output_tokens_details?: { reasoning_tokens?: number } }
          )?.output_tokens_details?.reasoning_tokens;
          this.emit('thinking_end', { tokens: reasoningTokens });
        }
      }
    }

    // Use output_text convenience field as fallback
    if (!textContent && response.output_text) {
      textContent = response.output_text;
    }

    // response.status is typed as string | undefined by the SDK but in practice
    // the Responses API always sets a terminal status by the time we parse the
    // final response object. Fall back to 'unknown' so the normalizer's WARN
    // path catches the SDK-shouldn't-do-this case rather than silently mapping
    // to end_turn.
    const { stopReason, stopDetails } = normalizeOpenAIResponsesStop(
      response.status ?? 'unknown',
      response.incomplete_details as Parameters<typeof normalizeOpenAIResponsesStop>[1],
      response.error as Parameters<typeof normalizeOpenAIResponsesStop>[2],
      // Refusal-item capture lands in chunk F; until then we never see a
      // refusal item at parse time so this is always null.
      null,
      toolCalls.length > 0
    );

    return {
      content: textContent,
      toolCalls,
      stopReason,
      stopDetails,
      usage: {
        promptTokens: response.usage?.input_tokens || 0,
        completionTokens: response.usage?.output_tokens || 0,
        totalTokens: response.usage?.total_tokens || 0,
      },
      responseId: response.id, // Store response ID for conversation chaining
    };
  }

  /**
   * Checks if we're using a custom OpenAI-compatible endpoint (not the real OpenAI API).
   * Custom endpoints may not support the Responses API.
   */
  private isCustomEndpoint(): boolean {
    const config = this._config as OpenAIProviderConfig;
    const baseURL = config.baseURL as string | undefined;
    return !!baseURL && !baseURL.includes('api.openai.com');
  }

  /**
   * Checks if an error indicates the model doesn't support the Responses API.
   * This allows us to fall back to Chat Completions for older models.
   */
  private isResponsesAPINotSupportedError(error: unknown): boolean {
    if (!error || typeof error !== 'object') return false;

    const errorObj = error as { status?: number; message?: string; code?: string };

    // 404 typically means endpoint/model not found
    // Check for specific error messages that indicate Responses API isn't supported
    if (errorObj.status === 404) {
      const message = errorObj.message?.toLowerCase() || '';
      // These patterns indicate the model doesn't support Responses API
      if (
        message.includes('not found') ||
        message.includes('does not exist') ||
        message.includes('not supported')
      ) {
        return true;
      }
    }

    return false;
  }

  protected async _createResponseImpl(
    messages: ProviderMessage[],
    tools: WireTool[] = [],
    model: string,
    signal?: AbortSignal,
    conversationState?: ConversationState,
    options?: RequestOptions
  ): Promise<ProviderResponse> {
    // For custom OpenAI-compatible endpoints, use Chat Completions (they may not support Responses API)
    if (this.isCustomEndpoint()) {
      return this._createChatCompletionsResponse(messages, tools, model, signal, options);
    }

    // For real OpenAI: try Responses API first, fall back to Chat Completions if not supported
    try {
      return await this._createResponsesAPIResponse(
        messages,
        tools,
        model,
        signal,
        conversationState,
        options
      );
    } catch (error) {
      if (this.isResponsesAPINotSupportedError(error)) {
        logger.info('Model does not support Responses API, falling back to Chat Completions', {
          model,
          error: error instanceof Error ? error.message : String(error),
        });
        return this._createChatCompletionsResponse(messages, tools, model, signal, options);
      }
      throw error;
    }
  }

  /**
   * Chat Completions API implementation (original endpoint)
   */
  private async _createChatCompletionsResponse(
    messages: ProviderMessage[],
    tools: WireTool[],
    model: string,
    signal?: AbortSignal,
    options?: RequestOptions
  ): Promise<ProviderResponse> {
    return this.withRetry(
      async () => {
        const requestPayload = this._createRequestPayload(messages, tools, model, false, options);

        // Log request with pretty formatting
        logProviderRequest('openai', requestPayload as unknown as Record<string, unknown>);

        let response: OpenAI.Chat.ChatCompletion;
        try {
          response = (await this.getOpenAIClient().chat.completions.create(requestPayload, {
            signal,
          })) as OpenAI.Chat.ChatCompletion;
        } catch (providerError) {
          const classified = tryClassifyAsContextWindow(
            providerError,
            'OpenAIProvider (ChatCompletions)'
          );
          if (classified) return classified;
          throw providerError;
        }

        // Log response with pretty formatting
        logProviderResponse('openai', response);

        const choice = response.choices[0];
        if (!choice.message) {
          throw new Error('No message in OpenAI response');
        }

        const textContent = choice.message.content || '';

        const toolCalls: ToolCall[] =
          choice.message.tool_calls
            ?.filter(
              (tc): tc is OpenAI.Chat.ChatCompletionMessageFunctionToolCall =>
                tc.type === 'function'
            )
            .map((toolCall) => this.parseToolCall(toolCall)) || [];

        logger.trace('Received response from OpenAI', {
          provider: 'openai',
          contentLength: textContent.length,
          toolCallCount: toolCalls.length,
          toolCallNames: toolCalls.map((tc) => tc.name),
          usage: response.usage,
        });

        // Extract usage data from response, or estimate if missing (for OpenAI-compatible endpoints)
        let usage: ProviderResponse['usage'];
        if (response.usage) {
          usage = {
            promptTokens: response.usage.prompt_tokens,
            completionTokens: response.usage.completion_tokens,
            totalTokens: response.usage.total_tokens,
          };
        } else {
          // Fallback: estimate tokens when OpenAI-compatible endpoints don't provide usage
          logger.debug('No usage data in OpenAI response, estimating tokens', {
            provider: 'openai',
            model: requestPayload.model,
          });

          // Include system prompt and tool context for accurate estimation
          const systemPrompt = this.getEffectiveSystemPrompt(messages);
          const promptText = systemPrompt + '\n' + messages.map((m) => m.content).join(' ');
          const toolsText = tools.length > 0 ? JSON.stringify(tools) : '';

          const countedTokens = await this._countTokensImpl(messages, tools, model);
          const estimatedPromptTokens =
            countedTokens ?? this.estimateTokens(promptText + toolsText);
          const estimatedCompletionTokens = this.estimateTokens(textContent);

          usage = {
            promptTokens: estimatedPromptTokens,
            completionTokens: estimatedCompletionTokens,
            totalTokens: estimatedPromptTokens + estimatedCompletionTokens,
          };
        }

        const { stopReason, stopDetails } = normalizeOpenAIChatStop(choice.finish_reason);

        return {
          content: textContent,
          toolCalls,
          stopReason,
          stopDetails,
          usage,
        };
      },
      { signal }
    );
  }

  /**
   * Responses API implementation (new endpoint for reasoning models)
   */
  private async _createResponsesAPIResponse(
    messages: ProviderMessage[],
    tools: WireTool[],
    model: string,
    signal?: AbortSignal,
    conversationState?: ConversationState,
    options?: RequestOptions
  ): Promise<ProviderResponse> {
    return this.withRetry(
      async () => {
        const requestPayload = this._createResponsesAPIPayload(
          messages,
          tools,
          model,
          false,
          conversationState?.previousResponseId,
          options
        );

        // Log request with pretty formatting
        logProviderRequest('openai', requestPayload as unknown as Record<string, unknown>);

        let response: Response;
        try {
          response = (await this.getOpenAIClient().responses.create(requestPayload, {
            signal,
          })) as Response;
        } catch (providerError) {
          const classified = tryClassifyAsContextWindow(
            providerError,
            'OpenAIProvider (Responses API)'
          );
          if (classified) return classified;
          throw providerError;
        }

        // Log response with pretty formatting
        logProviderResponse('openai', response);

        const parsedResponse = this._parseResponsesAPIResponse(response);

        logger.trace('Received response from OpenAI Responses API', {
          provider: 'openai',
          api: 'responses',
          contentLength: parsedResponse.content.length,
          toolCallCount: parsedResponse.toolCalls.length,
          toolCallNames: parsedResponse.toolCalls.map((tc) => tc.name),
          usage: parsedResponse.usage,
        });

        return parsedResponse;
      },
      { signal }
    );
  }

  protected async _createStreamingResponseImpl(
    messages: ProviderMessage[],
    tools: WireTool[] = [],
    model: string,
    signal?: AbortSignal,
    conversationState?: ConversationState,
    options?: RequestOptions
  ): Promise<ProviderResponse> {
    // For custom OpenAI-compatible endpoints, use Chat Completions (they may not support Responses API)
    if (this.isCustomEndpoint()) {
      return this._createChatCompletionsStreamingResponse(messages, tools, model, signal, options);
    }

    // For real OpenAI: try Responses API first, fall back to Chat Completions if not supported
    try {
      return await this._createResponsesAPIStreamingResponse(
        messages,
        tools,
        model,
        signal,
        conversationState,
        options
      );
    } catch (error) {
      if (this.isResponsesAPINotSupportedError(error)) {
        logger.info(
          'Model does not support Responses API streaming, falling back to Chat Completions',
          {
            model,
            error: error instanceof Error ? error.message : String(error),
          }
        );
        return this._createChatCompletionsStreamingResponse(
          messages,
          tools,
          model,
          signal,
          options
        );
      }
      throw error;
    }
  }

  /**
   * Chat Completions streaming implementation (original endpoint)
   */
  private async _createChatCompletionsStreamingResponse(
    messages: ProviderMessage[],
    tools: WireTool[],
    model: string,
    signal?: AbortSignal,
    options?: RequestOptions
  ): Promise<ProviderResponse> {
    let streamingStarted = false;
    let streamCreated = false;

    return this.withRetry(
      async () => {
        const requestPayload = this._createRequestPayload(messages, tools, model, true, options);

        // Log streaming request with pretty formatting
        logProviderRequest('openai', requestPayload as unknown as Record<string, unknown>, {
          streaming: true,
        });

        try {
          // Use the streaming API
          const stream = (await this.getOpenAIClient().chat.completions.create(requestPayload, {
            signal,
          })) as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>;

          // Mark that stream is created to prevent retries after this point
          streamCreated = true;

          let content = '';
          let toolCalls: ToolCall[] = [];
          let stopReason: string | undefined;
          let usage: OpenAI.CompletionUsage | undefined;

          // Accumulate tool calls during streaming
          const partialToolCalls: Map<
            number,
            {
              id: string;
              name: string;
              arguments: string;
            }
          > = new Map();

          // Track progressive token estimation
          let estimatedOutputTokens = 0;

          // Process stream chunks
          for await (const chunk of stream) {
            const delta = chunk.choices[0]?.delta;

            if (delta?.content) {
              streamingStarted = true; // Mark that streaming has begun
              content += delta.content;
              // Emit token events for real-time display
              this.emit('token', { token: delta.content });

              // Estimate progressive tokens from text chunks
              const newTokens = this.estimateTokens(delta.content);
              estimatedOutputTokens += newTokens;

              // Emit progressive token estimate
              this.emit('token_usage_update', {
                usage: {
                  promptTokens: 0, // Unknown during streaming
                  completionTokens: estimatedOutputTokens,
                  totalTokens: estimatedOutputTokens,
                },
              });
            }

            // Handle tool calls
            if (delta?.tool_calls) {
              for (const toolCall of delta.tool_calls) {
                const index = toolCall.index;

                if (!partialToolCalls.has(index)) {
                  partialToolCalls.set(index, {
                    id: toolCall.id!,
                    name: toolCall.function!.name!,
                    arguments: '',
                  });
                }

                const partial = partialToolCalls.get(index)!;
                if (toolCall.function?.arguments) {
                  partial.arguments += toolCall.function.arguments;
                }
              }
            }

            // Get finish reason from the last chunk
            if (chunk.choices[0]?.finish_reason) {
              stopReason = chunk.choices[0].finish_reason;
            }

            // Some providers include usage in streaming responses
            if (chunk.usage) {
              usage = chunk.usage;

              // Emit token usage updates during streaming
              this.emit('token_usage_update', {
                usage: {
                  promptTokens: usage.prompt_tokens,
                  completionTokens: usage.completion_tokens,
                  totalTokens: usage.total_tokens,
                },
              });
            }
          }

          // Convert partial tool calls to final format
          // Handle incomplete tool calls gracefully (can occur if stream is aborted)
          toolCalls = Array.from(partialToolCalls.values())
            .map((partial) => {
              try {
                return this.parseToolCall({
                  id: partial.id,
                  function: { name: partial.name, arguments: partial.arguments },
                });
              } catch (error) {
                // If stream was aborted, incomplete tool calls are expected
                if (signal?.aborted) {
                  logger.debug('Incomplete tool call due to aborted stream', {
                    toolName: partial.name,
                  });
                  return null;
                }
                logger.error('Failed to parse Chat Completions streaming tool call', {
                  toolName: partial.name,
                  argumentsLength: partial.arguments.length,
                  argumentsPreview: partial.arguments.substring(0, 200),
                  argumentsSuffix: partial.arguments.substring(
                    Math.max(0, partial.arguments.length - 100)
                  ),
                  error: (error as Error).message,
                });
                // Don't throw - log and skip the broken tool call to prevent agent crash
                return null;
              }
            })
            .filter((tc): tc is ToolCall => tc !== null);

          logger.trace('Received streaming response from OpenAI', {
            provider: 'openai',
            contentLength: content.length,
            toolCallCount: toolCalls.length,
            toolCallNames: toolCalls.map((tc) => tc.name),
            usage,
          });

          // Extract usage data from stream, or estimate if missing (for OpenAI-compatible endpoints)
          let finalUsage: ProviderResponse['usage'];
          if (
            usage &&
            typeof usage.prompt_tokens === 'number' &&
            typeof usage.completion_tokens === 'number' &&
            typeof usage.total_tokens === 'number'
          ) {
            finalUsage = {
              promptTokens: usage.prompt_tokens,
              completionTokens: usage.completion_tokens,
              totalTokens: usage.total_tokens,
            };
          } else {
            // Fallback: estimate tokens when OpenAI-compatible endpoints don't provide usage
            logger.debug('No usage data in OpenAI streaming response, estimating tokens', {
              provider: 'openai',
              model: requestPayload.model,
            });

            // Build prompt text same way as non-streaming: system prompt + non-system messages
            const systemPrompt = this.getEffectiveSystemPrompt(messages);
            const nonSystemMessages = messages.filter((m) => m.role !== 'system');
            const promptText =
              systemPrompt + '\n' + nonSystemMessages.map((m) => m.content).join(' ');
            const toolsText = tools.length > 0 ? JSON.stringify(tools) : '';

            // Include tool call arguments in completion token estimation
            let completionText = content;
            for (const partial of partialToolCalls.values()) {
              completionText += partial.arguments;
            }

            const countedTokens = await this._countTokensImpl(messages, tools, model);
            const estimatedPromptTokens =
              countedTokens ?? this.estimateTokens(promptText + toolsText);
            const estimatedCompletionTokens = this.estimateTokens(completionText);

            finalUsage = {
              promptTokens: estimatedPromptTokens,
              completionTokens: estimatedCompletionTokens,
              totalTokens: estimatedPromptTokens + estimatedCompletionTokens,
            };
          }

          const { stopReason: canonicalStopReason, stopDetails } =
            normalizeOpenAIChatStop(stopReason);

          const response = {
            content,
            toolCalls,
            stopReason: canonicalStopReason,
            stopDetails,
            usage: finalUsage,
          };

          // Emit completion event
          this.emit('complete', { response });

          return response;
        } catch (error) {
          // Run the classifier BEFORE emitting an error log: recoverable
          // context-window 400s shouldn't surface as ERROR-level noise.
          const classified = tryClassifyAsContextWindow(
            error,
            'OpenAIProvider (ChatCompletions streaming)'
          );
          if (classified) return classified;

          // Check for streaming verification error FIRST (special case fallback)
          if (this.isStreamingVerificationError(error)) {
            logger.warn(
              'OpenAI streaming requires organization verification, falling back to non-streaming mode',
              { model }
            );
            // Fall back to non-streaming mode
            return this._createResponseImpl(messages, tools, model, signal);
          }

          const errorObj = error as Error;
          logger.error('Streaming error from OpenAI', { error: errorObj.message });

          // If stream wasn't created yet, let error bubble up for retry handling
          // This allows rate limit errors during stream creation to be retried
          if (!streamCreated) {
            throw error;
          }

          throw error;
        }
      },
      {
        signal,
        isStreaming: true,
        canRetry: () => !streamCreated && !streamingStarted,
      }
    );
  }

  /**
   * Responses API streaming implementation (new endpoint for reasoning models)
   */
  private async _createResponsesAPIStreamingResponse(
    messages: ProviderMessage[],
    tools: WireTool[],
    model: string,
    signal?: AbortSignal,
    conversationState?: ConversationState,
    options?: RequestOptions
  ): Promise<ProviderResponse> {
    let streamingStarted = false;
    let streamCreated = false;

    return this.withRetry(
      async () => {
        const requestPayload = this._createResponsesAPIPayload(
          messages,
          tools,
          model,
          true,
          conversationState?.previousResponseId,
          options
        );

        // Log streaming request with pretty formatting
        logProviderRequest('openai', requestPayload as unknown as Record<string, unknown>, {
          streaming: true,
        });

        try {
          // Use the Responses API streaming
          const stream = (await this.getOpenAIClient().responses.create(requestPayload, {
            signal,
          })) as AsyncIterable<ResponseStreamEvent>;

          // Mark that stream is created to prevent retries after this point
          streamCreated = true;

          let content = '';
          const toolCalls: ToolCall[] = [];
          let estimatedOutputTokens = 0;
          let responseId: string | undefined;
          let hasThinkingBlock = false;
          let completionUsage:
            | { input_tokens: number; output_tokens: number; total_tokens: number }
            | undefined;
          // Capture the terminal response object so we can route status,
          // incomplete_details, and error fields through the shared
          // OpenAI-Responses normalizer below.
          let completedResponse:
            | {
                status?: string | null;
                incomplete_details?: { reason: string } | null;
                error?: { code: string; message: string } | null;
              }
            | undefined;
          // Refusal-item capture (spec §3.4 / chunk F):
          //   - response.refusal.done carries the FINAL refusal text; we use that as the
          //     canonical value passed to the normalizer.
          //   - response.refusal.delta events are intentionally ignored (the .done event's
          //     `refusal` field already contains the full text per SDK contract); we still
          //     handle the case to suppress the default-branch fallthrough.
          //   - refusalText is preserved across output_item.added boundaries — if multiple
          //     items emit .done, the last one wins.
          // refusalText must never be appended to `content` — refusals are policy responses,
          // not assistant output.
          let refusalText: string | null = null;

          // Track tool calls by output index
          const toolCallsByIndex = new Map<
            number,
            {
              id: string;
              call_id: string;
              name: string;
              arguments: string;
            }
          >();

          // Process stream events
          for await (const event of stream) {
            switch (event.type) {
              case 'response.output_text.delta':
                streamingStarted = true;
                content += event.delta;
                this.emit('token', { token: event.delta });

                // Estimate progressive tokens
                estimatedOutputTokens += this.estimateTokens(event.delta);
                this.emit('token_usage_update', {
                  usage: {
                    promptTokens: 0,
                    completionTokens: estimatedOutputTokens,
                    totalTokens: estimatedOutputTokens,
                  },
                });
                break;

              case 'response.output_item.added':
                // Track function call items for later processing
                if (event.item.type === 'function_call') {
                  const functionCall = event.item as {
                    id: string;
                    call_id: string;
                    name: string;
                    type: 'function_call';
                  };
                  toolCallsByIndex.set(event.output_index, {
                    id: functionCall.id,
                    call_id: functionCall.call_id,
                    name: functionCall.name,
                    arguments: '',
                  });
                }
                // Emit thinking_start when a reasoning item is added (o1/o3 models)
                if (event.item.type === 'reasoning') {
                  hasThinkingBlock = true;
                  this.emit('thinking_start', {});
                }
                break;

              case 'response.refusal.delta':
                // Refusal deltas are observed but intentionally not accumulated:
                // response.refusal.done's `refusal` field carries the full text. We
                // explicitly handle the case so the default branch doesn't log it as
                // an unknown event type. Never append to `content` — refusals are
                // policy responses, not assistant output.
                break;

              case 'response.refusal.done':
                // Finalize the refusal text for this item. If multiple refusal-done
                // events fire across multiple items in one Response, the last one wins
                // (per chunk F note on the rare multi-item-refusal case).
                // A refusal in any output item poisons the whole response. Text from later
                // output items is still captured in `content`, but the normalizer's precedence
                // (per stop-reason.ts §3.4) makes refusal the canonical stopReason.
                refusalText = event.refusal;
                break;

              case 'response.function_call_arguments.delta': {
                // Accumulate function call arguments
                const toolCall = toolCallsByIndex.get(event.output_index);
                if (toolCall) {
                  toolCall.arguments += event.delta;
                  logger.trace('Responses API: Accumulated tool arguments delta', {
                    outputIndex: event.output_index,
                    deltaLength: event.delta.length,
                    totalLength: toolCall.arguments.length,
                    toolName: toolCall.name,
                  });
                }
                break;
              }

              case 'response.completed':
              case 'response.incomplete':
              case 'response.failed': {
                // All three terminal events ship the same payload shape: a
                // Response object with status + incomplete_details + error.
                // Without the explicit 'response.incomplete' / 'response.failed'
                // cases we would fall through to the synthetic 'incomplete_stream'
                // failure path below — losing the real stopReason and details.
                // (roborev job 803 Finding 2.)
                responseId = event.response.id;
                completedResponse = event.response as typeof completedResponse;
                logger.trace('Responses API: Received terminal event', {
                  eventType: event.type,
                  status: event.response.status,
                  hasUsage: !!event.response.usage,
                  responseId,
                });
                // Final event - capture and emit actual usage
                if (event.response.usage) {
                  completionUsage = event.response.usage;
                  this.emit('token_usage_update', {
                    usage: {
                      promptTokens: event.response.usage.input_tokens,
                      completionTokens: event.response.usage.output_tokens,
                      totalTokens: event.response.usage.total_tokens,
                    },
                  });
                  // Emit thinking_end with reasoning tokens if we had a thinking block
                  if (hasThinkingBlock) {
                    const reasoningTokens = (
                      event.response.usage as {
                        output_tokens_details?: { reasoning_tokens?: number };
                      }
                    )?.output_tokens_details?.reasoning_tokens;
                    this.emit('thinking_end', { tokens: reasoningTokens });
                  }
                }
                break;
              }

              default: {
                // Handle reasoning summary events (o1/o3 models)
                // These types were removed from the SDK's ResponseStreamEvent union
                // but are still sent at runtime
                const eventType = (event as { type: string }).type;
                if (eventType === 'response.reasoning_summary.delta') {
                  const summaryDelta = event as { delta: string };
                  if (summaryDelta.delta) {
                    this.emit('thinking_delta', { text: summaryDelta.delta });
                  }
                }
                // 'response.reasoning_summary.done' needs no action — thinking_end
                // is emitted at response.completed with actual token count from usage
                break;
              }
            }
          }

          // Convert accumulated tool calls to final format
          // Handle incomplete tool calls gracefully (can occur if stream is aborted)
          for (const buffer of toolCallsByIndex.values()) {
            try {
              // Tool-name un-sanitization is handled by AIProvider — report the wire name.
              toolCalls.push({
                id: buffer.call_id,
                name: buffer.name,
                arguments: JSON.parse(buffer.arguments) as Record<string, unknown>,
              });
            } catch (error) {
              // If stream was aborted, incomplete tool calls are expected
              if (signal?.aborted) {
                logger.debug('Incomplete tool call due to aborted Responses API stream', {
                  toolName: buffer.name,
                });
                continue;
              }
              logger.error('Failed to parse Responses API streaming tool call', {
                toolName: buffer.name,
                argumentsLength: buffer.arguments.length,
                argumentsPreview: buffer.arguments.substring(0, 200),
                argumentsSuffix: buffer.arguments.substring(
                  Math.max(0, buffer.arguments.length - 100)
                ),
                error: (error as Error).message,
              });
              // Don't throw - log and skip the broken tool call to prevent agent crash
              continue;
            }
          }

          logger.trace('Received streaming response from OpenAI Responses API', {
            provider: 'openai',
            api: 'responses',
            contentLength: content.length,
            toolCallCount: toolCalls.length,
            toolCallNames: toolCalls.map((tc) => tc.name),
          });

          // Estimate usage if not provided in final event
          const systemPrompt = this.getEffectiveSystemPrompt(messages);
          const nonSystemMessages = messages.filter((m) => m.role !== 'system');
          const promptText =
            systemPrompt + '\n' + nonSystemMessages.map((m) => m.content).join(' ');
          const toolsText = tools.length > 0 ? JSON.stringify(tools) : '';

          const countedTokens = await this._countTokensImpl(messages, tools, model);
          const estimatedPromptTokens =
            countedTokens ?? this.estimateTokens(promptText + toolsText);

          // Normalize the terminal stop signal via the shared Responses-API
          // normalizer. If we never saw a `response.completed` event, branch
          // on the abort signal: user-aborted streams map to 'cancelled', but
          // streams that ended early for any other reason (connection drop,
          // malformed SDK behavior) map to 'failed' with an incomplete_stream
          // code so downstream consumers can distinguish them.
          let stopReason: LaceStopReason;
          let stopDetails: LaceStopDetails | null;
          if (completedResponse) {
            ({ stopReason, stopDetails } = normalizeOpenAIResponsesStop(
              completedResponse.status ?? 'unknown',
              completedResponse.incomplete_details ?? null,
              completedResponse.error ?? null,
              // Refusal text captured from response.refusal.done above (chunk F).
              // When non-null, the normalizer's precedence (spec §3.4) puts this
              // ahead of `status: completed`, yielding stopReason='refusal'.
              refusalText,
              toolCalls.length > 0
            ));
          } else if (signal?.aborted) {
            stopReason = 'cancelled';
            stopDetails = { type: 'cancelled', reason: 'abort_signal' };
          } else {
            logger.warn('Responses API stream ended without completion event', {
              toolCallCount: toolCalls.length,
              toolNames: toolCalls.map((tc) => tc.name),
            });
            stopReason = 'failed';
            stopDetails = {
              type: 'failed',
              code: 'incomplete_stream',
              message: 'Responses API stream ended without completion event',
              source: 'openai_responses_failed_status',
            };
          }

          const response = {
            content,
            toolCalls,
            stopReason,
            stopDetails,
            usage: completionUsage
              ? {
                  promptTokens: completionUsage.input_tokens,
                  completionTokens: completionUsage.output_tokens,
                  totalTokens: completionUsage.total_tokens,
                }
              : {
                  promptTokens: estimatedPromptTokens,
                  completionTokens: estimatedOutputTokens,
                  totalTokens: estimatedPromptTokens + estimatedOutputTokens,
                },
            responseId, // Include response ID for conversation chaining
          };

          this.emit('complete', { response });

          return response;
        } catch (error) {
          // Run the classifier BEFORE emitting an error log: recoverable
          // context-window 400s shouldn't surface as ERROR-level noise.
          const classified = tryClassifyAsContextWindow(
            error,
            'OpenAIProvider (Responses API streaming)'
          );
          if (classified) return classified;

          // Check for streaming verification error FIRST (special case fallback)
          if (this.isStreamingVerificationError(error)) {
            logger.warn(
              'OpenAI Responses API streaming requires organization verification, falling back to non-streaming mode',
              { model }
            );
            // Fall back to non-streaming Responses API
            return this._createResponsesAPIResponse(messages, tools, model, signal);
          }

          const errorObj = error as Error;
          logger.error('Streaming error from OpenAI Responses API', { error: errorObj.message });

          // If stream wasn't created yet, let error bubble up for retry handling
          // This allows rate limit errors during stream creation to be retried
          if (!streamCreated) {
            throw error;
          }

          throw error;
        }
      },
      {
        signal,
        isStreaming: true,
        canRetry: () => !streamCreated && !streamingStarted,
      }
    );
  }

  getProviderInfo(): ProviderInfo {
    return {
      name: 'openai',
      displayName: 'OpenAI',
      requiresApiKey: true,
      configurationHint: 'Set OPENAI_API_KEY or OPENAI_KEY environment variable',
    };
  }

  isConfigured(): boolean {
    const config = this._config as OpenAIProviderConfig;
    const configBaseURL = config.baseURL as string | undefined;
    // Configured if we have an API key, or if we have a custom base URL (for local endpoints)
    return (!!config.apiKey && config.apiKey.length > 0) || !!configBaseURL;
  }

  override isRecoverableError(error: unknown): boolean {
    // Safely narrow unknown to non-null object
    if (!error || typeof error !== 'object' || error === null) {
      return false;
    }

    // Cast to safe interface for property access
    const errorObj = error as {
      status?: number;
      statusCode?: number;
      type?: string;
      code?: string;
      constructor?: { name?: string };
      error?: {
        type?: string;
        code?: string;
        status_code?: number;
      };
    };

    // OpenAI SDK throws BadRequestError for 400 status codes
    if (errorObj.constructor?.name === 'BadRequestError') {
      return true;
    }

    // Check for 400 status code (handle both status and statusCode fields)
    const status = errorObj.status ?? errorObj.statusCode ?? errorObj.error?.status_code;
    if (typeof status === 'number' && status === 400) {
      return true;
    }

    // Check for specific OpenAI error types that indicate recoverable tool issues
    if (errorObj.type === 'invalid_request_error' || errorObj.code === 'tool_use_failed') {
      return true;
    }

    // Check nested error structure (some OpenAI errors nest details)
    if (errorObj.error) {
      if (
        errorObj.error.type === 'invalid_request_error' ||
        errorObj.error.code === 'tool_use_failed'
      ) {
        return true;
      }
    }

    return false;
  }

  // Clean up encoder cache to prevent memory leaks
  destroy(): void {
    for (const encoder of this._encoderCache.values()) {
      encoder.free();
    }
    this._encoderCache.clear();
    super.removeAllListeners();
  }
}
