import type {
  AgentSpanData,
  CustomSpanData,
  FunctionSpanData,
  GenerationSpanData,
  GenerationUsageData,
  HandoffSpanData,
  MCPListToolsSpanData,
  ResponseSpanData,
  Span,
  SpanData,
  Trace,
  TracingProcessor,
} from "@openai/agents";
import type { Attributes, HrTime, Span as OtelSpan, TracerProvider } from "@opentelemetry/api";
import { context, SpanStatusCode, trace } from "@opentelemetry/api";
import { isTracingSuppressed } from "@opentelemetry/core";

import type { TraceConfigOptions } from "@arizeai/openinference-core";
import { OITracer, safelyJSONStringify } from "@arizeai/openinference-core";
import {
  GRAPH_NODE_ID,
  GRAPH_NODE_PARENT_ID,
  INPUT_MIME_TYPE,
  INPUT_VALUE,
  LLM_INPUT_MESSAGES,
  LLM_INVOCATION_PARAMETERS,
  LLM_MODEL_NAME,
  LLM_OUTPUT_MESSAGES,
  LLM_PROVIDER,
  LLM_SYSTEM,
  LLM_TOKEN_COUNT_COMPLETION,
  LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING,
  LLM_TOKEN_COUNT_PROMPT,
  LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ,
  LLM_TOKEN_COUNT_TOTAL,
  LLM_TOOLS,
  LLMProvider,
  LLMSystem,
  MESSAGE_CONTENT,
  MESSAGE_CONTENT_TEXT,
  MESSAGE_CONTENT_TYPE,
  MESSAGE_CONTENTS,
  MESSAGE_ROLE,
  MESSAGE_TOOL_CALL_ID,
  MESSAGE_TOOL_CALLS,
  MimeType,
  OpenInferenceSpanKind,
  OUTPUT_MIME_TYPE,
  OUTPUT_VALUE,
  SemanticConventions,
  TOOL_CALL_FUNCTION_ARGUMENTS_JSON,
  TOOL_CALL_FUNCTION_NAME,
  TOOL_CALL_ID,
  TOOL_JSON_SCHEMA,
  TOOL_NAME,
} from "@arizeai/openinference-semantic-conventions";

// oxlint-disable-next-line typescript/prefer-ts-expect-error
// @ts-ignore - generated by prebuild step
import { VERSION } from "./version";

const INSTRUMENTATION_NAME = "@arizeai/openinference-instrumentation-openai-agents";
const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND;

/**
 * Maximum number of in-flight handoff entries kept in memory.
 *
 * A handoff entry is added when a handoff span ends, and removed when the
 * matching agent span starts. Mismatches (e.g. handoff spans that never
 * complete with a follow-up agent span) would otherwise leak memory.
 */
const MAX_HANDOFFS_IN_FLIGHT = 1000;

/**
 * Maximum number of root trace spans kept in memory by default.
 */
const DEFAULT_MAX_ROOT_SPANS_IN_FLIGHT = 1000;

/**
 * Maximum number of per-agent lifted input/output accumulators kept in
 * memory. Entries are removed when the owning agent span ends; the cap guards
 * against leaks when an agent span never ends.
 */
const MAX_AGENT_IO_IN_FLIGHT = 1000;

/**
 * Maximum number of started-but-not-ended child spans kept in memory.
 * Entries are removed when the matching end event arrives; the cap guards
 * against leaks when an end event never arrives.
 */
const MAX_SPANS_IN_FLIGHT = 5000;

/**
 * Maximum number of suppressed trace/span ids retained. Entries are removed
 * on the matching end event; the cap guards against leaks when an end event
 * never arrives.
 */
const MAX_SUPPRESSED_IDS_RETAINED = 5000;

export type OpenInferenceTracingProcessorOptions = {
  tracerProvider?: TracerProvider;
  traceConfig?: TraceConfigOptions;
  maxRootSpansInFlight?: number;
};

// ─── Type guards ─────────────────────────────────────────────────────────────

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isString(value: unknown): value is string {
  return typeof value === "string";
}

function isNumber(value: unknown): value is number {
  return typeof value === "number";
}

function isMessageTextPart(part: Record<string, unknown>): part is Record<string, unknown> & {
  text: string;
} {
  return (part.type === "text" || part.type === "input_text") && isString(part.text);
}

function normalizeMaxRootSpansInFlight(value: number | undefined): number {
  if (value === undefined || !Number.isFinite(value)) {
    return DEFAULT_MAX_ROOT_SPANS_IN_FLIGHT;
  }
  return Math.max(1, Math.floor(value));
}

/**
 * Evicts the oldest entries of a `Set` used as an insertion-ordered LRU once
 * it exceeds `max`.
 */
function boundIdSet(ids: Set<string>, max: number): void {
  while (ids.size > max) {
    const oldest = ids.values().next().value;
    if (oldest === undefined) break;
    ids.delete(oldest);
  }
}

// ─── Time helpers ────────────────────────────────────────────────────────────

/**
 * Converts an ISO 8601 timestamp string to an OpenTelemetry {@link HrTime}
 * tuple `[seconds, remainder-in-nanoseconds]`.
 *
 * The OpenTelemetry SDK interprets a plain `number` `startTime` as either an
 * epoch-millisecond value or a `performance.now()` reading, so passing
 * nanoseconds as a single number truncates precision and confuses the
 * timeOrigin heuristic. Returning an `HrTime` tuple is unambiguous and
 * preserves sub-millisecond precision when the ISO string carries it.
 *
 * Returns `undefined` for unparseable timestamps so callers fall back to the
 * OTel SDK clock ("now") rather than producing spans dated at epoch 0.
 *
 * @example
 * isoToHrTime("2024-01-01T00:00:00.123456Z") → [1704067200, 123456000]
 */
function isoToHrTime(isoTimestamp: string): HrTime | undefined {
  const epochMilliseconds = Date.parse(isoTimestamp);
  if (Number.isNaN(epochMilliseconds)) return undefined;

  const seconds = Math.floor(epochMilliseconds / 1000);
  // Date.parse only retains millisecond precision. Recover the full
  // sub-second precision from the ISO fractional-seconds field directly.
  let nanoseconds = 0;
  const fractionMatch = /\.(\d+)/.exec(isoTimestamp);
  if (fractionMatch) {
    // Pad to 9 digits (nanosecond precision); truncate any longer fragment.
    const nanosecondDigits = fractionMatch[1].slice(0, 9).padEnd(9, "0");
    nanoseconds = parseInt(nanosecondDigits, 10);
  } else {
    // No fractional part: derive nanoseconds from the epoch milliseconds alone.
    nanoseconds = (epochMilliseconds % 1000) * 1_000_000;
  }
  return [seconds, nanoseconds];
}

// ─── Span name & kind ────────────────────────────────────────────────────────

/**
 * Derives a human-readable span name from an SDK span object.
 *
 * - For span types with a `name` field (agent/function/guardrail/custom/mcp)
 *   the name is used directly.
 * - For handoff spans we render `handoff to <to_agent>` to make the trace
 *   timeline readable.
 * - Otherwise we fall back to the span data's `type` discriminator.
 */
function getSpanName(span: Span<SpanData>): string {
  const data = span.spanData;
  if ("name" in data && isString(data.name)) {
    return data.name;
  }
  if (data.type === "handoff" && isString(data.to_agent)) {
    return `handoff to ${data.to_agent}`;
  }
  return data.type;
}

/**
 * Maps a SpanData type to the corresponding OpenInference span kind.
 *
 * @see https://github.com/Arize-ai/openinference/blob/main/spec/semantic_conventions.md
 */
function getSpanKind(data: SpanData): OpenInferenceSpanKind {
  switch (data.type) {
    case "agent":
      return OpenInferenceSpanKind.AGENT;
    case "generation":
    case "response":
      return OpenInferenceSpanKind.LLM;
    case "function":
    case "handoff":
    case "mcp_tools":
      return OpenInferenceSpanKind.TOOL;
    case "guardrail":
      return OpenInferenceSpanKind.GUARDRAIL;
    default:
      return OpenInferenceSpanKind.CHAIN;
  }
}

// ─── Message extraction ──────────────────────────────────────────────────────

function assignToolCallAttributes(
  attributes: Attributes,
  toolCallPrefix: string,
  toolCall: Record<string, unknown>,
): void {
  if (isString(toolCall.id)) {
    attributes[`${toolCallPrefix}.${TOOL_CALL_ID}`] = toolCall.id;
  }
  if (!isRecord(toolCall.function)) {
    return;
  }
  if (isString(toolCall.function.name)) {
    attributes[`${toolCallPrefix}.${TOOL_CALL_FUNCTION_NAME}`] = toolCall.function.name;
  }
  if (isString(toolCall.function.arguments)) {
    attributes[`${toolCallPrefix}.${TOOL_CALL_FUNCTION_ARGUMENTS_JSON}`] =
      toolCall.function.arguments;
  }
}

/**
 * Extracts indexed message attributes from a list of chat-style messages.
 *
 * Each message contributes `${prefix}.${messageIndex}.message.role`, `.message.content`
 * (or per-content-part attributes for multipart messages), and any
 * `tool_calls` entries.
 */
function extractMessageList(
  messages: ReadonlyArray<unknown>,
  prefix: string,
  startIndex = 0,
): Attributes {
  const attributes: Attributes = {};

  for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
    const message = messages[messageIndex];
    if (!isRecord(message)) continue;
    const messagePrefix = `${prefix}.${startIndex + messageIndex}`;

    if (message.type === "function_call") {
      attributes[`${messagePrefix}.${MESSAGE_ROLE}`] = "assistant";
      const toolCallPrefix = `${messagePrefix}.${MESSAGE_TOOL_CALLS}.0`;
      const callId = isString(message.callId) ? message.callId : message.call_id;
      if (isString(callId)) {
        attributes[`${toolCallPrefix}.${TOOL_CALL_ID}`] = callId;
      }
      if (isString(message.name)) {
        attributes[`${toolCallPrefix}.${TOOL_CALL_FUNCTION_NAME}`] = message.name;
      }
      if (isString(message.arguments)) {
        attributes[`${toolCallPrefix}.${TOOL_CALL_FUNCTION_ARGUMENTS_JSON}`] = message.arguments;
      }
      continue;
    }

    if (message.type === "function_call_result") {
      attributes[`${messagePrefix}.${MESSAGE_ROLE}`] = "tool";
      const callId = isString(message.callId) ? message.callId : message.call_id;
      if (isString(callId)) {
        attributes[`${messagePrefix}.${MESSAGE_TOOL_CALL_ID}`] = callId;
      }
      if (isRecord(message.output) && isString(message.output.text)) {
        attributes[`${messagePrefix}.${MESSAGE_CONTENT}`] = message.output.text;
      } else if (isString(message.output)) {
        attributes[`${messagePrefix}.${MESSAGE_CONTENT}`] = message.output;
      } else if (message.output != null) {
        attributes[`${messagePrefix}.${MESSAGE_CONTENT}`] =
          safelyJSONStringify(message.output) ?? String(message.output);
      }
      continue;
    }

    if (isString(message.role)) {
      attributes[`${messagePrefix}.${MESSAGE_ROLE}`] = message.role;
    }

    const content = message.content;
    if (isString(content)) {
      attributes[`${messagePrefix}.${MESSAGE_CONTENT}`] = content;
    } else if (Array.isArray(content)) {
      for (let partIndex = 0; partIndex < content.length; partIndex++) {
        const part = content[partIndex];
        if (!isRecord(part)) continue;
        const partPrefix = `${messagePrefix}.${MESSAGE_CONTENTS}.${partIndex}`;
        if (isMessageTextPart(part)) {
          attributes[`${partPrefix}.${MESSAGE_CONTENT_TYPE}`] = "text";
          attributes[`${partPrefix}.${MESSAGE_CONTENT_TEXT}`] = part.text;
        }
      }
    }

    if (isString(message.tool_call_id)) {
      attributes[`${messagePrefix}.${MESSAGE_TOOL_CALL_ID}`] = message.tool_call_id;
    }

    if (Array.isArray(message.tool_calls)) {
      let toolCallIndex = 0;
      for (const toolCall of message.tool_calls) {
        if (!isRecord(toolCall)) continue;
        assignToolCallAttributes(
          attributes,
          `${messagePrefix}.${MESSAGE_TOOL_CALLS}.${toolCallIndex}`,
          toolCall,
        );
        toolCallIndex++;
      }
    }
  }

  return attributes;
}

// ─── Generation span ─────────────────────────────────────────────────────────

/**
 * Extracts attributes from {@link GenerationSpanData}.
 *
 * In the chat_completions transport, the SDK stores raw ChatCompletion
 * response objects in `data.output[]`, and `data.usage` is `undefined`.
 * In the responses transport, `data.usage` is populated and `data.output`
 * contains plain message dicts.
 *
 * We probe the first element of `data.output` for a `choices` field to decide
 * which extraction path to take.
 */
function getGenerationAttributes(data: GenerationSpanData): Attributes {
  const attributes: Attributes = {};

  if (isString(data.model)) {
    attributes[LLM_MODEL_NAME] = data.model;
  }

  if (isRecord(data.model_config)) {
    const filteredModelConfig: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(data.model_config)) {
      if (value != null) filteredModelConfig[key] = value;
    }
    if (Object.keys(filteredModelConfig).length > 0) {
      attributes[LLM_INVOCATION_PARAMETERS] = safelyJSONStringify(filteredModelConfig) ?? "";
    }
  }

  if (Array.isArray(data.input) && data.input.length > 0) {
    const inputJson = safelyJSONStringify(data.input);
    if (inputJson) {
      attributes[INPUT_VALUE] = inputJson;
      attributes[INPUT_MIME_TYPE] = MimeType.JSON;
    }
    Object.assign(attributes, extractMessageList(data.input, LLM_INPUT_MESSAGES));
  }

  if (Array.isArray(data.output) && data.output.length > 0) {
    const outputJson = safelyJSONStringify(data.output);
    if (outputJson) {
      attributes[OUTPUT_VALUE] = outputJson;
      attributes[OUTPUT_MIME_TYPE] = MimeType.JSON;
    }

    const firstOutputItem = data.output[0];
    if (isRecord(firstOutputItem) && firstOutputItem.choices !== undefined) {
      // chat_completions transport: output[] are ChatCompletion responses.
      Object.assign(attributes, extractFromChatCompletionResponses(data.output));
    } else {
      // Responses API or generic message-list shape.
      Object.assign(attributes, extractMessageList(data.output, LLM_OUTPUT_MESSAGES));
    }
  }

  if (data.usage) {
    Object.assign(attributes, getGenerationUsageAttributes(data.usage));
  }

  return attributes;
}

/**
 * Extracts output messages and aggregated token counts from an array of
 * ChatCompletion response objects (chat_completions transport).
 *
 * Multiple response objects may be present when the SDK retries or batches
 * model calls. Token counts are summed across all responses.
 */
function extractFromChatCompletionResponses(responses: ReadonlyArray<unknown>): Attributes {
  const attributes: Attributes = {};
  let messageIndex = 0;
  let totalPromptTokens = 0;
  let totalCompletionTokens = 0;
  let totalTokens = 0;
  let totalCacheReadTokens = 0;
  let totalReasoningTokens = 0;
  let hasPromptTokens = false;
  let hasCompletionTokens = false;
  let hasCacheReadTokens = false;
  let hasReasoningTokens = false;
  let hasTotalTokens = false;

  for (const response of responses) {
    if (!isRecord(response)) continue;

    // Token usage
    if (isRecord(response.usage)) {
      const usage = response.usage;
      const promptTokens = isNumber(usage.prompt_tokens) ? usage.prompt_tokens : undefined;
      const completionTokens = isNumber(usage.completion_tokens)
        ? usage.completion_tokens
        : undefined;
      if (promptTokens !== undefined) {
        totalPromptTokens += promptTokens;
        hasPromptTokens = true;
      }
      if (completionTokens !== undefined) {
        totalCompletionTokens += completionTokens;
        hasCompletionTokens = true;
      }
      if (isNumber(usage.total_tokens)) {
        totalTokens += usage.total_tokens;
        hasTotalTokens = true;
      } else if (promptTokens !== undefined || completionTokens !== undefined) {
        totalTokens += (promptTokens ?? 0) + (completionTokens ?? 0);
        hasTotalTokens = true;
      }
      // OpenAI / DeepSeek prompt_tokens_details.cached_tokens
      if (
        isRecord(usage.prompt_tokens_details) &&
        isNumber(usage.prompt_tokens_details.cached_tokens)
      ) {
        totalCacheReadTokens += usage.prompt_tokens_details.cached_tokens;
        hasCacheReadTokens = true;
      }
      // o-series completion_tokens_details.reasoning_tokens
      if (
        isRecord(usage.completion_tokens_details) &&
        isNumber(usage.completion_tokens_details.reasoning_tokens)
      ) {
        totalReasoningTokens += usage.completion_tokens_details.reasoning_tokens;
        hasReasoningTokens = true;
      }
    }

    // Output messages
    if (!Array.isArray(response.choices)) continue;
    for (const choice of response.choices) {
      if (!isRecord(choice) || !isRecord(choice.message)) continue;
      const message = choice.message;
      const messagePrefix = `${LLM_OUTPUT_MESSAGES}.${messageIndex}`;

      if (isString(message.role)) {
        attributes[`${messagePrefix}.${MESSAGE_ROLE}`] = message.role;
      }

      const content = message.content;
      if (isString(content)) {
        attributes[`${messagePrefix}.${MESSAGE_CONTENT}`] = content;
      } else if (Array.isArray(content)) {
        for (let partIndex = 0; partIndex < content.length; partIndex++) {
          const part = content[partIndex];
          if (!isRecord(part)) continue;
          const partPrefix = `${messagePrefix}.${MESSAGE_CONTENTS}.${partIndex}`;
          if (part.type === "text" && isString(part.text)) {
            attributes[`${partPrefix}.${MESSAGE_CONTENT_TYPE}`] = "text";
            attributes[`${partPrefix}.${MESSAGE_CONTENT_TEXT}`] = part.text;
          }
        }
      }

      if (Array.isArray(message.tool_calls)) {
        let toolCallIndex = 0;
        for (const toolCall of message.tool_calls) {
          if (!isRecord(toolCall)) continue;
          assignToolCallAttributes(
            attributes,
            `${messagePrefix}.${MESSAGE_TOOL_CALLS}.${toolCallIndex}`,
            toolCall,
          );
          toolCallIndex++;
        }
      }

      messageIndex++;
    }
  }

  // Record any counts that were actually observed, including legitimate zeros.
  if (hasPromptTokens) attributes[LLM_TOKEN_COUNT_PROMPT] = totalPromptTokens;
  if (hasCompletionTokens) attributes[LLM_TOKEN_COUNT_COMPLETION] = totalCompletionTokens;
  if (hasTotalTokens) attributes[LLM_TOKEN_COUNT_TOTAL] = totalTokens;
  if (hasCacheReadTokens)
    attributes[LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ] = totalCacheReadTokens;
  if (hasReasoningTokens)
    attributes[LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING] = totalReasoningTokens;

  return attributes;
}

/**
 * Extracts token usage attributes from {@link GenerationUsageData}.
 *
 * The SDK uses `input_tokens` / `output_tokens` for generation usage and
 * nests details under a `details` object.
 */
function getGenerationUsageAttributes(usage: GenerationUsageData): Attributes {
  const attributes: Attributes = {};
  if (isNumber(usage.input_tokens)) {
    attributes[LLM_TOKEN_COUNT_PROMPT] = usage.input_tokens;
  }
  if (isNumber(usage.output_tokens)) {
    attributes[LLM_TOKEN_COUNT_COMPLETION] = usage.output_tokens;
  }
  if (isNumber(usage.input_tokens) && isNumber(usage.output_tokens)) {
    attributes[LLM_TOKEN_COUNT_TOTAL] = usage.input_tokens + usage.output_tokens;
  }
  if (isRecord(usage.details)) {
    if (isNumber(usage.details.cached_tokens)) {
      attributes[LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ] = usage.details.cached_tokens;
    }
    if (isNumber(usage.details.reasoning_tokens)) {
      attributes[LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING] = usage.details.reasoning_tokens;
    }
  }
  return attributes;
}

// ─── Function span ───────────────────────────────────────────────────────────

/**
 * Extracts attributes from {@link FunctionSpanData} (tool / function calls).
 */
function getFunctionAttributes(data: FunctionSpanData): Attributes {
  const attributes: Attributes = {};
  attributes[TOOL_NAME] = data.name;
  if (data.input) {
    attributes[INPUT_VALUE] = data.input;
    attributes[INPUT_MIME_TYPE] = MimeType.JSON;
  }
  if (data.output != null) {
    if (isString(data.output)) {
      attributes[OUTPUT_VALUE] = data.output;
      // Heuristic: if the output looks like a JSON object, mark it as JSON.
      if (data.output.length > 1 && data.output.startsWith("{") && data.output.endsWith("}")) {
        attributes[OUTPUT_MIME_TYPE] = MimeType.JSON;
      }
    } else {
      attributes[OUTPUT_VALUE] = safelyJSONStringify(data.output) ?? String(data.output);
    }
  }
  return attributes;
}

// ─── Handoff span ────────────────────────────────────────────────────────────

/**
 * Extracts attributes from {@link HandoffSpanData} so handoff TOOL spans are
 * meaningful in backends that primarily render tool name and I/O fields.
 */
function getHandoffAttributes(data: HandoffSpanData): Attributes {
  const attributes: Attributes = {};

  if (isString(data.to_agent)) {
    attributes[TOOL_NAME] = `handoff_to_${data.to_agent}`;
    attributes[OUTPUT_VALUE] = safelyJSONStringify({ to_agent: data.to_agent }) ?? "";
    attributes[OUTPUT_MIME_TYPE] = MimeType.JSON;
  }

  if (isString(data.from_agent)) {
    attributes[INPUT_VALUE] = safelyJSONStringify({ from_agent: data.from_agent }) ?? "";
    attributes[INPUT_MIME_TYPE] = MimeType.JSON;
  }

  return attributes;
}

// ─── Response span (Responses API) ───────────────────────────────────────────

/**
 * Extracts output messages and tool calls from a Responses API output array.
 */
function extractResponseOutput(output: ReadonlyArray<unknown>): Attributes {
  const attributes: Attributes = {};
  let messageIndex = 0;

  for (const item of output) {
    if (!isRecord(item)) continue;

    if (item.type === "message") {
      const messagePrefix = `${LLM_OUTPUT_MESSAGES}.${messageIndex}`;
      if (isString(item.role)) {
        attributes[`${messagePrefix}.${MESSAGE_ROLE}`] = item.role;
      }
      if (Array.isArray(item.content)) {
        for (let partIndex = 0; partIndex < item.content.length; partIndex++) {
          const part = item.content[partIndex];
          if (!isRecord(part)) continue;
          const partPrefix = `${messagePrefix}.${MESSAGE_CONTENTS}.${partIndex}`;
          if ((part.type === "output_text" || part.type === "text") && isString(part.text)) {
            attributes[`${partPrefix}.${MESSAGE_CONTENT_TYPE}`] = "text";
            attributes[`${partPrefix}.${MESSAGE_CONTENT_TEXT}`] = part.text;
          } else if (part.type === "refusal" && isString(part.refusal)) {
            attributes[`${partPrefix}.${MESSAGE_CONTENT_TYPE}`] = "text";
            attributes[`${partPrefix}.${MESSAGE_CONTENT_TEXT}`] = part.refusal;
          }
        }
      }
      messageIndex++;
    } else if (item.type === "function_call") {
      const messagePrefix = `${LLM_OUTPUT_MESSAGES}.${messageIndex}`;
      attributes[`${messagePrefix}.${MESSAGE_ROLE}`] = "assistant";
      const toolCallPrefix = `${messagePrefix}.${MESSAGE_TOOL_CALLS}.0`;
      if (isString(item.call_id)) {
        attributes[`${toolCallPrefix}.${TOOL_CALL_ID}`] = item.call_id;
      }
      if (isString(item.name)) {
        attributes[`${toolCallPrefix}.${TOOL_CALL_FUNCTION_NAME}`] = item.name;
      }
      if (isString(item.arguments)) {
        attributes[`${toolCallPrefix}.${TOOL_CALL_FUNCTION_ARGUMENTS_JSON}`] = item.arguments;
      }
      messageIndex++;
    }
    // Other item types (file_search_call, web_search_call, computer_call, ...)
    // are not yet supported, matching the Python instrumentation's TODOs.
  }

  return attributes;
}

/**
 * Response object keys excluded from `llm.invocation_parameters`.
 *
 * Mirrors the Python instrumentation, which dumps the Response object minus
 * the bulky fields that are already captured by dedicated attributes
 * (input/output messages, output text, usage, tools) or carry no invocation
 * semantics.
 */
const RESPONSE_NON_INVOCATION_PARAM_KEYS: ReadonlySet<string> = new Set([
  "instructions",
  "object",
  "output_text",
  "tools",
  "usage",
  "output",
  "error",
  "status",
]);

/**
 * Extracts attributes from {@link ResponseSpanData}.
 *
 * The SDK exposes the input as `_input` (string or list of input items) and
 * the raw `Response` object as `_response` (a plain dict at runtime). We pull
 * out tools, usage, output, instructions and model name from the response.
 */
function getResponseAttributes(data: ResponseSpanData): Attributes {
  const attributes: Attributes = {};
  // The Responses API is only served by OpenAI.
  attributes[LLM_PROVIDER] = LLMProvider.OPENAI;

  // Input
  const hasSystemInstruction =
    isRecord(data._response) &&
    isString(data._response.instructions) &&
    data._response.instructions.length > 0;
  if (data._input != null) {
    if (isString(data._input)) {
      attributes[INPUT_VALUE] = data._input;
      Object.assign(
        attributes,
        extractMessageList(
          [{ role: "user", content: data._input }],
          LLM_INPUT_MESSAGES,
          hasSystemInstruction ? 1 : 0,
        ),
      );
    } else if (Array.isArray(data._input)) {
      const inputJson = safelyJSONStringify(data._input);
      if (inputJson) {
        attributes[INPUT_VALUE] = inputJson;
        attributes[INPUT_MIME_TYPE] = MimeType.JSON;
      }
      Object.assign(
        attributes,
        extractMessageList(data._input, LLM_INPUT_MESSAGES, hasSystemInstruction ? 1 : 0),
      );
    }
  }

  // Response
  if (!isRecord(data._response)) return attributes;
  const response = data._response;

  const responseJson = safelyJSONStringify(response);
  if (responseJson) {
    attributes[OUTPUT_VALUE] = responseJson;
    attributes[OUTPUT_MIME_TYPE] = MimeType.JSON;
  }

  if (isString(response.model)) {
    attributes[LLM_MODEL_NAME] = response.model;
  }

  // Invocation parameters: the Response object minus output/usage/tools,
  // matching the Python instrumentation's model_dump exclusions.
  const invocationParameters: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(response)) {
    if (value == null || RESPONSE_NON_INVOCATION_PARAM_KEYS.has(key)) continue;
    invocationParameters[key] = value;
  }
  if (Object.keys(invocationParameters).length > 0) {
    const invocationParametersJson = safelyJSONStringify(invocationParameters);
    if (invocationParametersJson) {
      attributes[LLM_INVOCATION_PARAMETERS] = invocationParametersJson;
    }
  }

  // Token usage. The Responses API uses input_tokens/output_tokens/total_tokens
  // and nests cache/reasoning details under *_tokens_details.
  if (isRecord(response.usage)) {
    const usage = response.usage;
    if (isNumber(usage.input_tokens)) attributes[LLM_TOKEN_COUNT_PROMPT] = usage.input_tokens;
    if (isNumber(usage.output_tokens)) attributes[LLM_TOKEN_COUNT_COMPLETION] = usage.output_tokens;
    if (isNumber(usage.total_tokens)) attributes[LLM_TOKEN_COUNT_TOTAL] = usage.total_tokens;
    if (
      isRecord(usage.input_tokens_details) &&
      isNumber(usage.input_tokens_details.cached_tokens)
    ) {
      attributes[LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ] =
        usage.input_tokens_details.cached_tokens;
    }
    if (
      isRecord(usage.output_tokens_details) &&
      isNumber(usage.output_tokens_details.reasoning_tokens)
    ) {
      attributes[LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING] =
        usage.output_tokens_details.reasoning_tokens;
    }
  }

  // Tools
  if (Array.isArray(response.tools)) {
    for (let toolIndex = 0; toolIndex < response.tools.length; toolIndex++) {
      const tool = response.tools[toolIndex];
      if (!isRecord(tool) || tool.type !== "function") continue;
      const schema = safelyJSONStringify({
        type: "function",
        function: {
          name: tool.name,
          description: tool.description,
          parameters: tool.parameters,
          strict: tool.strict,
        },
      });
      if (schema) {
        attributes[`${LLM_TOOLS}.${toolIndex}.${TOOL_JSON_SCHEMA}`] = schema;
      }
    }
  }

  // Output messages and function calls
  if (Array.isArray(response.output)) {
    Object.assign(attributes, extractResponseOutput(response.output));
  }

  // System instructions become the first input message (matching the Python
  // instrumentation, which prepends a system message at index 0).
  if (isString(response.instructions) && response.instructions.length > 0) {
    attributes[`${LLM_INPUT_MESSAGES}.0.${MESSAGE_ROLE}`] = "system";
    attributes[`${LLM_INPUT_MESSAGES}.0.${MESSAGE_CONTENT}`] = response.instructions;
  }

  return attributes;
}

// ─── Lifted input/output for AGENT spans ─────────────────────────────────────

/**
 * Input/output captured from LLM child spans so it can be surfaced on the
 * enclosing AGENT spans (the per-agent span and the root trace span), which
 * the SDK gives no input/output of their own.
 */
type LiftedIO = {
  input?: string;
  inputMimeType?: MimeType;
  output?: string;
  outputMimeType?: MimeType;
};

/**
 * Merges newly observed LLM-span input/output into an accumulator. The first
 * observed input wins (the original request); the last observed output wins
 * (the final answer).
 */
function mergeLiftedIO(target: LiftedIO, source: LiftedIO): void {
  if (target.input === undefined && source.input !== undefined) {
    target.input = source.input;
    target.inputMimeType = source.inputMimeType;
  }
  if (source.output !== undefined) {
    target.output = source.output;
    target.outputMimeType = source.outputMimeType;
  }
}

function liftedIOAttributes(liftedIO: LiftedIO | undefined): Attributes {
  const attributes: Attributes = {};
  if (!liftedIO) return attributes;
  if (liftedIO.input !== undefined) {
    attributes[INPUT_VALUE] = liftedIO.input;
    if (liftedIO.inputMimeType) attributes[INPUT_MIME_TYPE] = liftedIO.inputMimeType;
  }
  if (liftedIO.output !== undefined) {
    attributes[OUTPUT_VALUE] = liftedIO.output;
    if (liftedIO.outputMimeType) attributes[OUTPUT_MIME_TYPE] = liftedIO.outputMimeType;
  }
  return attributes;
}

/**
 * Extracts the final assistant text from a Responses API `Response` object,
 * preferring the SDK's `output_text` convenience field.
 */
function getResponseOutputText(response: Record<string, unknown>): string | undefined {
  if (isString(response.output_text) && response.output_text.length > 0) {
    return response.output_text;
  }
  if (!Array.isArray(response.output)) return undefined;
  const texts: string[] = [];
  for (const item of response.output) {
    if (!isRecord(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
    for (const part of item.content) {
      if (
        isRecord(part) &&
        (part.type === "output_text" || part.type === "text") &&
        isString(part.text)
      ) {
        texts.push(part.text);
      }
    }
  }
  return texts.length > 0 ? texts.join("") : undefined;
}

function getResponseLiftedIO(data: ResponseSpanData): LiftedIO {
  const liftedIO: LiftedIO = {};
  if (isString(data._input)) {
    liftedIO.input = data._input;
  } else if (Array.isArray(data._input)) {
    const inputJson = safelyJSONStringify(data._input);
    if (inputJson) {
      liftedIO.input = inputJson;
      liftedIO.inputMimeType = MimeType.JSON;
    }
  }
  if (isRecord(data._response)) {
    liftedIO.output = getResponseOutputText(data._response);
  }
  return liftedIO;
}

function getGenerationLiftedIO(data: GenerationSpanData): LiftedIO {
  const liftedIO: LiftedIO = {};
  if (Array.isArray(data.input) && data.input.length > 0) {
    const inputJson = safelyJSONStringify(data.input);
    if (inputJson) {
      liftedIO.input = inputJson;
      liftedIO.inputMimeType = MimeType.JSON;
    }
  }
  if (Array.isArray(data.output)) {
    const texts: string[] = [];
    for (const item of data.output) {
      if (!isRecord(item)) continue;
      if (Array.isArray(item.choices)) {
        // chat_completions transport: output[] are ChatCompletion responses.
        for (const choice of item.choices) {
          if (isRecord(choice) && isRecord(choice.message) && isString(choice.message.content)) {
            texts.push(choice.message.content);
          }
        }
      } else if (isString(item.content)) {
        texts.push(item.content);
      }
    }
    if (texts.length > 0) {
      liftedIO.output = texts.join("");
    }
  }
  return liftedIO;
}

// ─── MCP list tools span ─────────────────────────────────────────────────────

/**
 * Extracts attributes from {@link MCPListToolsSpanData}.
 */
function getMCPListToolsAttributes(data: MCPListToolsSpanData): Attributes {
  const attributes: Attributes = {};
  if (data.result != null) {
    attributes[OUTPUT_VALUE] = safelyJSONStringify(data.result) ?? "";
    attributes[OUTPUT_MIME_TYPE] = MimeType.JSON;
  }
  return attributes;
}

// ─── Custom span ─────────────────────────────────────────────────────────────

/**
 * Extracts attributes from {@link CustomSpanData}, serialising the user-defined
 * data dict as JSON output.
 */
function getCustomAttributes(data: CustomSpanData): Attributes {
  const attributes: Attributes = {};
  if (isRecord(data.data) && Object.keys(data.data).length > 0) {
    const customDataJson = safelyJSONStringify(data.data);
    if (customDataJson) {
      attributes[OUTPUT_VALUE] = customDataJson;
      attributes[OUTPUT_MIME_TYPE] = MimeType.JSON;
    }
  }
  return attributes;
}

// ─── Status ──────────────────────────────────────────────────────────────────

function setSpanStatus(otelSpan: OtelSpan, span: Span<SpanData>, eventTime?: HrTime): void {
  if (span.error) {
    let description = span.error.message || "Unknown error";
    if (span.error.data) {
      const errorDataString = isString(span.error.data)
        ? span.error.data
        : (safelyJSONStringify(span.error.data) ?? "");
      if (errorDataString) {
        description = `${description}: ${errorDataString}`;
      }
    }
    otelSpan.recordException({ message: description }, eventTime);
    otelSpan.setStatus({ code: SpanStatusCode.ERROR, message: description });
  } else {
    otelSpan.setStatus({ code: SpanStatusCode.OK });
  }
}

// ─── Processor ───────────────────────────────────────────────────────────────

/**
 * A {@link TracingProcessor} that bridges `@openai/agents` tracing events to
 * OpenTelemetry spans following the OpenInference semantic conventions.
 *
 * Register it with the agents SDK via
 * {@link OpenAIAgentsInstrumentation.instrument} (preferred) or directly with
 * `setTraceProcessors` / `addTraceProcessor`.
 *
 * @see https://github.com/Arize-ai/openinference/blob/main/spec/semantic_conventions.md
 */
export class OpenInferenceTracingProcessor implements TracingProcessor {
  private enabled = true;
  private readonly oiTracer: OITracer;
  private readonly maxRootSpansInFlight: number;

  /** trace_id → root OTel span */
  private readonly rootSpans = new Map<string, OtelSpan>();
  /** span_id → OTel span */
  private readonly otelSpans = new Map<string, OtelSpan>();
  /**
   * Tracks in-flight handoffs so that when the destination agent's span starts
   * we can record `graph.node.parent_id` to visualise multi-agent flows.
   *
   * Key: `${to_agent}:${trace_id}`. Value: `from_agent` name.
   *
   * `Map` preserves insertion order, so we use it as an LRU bound by
   * {@link MAX_HANDOFFS_IN_FLIGHT} to guard against unbounded growth when
   * handoff spans never get a follow-up agent span.
   */
  private readonly reverseHandoffs = new Map<string, string>();
  /** trace_id → input/output lifted from LLM spans for the root span. */
  private readonly traceIO = new Map<string, LiftedIO>();
  /**
   * SDK parent span_id → input/output lifted from child LLM spans, applied to
   * the parent AGENT span when it ends. `Map` insertion order is used as an
   * LRU bound by {@link MAX_AGENT_IO_IN_FLIGHT}.
   */
  private readonly agentIO = new Map<string, LiftedIO>();
  /** trace_id values whose trace start happened under suppressed tracing. */
  private readonly suppressedTraceIds = new Set<string>();
  /** span_id values skipped at start, so their end event can be ignored safely. */
  private readonly suppressedSpanIds = new Set<string>();

  constructor({
    tracerProvider,
    traceConfig,
    maxRootSpansInFlight,
  }: OpenInferenceTracingProcessorOptions = {}) {
    const baseTracer = (tracerProvider ?? trace).getTracer(INSTRUMENTATION_NAME, VERSION);
    this.oiTracer = new OITracer({ tracer: baseTracer, traceConfig });
    this.maxRootSpansInFlight = normalizeMaxRootSpansInFlight(maxRootSpansInFlight);
  }

  async onTraceStart(agentTrace: Trace): Promise<void> {
    if (!this.enabled) return;

    if (isTracingSuppressed(context.active())) {
      this.suppressedTraceIds.add(agentTrace.traceId);
      boundIdSet(this.suppressedTraceIds, MAX_SUPPRESSED_IDS_RETAINED);
      return;
    }

    const rootSpan = this.oiTracer.startSpan(agentTrace.name, {
      attributes: {
        [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.AGENT,
        // `llm.system` identifies the agents SDK, which is an OpenAI product.
        // In chat_completions mode the underlying model may be served by a
        // non-OpenAI provider (e.g. DeepSeek); `llm.provider` is set per-span
        // where it can be determined.
        [LLM_SYSTEM]: LLMSystem.OPENAI,
      },
    });
    this.rootSpans.set(agentTrace.traceId, rootSpan);
    this.evictOldestRootSpans();
  }

  async onTraceEnd(agentTrace: Trace): Promise<void> {
    this.suppressedTraceIds.delete(agentTrace.traceId);
    const liftedIO = this.traceIO.get(agentTrace.traceId);
    this.traceIO.delete(agentTrace.traceId);
    const rootSpan = this.rootSpans.get(agentTrace.traceId);
    if (!rootSpan) return;
    rootSpan.setAttributes(liftedIOAttributes(liftedIO));
    rootSpan.setStatus({ code: SpanStatusCode.OK });
    rootSpan.end();
    this.rootSpans.delete(agentTrace.traceId);
  }

  async onSpanStart(span: Span<SpanData>): Promise<void> {
    if (!span.startedAt) return;
    if (!this.enabled) return;

    if (this.shouldSuppressSpan(span)) {
      this.suppressedSpanIds.add(span.spanId);
      boundIdSet(this.suppressedSpanIds, MAX_SUPPRESSED_IDS_RETAINED);
      return;
    }

    const parentSpan = span.parentId
      ? this.otelSpans.get(span.parentId)
      : this.rootSpans.get(span.traceId);

    const parentContext = parentSpan
      ? trace.setSpan(context.active(), parentSpan)
      : context.active();

    const otelSpan = this.oiTracer.startSpan(
      getSpanName(span),
      {
        startTime: isoToHrTime(span.startedAt),
        attributes: {
          [OPENINFERENCE_SPAN_KIND]: getSpanKind(span.spanData),
          // See onTraceStart: the agents SDK is the "system"; the provider may differ.
          [LLM_SYSTEM]: LLMSystem.OPENAI,
        },
      },
      parentContext,
    );

    this.otelSpans.set(span.spanId, otelSpan);
    this.evictOldestOtelSpans();
  }

  async onSpanEnd(span: Span<SpanData>): Promise<void> {
    if (this.suppressedSpanIds.delete(span.spanId)) return;

    const otelSpan = this.otelSpans.get(span.spanId);
    if (!otelSpan) return;
    this.otelSpans.delete(span.spanId);

    // Some span types (e.g. handoff) don't have their final name resolved
    // until the span ends, so refresh it here.
    otelSpan.updateName(getSpanName(span));

    let attributes: Attributes = {};
    const data = span.spanData;
    switch (data.type) {
      case "generation":
        attributes = getGenerationAttributes(data);
        this.recordLiftedIO(span, getGenerationLiftedIO(data));
        break;
      case "function":
        attributes = getFunctionAttributes(data);
        break;
      case "response":
        attributes = getResponseAttributes(data);
        this.recordLiftedIO(span, getResponseLiftedIO(data));
        break;
      case "mcp_tools":
        attributes = getMCPListToolsAttributes(data);
        break;
      case "custom":
        attributes = getCustomAttributes(data);
        break;
      case "handoff":
        attributes = getHandoffAttributes(data);
        this.recordHandoff(data, span.traceId);
        break;
      case "agent":
        this.applyAgentGraphAttributes(otelSpan, data, span.traceId);
        attributes = liftedIOAttributes(this.agentIO.get(span.spanId));
        this.agentIO.delete(span.spanId);
        break;
      case "guardrail":
        otelSpan.setAttribute(TOOL_NAME, data.name);
        otelSpan.setAttribute("guardrail.triggered", data.triggered);
        break;
      case "speech":
      case "speech_group":
      case "transcription":
        // These span types carry no additional attributes in the current
        // OpenInference spec. Listed explicitly for exhaustiveness.
        break;
    }

    const endTime = span.endedAt ? isoToHrTime(span.endedAt) : undefined;
    otelSpan.setAttributes(attributes);
    setSpanStatus(otelSpan, span, endTime);

    otelSpan.end(endTime);
    this.endDanglingRootSpanOnTopLevelAgentError(span, endTime);
  }

  async shutdown(_timeout?: number): Promise<void> {
    // The OTel TracerProvider owns flushing/shutdown — nothing to do here.
  }

  async forceFlush(): Promise<void> {
    // The OTel TracerProvider owns flushing — nothing to do here.
  }

  enable(): void {
    this.enabled = true;
  }

  disable(): void {
    this.enabled = false;
    for (const span of this.otelSpans.values()) {
      span.setStatus({ code: SpanStatusCode.OK });
      span.end();
    }
    for (const span of this.rootSpans.values()) {
      span.setStatus({ code: SpanStatusCode.OK });
      span.end();
    }
    this.otelSpans.clear();
    this.rootSpans.clear();
    this.suppressedSpanIds.clear();
    this.suppressedTraceIds.clear();
    this.reverseHandoffs.clear();
    this.traceIO.clear();
    this.agentIO.clear();
  }

  // ─── Private helpers ───────────────────────────────────────────────────────

  /**
   * Accumulates an LLM span's input/output so it can be surfaced on the
   * enclosing AGENT spans: the trace's root span and, when the LLM span has a
   * parent (the per-agent span), that parent.
   */
  private recordLiftedIO(span: Span<SpanData>, liftedIO: LiftedIO): void {
    if (liftedIO.input === undefined && liftedIO.output === undefined) return;

    if (this.rootSpans.has(span.traceId)) {
      let traceEntry = this.traceIO.get(span.traceId);
      if (!traceEntry) {
        traceEntry = {};
        this.traceIO.set(span.traceId, traceEntry);
      }
      mergeLiftedIO(traceEntry, liftedIO);
    }

    if (span.parentId != null && this.otelSpans.has(span.parentId)) {
      let agentEntry = this.agentIO.get(span.parentId);
      if (!agentEntry) {
        agentEntry = {};
        this.agentIO.set(span.parentId, agentEntry);
        while (this.agentIO.size > MAX_AGENT_IO_IN_FLIGHT) {
          const oldest = this.agentIO.keys().next().value;
          if (oldest === undefined) break;
          this.agentIO.delete(oldest);
        }
      }
      mergeLiftedIO(agentEntry, liftedIO);
    }
  }

  private evictOldestOtelSpans(): void {
    while (this.otelSpans.size > MAX_SPANS_IN_FLIGHT) {
      const oldestSpanId = this.otelSpans.keys().next().value;
      if (oldestSpanId === undefined) break;

      const otelSpan = this.otelSpans.get(oldestSpanId);
      otelSpan?.setStatus({ code: SpanStatusCode.OK });
      otelSpan?.end();
      this.otelSpans.delete(oldestSpanId);
      this.agentIO.delete(oldestSpanId);
    }
  }

  private evictOldestRootSpans(): void {
    while (this.rootSpans.size > this.maxRootSpansInFlight) {
      const oldestTraceId = this.rootSpans.keys().next().value;
      if (oldestTraceId === undefined) break;

      const rootSpan = this.rootSpans.get(oldestTraceId);
      rootSpan?.setStatus({ code: SpanStatusCode.OK });
      rootSpan?.end();
      this.rootSpans.delete(oldestTraceId);
      this.traceIO.delete(oldestTraceId);
      this.suppressedTraceIds.delete(oldestTraceId);
    }
  }

  /**
   * Ends the root span when a top-level agent span ends with an error.
   *
   * The SDK does not emit `onTraceEnd` when a run aborts (e.g. a guardrail
   * tripwire throws), which would otherwise leave the root span dangling
   * until eviction. This assumes the error ends the trace: if the caller
   * catches the error and runs another top-level agent under the same trace,
   * that agent's spans will no longer be parented under this root span.
   */
  private endDanglingRootSpanOnTopLevelAgentError(
    span: Span<SpanData>,
    endTime: HrTime | undefined,
  ): void {
    if (!span.error || span.spanData.type !== "agent") return;

    const rootSpan = this.rootSpans.get(span.traceId);
    if (!rootSpan) return;

    const rootSpanId = rootSpan.spanContext().spanId;
    if (span.parentId != null && span.parentId !== rootSpanId) return;

    rootSpan.setAttributes(liftedIOAttributes(this.traceIO.get(span.traceId)));
    this.traceIO.delete(span.traceId);
    setSpanStatus(rootSpan, span, endTime);
    rootSpan.end(endTime);
    this.rootSpans.delete(span.traceId);
  }

  private shouldSuppressSpan(span: Span<SpanData>): boolean {
    return (
      isTracingSuppressed(context.active()) ||
      this.suppressedTraceIds.has(span.traceId) ||
      (span.parentId != null && this.suppressedSpanIds.has(span.parentId))
    );
  }

  /**
   * Records a handoff so that the destination agent's span can later be
   * linked to the source agent via `graph.node.parent_id`.
   */
  private recordHandoff(data: HandoffSpanData, traceId: string): void {
    if (!data.to_agent || !data.from_agent) return;
    const key = `${data.to_agent}:${traceId}`;
    // Re-insert to refresh LRU position
    this.reverseHandoffs.delete(key);
    this.reverseHandoffs.set(key, data.from_agent);
    while (this.reverseHandoffs.size > MAX_HANDOFFS_IN_FLIGHT) {
      const oldest = this.reverseHandoffs.keys().next().value;
      if (oldest === undefined) break;
      this.reverseHandoffs.delete(oldest);
    }
  }

  /**
   * Annotates an agent span with graph-node identifiers, including a parent
   * pointer if this agent was reached via a handoff.
   */
  private applyAgentGraphAttributes(
    otelSpan: OtelSpan,
    data: AgentSpanData,
    traceId: string,
  ): void {
    otelSpan.setAttribute(GRAPH_NODE_ID, data.name);
    const key = `${data.name}:${traceId}`;
    const parentNode = this.reverseHandoffs.get(key);
    if (parentNode) {
      otelSpan.setAttribute(GRAPH_NODE_PARENT_ID, parentNode);
      this.reverseHandoffs.delete(key);
    }
  }
}
