import AnthropicProvider from "@anthropic-ai/sdk";
import type { ArchestraInternalErrorCode } from "@archestra/shared";
import { encode as toonEncode } from "@toon-format/toon";
import { get } from "lodash-es";
import { anthropicWorkloadIdentity } from "@/clients/anthropic-workload-identity";
import {
  getAzureAiFoundryBearerTokenProvider,
  isAnthropicAzureFoundryEntraIdEnabled,
} from "@/clients/azure-openai-credentials";
import config from "@/config";
import logger from "@/logging";
import { ModelModel } from "@/models";
import { metrics } from "@/observability";
import { getTokenizer } from "@/tokenizers";
import type {
  Anthropic,
  ChunkProcessingResult,
  CommonMcpToolDefinition,
  CommonMessage,
  CommonToolCall,
  CommonToolResult,
  CreateClientOptions,
  LLMProvider,
  LLMRequestAdapter,
  LLMResponseAdapter,
  LLMStreamAdapter,
  StreamAccumulatorState,
  ToolCompressionStats,
  UsageView,
} from "@/types";
import { extractCommonMessageText } from "@/types";
import {
  hasImageContent,
  isImageTooLarge,
  isMcpImageBlock,
} from "../utils/mcp-image";
import { unwrapToolContent } from "../utils/unwrap-tool-content";

// =============================================================================
// TYPE ALIASES
// =============================================================================

type AnthropicRequest = Anthropic.Types.MessagesRequest;
type AnthropicResponse = Anthropic.Types.MessagesResponse;
type AnthropicMessages = Anthropic.Types.MessagesRequest["messages"];
type AnthropicHeaders = Anthropic.Types.MessagesHeaders;
type AnthropicStreamChunk = AnthropicProvider.Messages.MessageStreamEvent;

type AnthropicToolResultImageBlock = {
  type: "image";
  source: {
    type: "base64";
    media_type: string;
    data: string;
  };
};

type AnthropicToolResultTextBlock = {
  type: "text";
  text: string;
};

type AnthropicToolResultContentBlock =
  | AnthropicToolResultImageBlock
  | AnthropicToolResultTextBlock;

// =============================================================================
// REQUEST ADAPTER
// =============================================================================

class AnthropicRequestAdapter
  implements LLMRequestAdapter<AnthropicRequest, AnthropicMessages>
{
  readonly provider = "anthropic" as const;
  private request: AnthropicRequest;
  private modifiedModel: string | null = null;
  private toolResultUpdates: Record<string, string> = {};

  constructor(request: AnthropicRequest) {
    this.request = request;
  }

  // ---------------------------------------------------------------------------
  // Read Access
  // ---------------------------------------------------------------------------

  getModel(): string {
    return this.modifiedModel ?? this.request.model;
  }

  isStreaming(): boolean {
    return this.request.stream === true;
  }

  getMessages(): CommonMessage[] {
    return this.toCommonFormat(this.request.messages);
  }

  getToolResults(): CommonToolResult[] {
    const results: CommonToolResult[] = [];

    for (const message of this.request.messages) {
      if (message.role === "user" && Array.isArray(message.content)) {
        for (const contentBlock of message.content) {
          if (contentBlock.type === "tool_result") {
            // Find tool name from previous assistant messages
            const toolName = this.findToolName(contentBlock.tool_use_id);

            let content: unknown;
            if (typeof contentBlock.content === "string") {
              try {
                content = JSON.parse(contentBlock.content);
              } catch {
                content = contentBlock.content;
              }
            } else {
              content = contentBlock.content;
            }

            results.push({
              id: contentBlock.tool_use_id,
              name: toolName ?? "unknown",
              content,
              isError: contentBlock.is_error ?? false,
            });
          }
        }
      }
    }

    return results;
  }

  getTools(): CommonMcpToolDefinition[] {
    if (!this.request.tools) return [];

    const result: CommonMcpToolDefinition[] = [];
    for (const tool of this.request.tools) {
      // Only process custom tools (not bash, text_editor, etc.)
      if (
        tool.type === undefined ||
        tool.type === null ||
        tool.type === "custom"
      ) {
        // Type narrowing: at this point tool has input_schema
        const customTool = tool as {
          name: string;
          input_schema: Record<string, unknown>;
          description?: string;
        };
        result.push({
          name: customTool.name,
          description: customTool.description,
          inputSchema: customTool.input_schema,
        });
      }
    }
    return result;
  }

  hasTools(): boolean {
    return (this.request.tools?.length ?? 0) > 0;
  }

  getProviderMessages(): AnthropicMessages {
    return this.request.messages;
  }

  getOriginalRequest(): AnthropicRequest {
    return this.request;
  }

  // ---------------------------------------------------------------------------
  // Modify Access
  // ---------------------------------------------------------------------------

  setModel(model: string): void {
    this.modifiedModel = model;
  }

  updateToolResult(toolCallId: string, newContent: string): void {
    this.toolResultUpdates[toolCallId] = newContent;
  }

  applyToolResultUpdates(updates: Record<string, string>): void {
    Object.assign(this.toolResultUpdates, updates);
  }

  async applyToonCompression(model: string): Promise<ToolCompressionStats> {
    const { messages: compressedMessages, stats } =
      await convertToolResultsToToon(this.request.messages, model);
    // Update internal messages state
    this.request = {
      ...this.request,
      messages: compressedMessages,
    };
    return stats;
  }

  convertToolResultContent(messages: AnthropicMessages): AnthropicMessages {
    return messages.map((message) => {
      if (message.role !== "user" || !Array.isArray(message.content)) {
        return message;
      }

      let updated = false;
      const updatedContent = message.content.map((contentBlock) => {
        if (contentBlock.type !== "tool_result") {
          return contentBlock;
        }

        const convertedContent = convertMcpImageBlocksToAnthropic(
          contentBlock.content,
        );
        if (!convertedContent) {
          return contentBlock;
        }

        updated = true;
        return {
          ...contentBlock,
          content: convertedContent,
        };
      });

      if (!updated) {
        return message;
      }

      return {
        ...message,
        content: updatedContent,
      };
    });
  }

  // ---------------------------------------------------------------------------
  // Build Modified Request
  // ---------------------------------------------------------------------------

  toProviderRequest(): AnthropicRequest {
    let messages = this.request.messages;

    // Apply tool result updates if any
    if (Object.keys(this.toolResultUpdates).length > 0) {
      messages = this.applyUpdates(messages, this.toolResultUpdates);
    }

    messages = this.convertToolResultContent(messages);

    return {
      ...this.request,
      model: this.getModel(),
      messages,
    };
  }

  // ---------------------------------------------------------------------------
  // Private Helpers
  // ---------------------------------------------------------------------------

  private findToolName(toolUseId: string): string | null {
    for (let i = this.request.messages.length - 1; i >= 0; i--) {
      const message = this.request.messages[i];
      if (
        message.role === "assistant" &&
        Array.isArray(message.content) &&
        message.content.length > 0
      ) {
        for (const content of message.content) {
          if (content.type === "tool_use" && content.id === toolUseId) {
            return content.name;
          }
        }
      }
    }
    return null;
  }

  /**
   * Convert Anthropic messages to common format for policy evaluation
   */
  private toCommonFormat(messages: AnthropicMessages): CommonMessage[] {
    logger.debug(
      { messageCount: messages.length },
      "[AnthropicAdapter] toCommonFormat: starting conversion",
    );
    const commonMessages: CommonMessage[] = [];

    for (const message of messages) {
      const commonMessage: CommonMessage = {
        role: message.role as CommonMessage["role"],
        content: extractCommonMessageText(message),
      };

      // Handle user messages that may contain tool results
      if (message.role === "user" && Array.isArray(message.content)) {
        const toolCalls: CommonToolResult[] = [];

        for (const contentBlock of message.content) {
          if (contentBlock.type === "tool_result") {
            // Find the tool name from previous assistant messages
            const toolName = this.findToolNameInMessages(
              messages,
              contentBlock.tool_use_id,
            );

            if (toolName) {
              logger.debug(
                { toolUseId: contentBlock.tool_use_id, toolName },
                "[AnthropicAdapter] toCommonFormat: found tool result",
              );
              // Parse the tool result
              let toolResult: unknown;
              if (typeof contentBlock.content === "string") {
                try {
                  toolResult = JSON.parse(contentBlock.content);
                } catch {
                  toolResult = contentBlock.content;
                }
              } else {
                toolResult = contentBlock.content;
              }

              toolCalls.push({
                id: contentBlock.tool_use_id,
                name: toolName,
                content: toolResult,
                isError: false,
              });
            }
          }
        }

        if (toolCalls.length > 0) {
          commonMessage.toolCalls = toolCalls;
          logger.debug(
            { toolCallCount: toolCalls.length },
            "[AnthropicAdapter] toCommonFormat: attached tool calls to message",
          );
        }
      }

      commonMessages.push(commonMessage);
    }

    logger.debug(
      { inputCount: messages.length, outputCount: commonMessages.length },
      "[AnthropicAdapter] toCommonFormat: conversion complete",
    );
    return commonMessages;
  }

  /**
   * Extract tool name from messages by finding the assistant message
   * that contains the tool_use_id
   */
  private findToolNameInMessages(
    messages: AnthropicMessages,
    toolUseId: string,
  ): string | null {
    for (let i = messages.length - 1; i >= 0; i--) {
      const message = messages[i];
      if (
        message.role === "assistant" &&
        Array.isArray(message.content) &&
        message.content.length > 0
      ) {
        for (const content of message.content) {
          if (content.type === "tool_use" && content.id === toolUseId) {
            return content.name;
          }
        }
      }
    }
    return null;
  }

  /**
   * Apply tool result updates back to Anthropic messages
   */
  private applyUpdates(
    messages: AnthropicMessages,
    updates: Record<string, string>,
  ): AnthropicMessages {
    const updateCount = Object.keys(updates).length;
    logger.debug(
      { messageCount: messages.length, updateCount },
      "[AnthropicAdapter] applyUpdates: starting",
    );

    if (updateCount === 0) {
      logger.debug("[AnthropicAdapter] applyUpdates: no updates to apply");
      return messages;
    }

    let appliedCount = 0;
    const result = messages.map((message) => {
      // Only process user messages with content arrays
      if (message.role === "user" && Array.isArray(message.content)) {
        const updatedContent = message.content.map((contentBlock) => {
          if (
            contentBlock.type === "tool_result" &&
            updates[contentBlock.tool_use_id]
          ) {
            appliedCount++;
            logger.debug(
              { toolUseId: contentBlock.tool_use_id },
              "[AnthropicAdapter] applyUpdates: applying update to tool result",
            );
            return {
              ...contentBlock,
              content: updates[contentBlock.tool_use_id],
            };
          }
          return contentBlock;
        });

        return {
          ...message,
          content: updatedContent,
        };
      }

      return message;
    });

    logger.debug(
      { updateCount, appliedCount },
      "[AnthropicAdapter] applyUpdates: complete",
    );
    return result;
  }
}

function isAnthropicImageBlock(
  item: unknown,
): item is AnthropicToolResultImageBlock {
  if (typeof item !== "object" || item === null) return false;
  const candidate = item as Record<string, unknown>;
  if (candidate.type !== "image") return false;
  if (typeof candidate.source !== "object" || candidate.source === null) {
    return false;
  }

  const source = candidate.source as Record<string, unknown>;
  return (
    source.type === "base64" &&
    typeof source.media_type === "string" &&
    typeof source.data === "string"
  );
}

function isAnthropicTextBlock(
  item: unknown,
): item is AnthropicToolResultTextBlock {
  if (typeof item !== "object" || item === null) return false;
  const candidate = item as Record<string, unknown>;
  return candidate.type === "text" && typeof candidate.text === "string";
}

function convertMcpImageBlocksToAnthropic(
  content: unknown,
): AnthropicToolResultContentBlock[] | null {
  if (!Array.isArray(content)) {
    return null;
  }

  if (!hasImageContent(content)) {
    return null;
  }

  const convertedContent: AnthropicToolResultContentBlock[] = [];
  const imageTooLargePlaceholder = "[Image omitted due to size]";

  for (const item of content) {
    if (typeof item !== "object" || item === null) continue;
    const candidate = item as Record<string, unknown>;

    if (isMcpImageBlock(item)) {
      if (isImageTooLarge(item)) {
        convertedContent.push({
          type: "text",
          text: imageTooLargePlaceholder,
        });
        continue;
      }
      const mimeType = item.mimeType ?? "image/png";
      convertedContent.push({
        type: "image",
        source: {
          type: "base64",
          media_type: mimeType,
          data: item.data,
        },
      });
    } else if (isAnthropicImageBlock(item)) {
      convertedContent.push(item);
    } else if (isAnthropicTextBlock(item)) {
      convertedContent.push(item);
    } else if (candidate.type === "text" && "text" in candidate) {
      convertedContent.push({
        type: "text",
        text:
          typeof candidate.text === "string"
            ? candidate.text
            : JSON.stringify(candidate),
      });
    }
  }

  return convertedContent.length > 0 ? convertedContent : null;
}

// =============================================================================
// RESPONSE ADAPTER
// =============================================================================

class AnthropicResponseAdapter
  implements LLMResponseAdapter<AnthropicResponse>
{
  readonly provider = "anthropic" as const;
  private response: AnthropicResponse;

  constructor(response: AnthropicResponse) {
    this.response = response;
  }

  getId(): string {
    return this.response.id;
  }

  getModel(): string {
    return this.response.model;
  }

  getText(): string {
    const textBlocks = this.response.content.filter(
      (block) => block.type === "text",
    );
    return textBlocks.map((block) => block.text).join("");
  }

  getToolCalls(): CommonToolCall[] {
    return this.response.content
      .filter((block) => block.type === "tool_use")
      .map((block) => ({
        id: block.id,
        name: block.name,
        arguments: block.input as Record<string, unknown>,
      }));
  }

  hasToolCalls(): boolean {
    return this.response.content.some((block) => block.type === "tool_use");
  }

  getUsage(): UsageView {
    const { input, output, cacheRead, cacheWrite, cacheWrite1h } =
      getUsageTokens(this.response.usage);
    return {
      inputTokens: input,
      outputTokens: output,
      cacheReadTokens: cacheRead,
      cacheWriteTokens: cacheWrite,
      cacheWrite1hTokens: cacheWrite1h,
    };
  }

  getOriginalResponse(): AnthropicResponse {
    return this.response;
  }

  getFinishReasons(): string[] {
    const reason = this.response.stop_reason;
    return reason ? [reason] : [];
  }

  toRefusalResponse(
    _refusalMessage: string,
    contentMessage: string,
  ): AnthropicResponse {
    return {
      ...this.response,
      content: [
        {
          type: "text",
          text: contentMessage,
          citations: null,
        },
      ],
      stop_reason: "end_turn",
    };
  }
}

// =============================================================================
// STREAM ADAPTER
// =============================================================================

class AnthropicStreamAdapter
  implements LLMStreamAdapter<AnthropicStreamChunk, AnthropicResponse>
{
  readonly provider = "anthropic" as const;
  readonly state: StreamAccumulatorState;
  private toolUseBlockIndices = new Set<number>();
  private currentToolCallIndex = -1;
  // Highest content-block index actually forwarded to the client, so a refusal
  // block appended after a guardrail hit does not collide with (reuse) an index
  // the client has already seen.
  private maxStreamedBlockIndex = -1;
  // Set to the refusal text when the streamed response was replaced by a policy
  // refusal. formatEndSSE then closes the turn as end_turn instead of replaying
  // the upstream tool_use stop reason (which would leave a text-only turn ending
  // in a tool-use stop reason and no tool_use blocks — an inconsistent state
  // that makes agent harnesses treat the turn as a malformed tool call and
  // retry), and toProviderResponse persists the refusal rather than the blocked
  // tool calls so the interaction log matches what the client received.
  private replacedText: string | null = null;
  private get responseReplacedWithText(): boolean {
    return this.replacedText !== null;
  }

  constructor() {
    this.state = {
      responseId: "",
      model: "",
      text: "",
      toolCalls: [],
      rawToolCallEvents: [],
      usage: null,
      stopReason: null,
      timing: {
        startTime: Date.now(),
        firstChunkTime: null,
      },
    };
  }

  processChunk(chunk: AnthropicStreamChunk): ChunkProcessingResult {
    // Track first chunk time
    if (this.state.timing.firstChunkTime === null) {
      this.state.timing.firstChunkTime = Date.now();
    }

    let sseData: string | null = null;
    let isToolCallChunk = false;
    let isFinal = false;

    switch (chunk.type) {
      case "message_start":
        this.state.responseId = chunk.message.id;
        this.state.model = chunk.message.model;
        if (chunk.message.usage) {
          this.state.usage = {
            inputTokens: chunk.message.usage.input_tokens,
            outputTokens: chunk.message.usage.output_tokens,
            cacheReadTokens: chunk.message.usage.cache_read_input_tokens ?? 0,
            cacheWriteTokens:
              chunk.message.usage.cache_creation_input_tokens ?? 0,
            cacheWrite1hTokens:
              chunk.message.usage.cache_creation?.ephemeral_1h_input_tokens ??
              0,
          };
        }
        sseData = `event: message_start\ndata: ${JSON.stringify(chunk)}\n\n`;
        break;

      case "content_block_start":
        if (chunk.content_block.type === "tool_use") {
          this.toolUseBlockIndices.add(chunk.index);
          this.currentToolCallIndex = this.state.toolCalls.length;
          this.state.toolCalls.push({
            id: chunk.content_block.id,
            name: chunk.content_block.name,
            arguments: "",
          });
          // Store raw event for replay after policy approval
          this.state.rawToolCallEvents.push(chunk);
          isToolCallChunk = true;
        } else {
          // Everything except client tool calls (text, thinking,
          // redacted_thinking, server_tool_use, ...) streams through
          // unmodified. Thinking blocks in particular must reach the client:
          // it has to replay them (with signature) on the next turn or the
          // upstream API rejects the conversation.
          this.maxStreamedBlockIndex = Math.max(
            this.maxStreamedBlockIndex,
            chunk.index,
          );
          sseData = `event: content_block_start\ndata: ${JSON.stringify(chunk)}\n\n`;
        }
        break;

      case "content_block_delta":
        if (
          chunk.delta.type === "input_json_delta" &&
          this.toolUseBlockIndices.has(chunk.index)
        ) {
          if (this.currentToolCallIndex >= 0) {
            this.state.toolCalls[this.currentToolCallIndex].arguments +=
              chunk.delta.partial_json;
          }
          // Store raw event for replay after policy approval
          this.state.rawToolCallEvents.push(chunk);
          isToolCallChunk = true;
        } else {
          // input_json_delta outside a tool_use block belongs to a
          // server-side tool and is not subject to invocation policies.
          if (chunk.delta.type === "text_delta") {
            this.state.text += chunk.delta.text;
          }
          sseData = `event: content_block_delta\ndata: ${JSON.stringify(chunk)}\n\n`;
        }
        break;

      case "content_block_stop":
        if (!this.toolUseBlockIndices.has(chunk.index)) {
          sseData = `event: content_block_stop\ndata: ${JSON.stringify(chunk)}\n\n`;
        } else {
          // Store raw event for replay after policy approval
          this.state.rawToolCallEvents.push(chunk);
          isToolCallChunk = true;
        }
        break;

      case "message_delta":
        if (chunk.delta.stop_reason) {
          this.state.stopReason = chunk.delta.stop_reason;
        }
        if (chunk.usage?.output_tokens !== undefined) {
          if (this.state.usage) {
            this.state.usage.outputTokens = chunk.usage.output_tokens;
          }
        }
        // Don't send message_delta yet - we'll send it after policy evaluation
        break;

      case "message_stop":
        isFinal = true;
        // Don't send message_stop yet - we'll send it after policy evaluation
        break;
    }

    return { sseData, isToolCallChunk, isFinal };
  }

  getSSEHeaders(): Record<string, string> {
    return {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
      "anthropic-ratelimit-requests-limit": "1000",
      "anthropic-ratelimit-requests-remaining": "999",
      "anthropic-ratelimit-requests-reset": new Date(
        Date.now() + 60000,
      ).toISOString(),
      "anthropic-ratelimit-tokens-limit": "100000",
      "anthropic-ratelimit-tokens-remaining": "99000",
      "anthropic-ratelimit-tokens-reset": new Date(
        Date.now() + 60000,
      ).toISOString(),
      "request-id": `req-proxy-${Date.now()}`,
    };
  }

  formatTextDeltaSSE(text: string): string {
    const event = {
      type: "content_block_delta",
      index: 0,
      delta: {
        type: "text_delta",
        text,
      },
    };
    return `event: content_block_delta\ndata: ${JSON.stringify(event)}\n\n`;
  }

  getRawToolCallEvents(): string[] {
    return this.state.rawToolCallEvents.map(
      (event) =>
        `event: ${(event as { type: string }).type}\ndata: ${JSON.stringify(event)}\n\n`,
    );
  }

  formatCompleteTextSSE(text: string): string[] {
    this.replacedText = text;
    const index = this.maxStreamedBlockIndex + 1;
    return [
      `event: content_block_start\ndata: ${JSON.stringify({
        type: "content_block_start",
        index,
        content_block: { type: "text", text: "" },
      })}\n\n`,
      `event: content_block_delta\ndata: ${JSON.stringify({
        type: "content_block_delta",
        index,
        delta: { type: "text_delta", text },
      })}\n\n`,
      `event: content_block_stop\ndata: ${JSON.stringify({
        type: "content_block_stop",
        index,
      })}\n\n`,
    ];
  }

  formatEndSSE(): string {
    const events: string[] = [];

    // message_delta with stop_reason
    events.push(
      `event: message_delta\ndata: ${JSON.stringify({
        type: "message_delta",
        delta: {
          stop_reason: this.responseReplacedWithText
            ? "end_turn"
            : (this.state.stopReason ?? "end_turn"),
          stop_sequence: null,
        },
        usage: {
          output_tokens: this.state.usage?.outputTokens ?? 0,
        },
      })}\n\n`,
    );

    // message_stop
    events.push(
      `event: message_stop\ndata: ${JSON.stringify({
        type: "message_stop",
      })}\n\n`,
    );

    return events.join("");
  }

  toProviderResponse(): AnthropicResponse {
    if (this.replacedText !== null) {
      return {
        id: this.state.responseId,
        type: "message",
        role: "assistant",
        content: [{ type: "text", text: this.replacedText, citations: null }],
        model: this.state.model,
        stop_reason: "end_turn",
        stop_sequence: null,
        usage: {
          input_tokens: this.state.usage?.inputTokens ?? 0,
          output_tokens: this.state.usage?.outputTokens ?? 0,
        },
      };
    }

    const content: AnthropicResponse["content"] = [];

    // Add text block if we have text
    if (this.state.text) {
      content.push({
        type: "text",
        text: this.state.text,
        citations: null,
      });
    }

    // Add tool use blocks
    for (const toolCall of this.state.toolCalls) {
      let parsedInput: Record<string, unknown> = {};
      try {
        parsedInput = JSON.parse(toolCall.arguments);
      } catch {
        // Keep empty object if parse fails
      }

      content.push({
        type: "tool_use",
        id: toolCall.id,
        name: toolCall.name,
        input: parsedInput,
      });
    }

    return {
      id: this.state.responseId,
      type: "message",
      role: "assistant",
      content,
      model: this.state.model,
      stop_reason:
        (this.state.stopReason as AnthropicResponse["stop_reason"]) ??
        "end_turn",
      stop_sequence: null,
      usage: {
        input_tokens: this.state.usage?.inputTokens ?? 0,
        output_tokens: this.state.usage?.outputTokens ?? 0,
      },
    };
  }
}

// =============================================================================
// TOON COMPRESSION
// =============================================================================

/**
 * Convert tool results in messages to TOON format
 * Returns both the converted messages and compression stats (tokens and cost savings)
 */
export async function convertToolResultsToToon(
  messages: AnthropicMessages,
  model: string,
): Promise<{
  messages: AnthropicMessages;
  stats: ToolCompressionStats;
}> {
  const tokenizer = getTokenizer("anthropic");
  let toolResultCount = 0;
  let totalTokensBefore = 0;
  let totalTokensAfter = 0;

  const result = messages.map((message) => {
    // Only process user messages with content arrays that contain tool_result blocks
    if (message.role === "user" && Array.isArray(message.content)) {
      const updatedContent = message.content.map((contentBlock) => {
        if (contentBlock.type === "tool_result" && !contentBlock.is_error) {
          toolResultCount++;
          logger.info(
            {
              toolCallId: contentBlock.tool_use_id,
              contentType: typeof contentBlock.content,
              isArray: Array.isArray(contentBlock.content),
            },
            "Processing tool_result for TOON conversion",
          );

          // Handle string content
          if (typeof contentBlock.content === "string") {
            try {
              // Unwrap any extra text block wrapping from clients
              const unwrapped = unwrapToolContent(contentBlock.content);
              const parsed = JSON.parse(unwrapped);
              const noncompressed = unwrapped;
              const compressed = toonEncode(parsed);

              // Count tokens for before and after
              const tokensBefore = tokenizer.countTokens([
                { role: "user", content: noncompressed },
              ]);
              const tokensAfter = tokenizer.countTokens([
                { role: "user", content: compressed },
              ]);

              // Always count tokens
              totalTokensBefore += tokensBefore;

              // Only apply compression if it actually saves tokens
              if (tokensAfter < tokensBefore) {
                totalTokensAfter += tokensAfter;

                logger.info(
                  {
                    toolCallId: contentBlock.tool_use_id,
                    beforeLength: noncompressed.length,
                    afterLength: compressed.length,
                    tokensBefore,
                    tokensAfter,
                    toonPreview: compressed.substring(0, 150),
                    provider: "anthropic",
                  },
                  "convertToolResultsToToon: compressed (string content)",
                );
                logger.trace(
                  {
                    toolCallId: contentBlock.tool_use_id,
                    before: noncompressed,
                    after: compressed,
                    provider: "anthropic",
                    supposedToBeJson: parsed,
                  },
                  "convertToolResultsToToon: before/after",
                );

                return {
                  ...contentBlock,
                  content: compressed,
                };
              }

              // Compression not applied - count non-compressed tokens to track total tokens anyway
              totalTokensAfter += tokensBefore;
              logger.info(
                {
                  toolCallId: contentBlock.tool_use_id,
                  tokensBefore,
                  tokensAfter,
                  provider: "anthropic",
                },
                "Skipping TOON compression - compressed output has more tokens",
              );
              return contentBlock;
            } catch {
              logger.info(
                {
                  toolCallId: contentBlock.tool_use_id,
                  contentPreview:
                    typeof contentBlock.content === "string"
                      ? contentBlock.content.substring(0, 100)
                      : "non-string",
                },
                "convertToolResultsToToon: skipping - string content is not JSON",
              );
              return contentBlock;
            }
          }

          // Handle array content (content blocks format)
          if (Array.isArray(contentBlock.content)) {
            const updatedBlocks = contentBlock.content.map((block) => {
              if (block.type === "text" && typeof block.text === "string") {
                try {
                  // Unwrap any extra text block wrapping from clients
                  const unwrapped = unwrapToolContent(block.text);
                  // Try to parse as JSON
                  const parsed = JSON.parse(unwrapped);
                  const noncompressed = unwrapped;
                  const compressed = toonEncode(parsed);

                  // Count tokens for before and after
                  const tokensBefore = tokenizer.countTokens([
                    { role: "user", content: noncompressed },
                  ]);
                  const tokensAfter = tokenizer.countTokens([
                    { role: "user", content: compressed },
                  ]);

                  // Always count tokens
                  totalTokensBefore += tokensBefore;

                  // Only apply compression if it actually saves tokens
                  if (tokensAfter < tokensBefore) {
                    totalTokensAfter += tokensAfter;

                    logger.info(
                      {
                        toolCallId: contentBlock.tool_use_id,
                        beforeLength: noncompressed.length,
                        afterLength: compressed.length,
                        tokensBefore,
                        tokensAfter,
                        toonPreview: compressed.substring(0, 150),
                      },
                      "convertToolResultsToToon: compressed (array content)",
                    );
                    logger.trace(
                      {
                        toolCallId: contentBlock.tool_use_id,
                        before: noncompressed,
                        after: compressed,
                        provider: "anthropic",
                        supposedToBeJson: parsed,
                      },
                      "convertToolResultsToToon: before/after",
                    );

                    return {
                      ...block,
                      text: compressed,
                    };
                  }

                  // Compression not applied - count non-compressed tokens to track total tokens anyway
                  totalTokensAfter += tokensBefore;
                  logger.info(
                    {
                      toolCallId: contentBlock.tool_use_id,
                      tokensBefore,
                      tokensAfter,
                      provider: "anthropic",
                    },
                    "Skipping TOON compression - compressed output has more tokens",
                  );
                  return block;
                } catch {
                  // Not JSON, keep as-is
                  logger.info(
                    {
                      toolCallId: contentBlock.tool_use_id,
                      blockType: block.type,
                      textPreview: block.text?.substring(0, 100),
                    },
                    "convertToolResultsToToon: skipping - content is not JSON",
                  );
                  return block;
                }
              }
              return block;
            });

            return {
              ...contentBlock,
              content: updatedBlocks,
            };
          }
        }
        return contentBlock;
      });

      return {
        ...message,
        content: updatedContent,
      };
    }

    return message;
  });

  logger.info(
    { messageCount: messages.length, toolResultCount },
    "convertToolResultsToToon completed",
  );

  // Calculate cost savings (always a number, 0 if no savings)
  let toonCostSavings = 0;
  const tokensSaved = totalTokensBefore - totalTokensAfter;
  if (tokensSaved > 0) {
    toonCostSavings = await ModelModel.calculateCostSavings(
      model,
      tokensSaved,
      "anthropic",
    );
  }

  return {
    messages: result,
    stats: {
      tokensBefore: totalTokensBefore,
      tokensAfter: totalTokensAfter,
      costSavings: toonCostSavings,
      wasEffective: totalTokensAfter < totalTokensBefore,
      hadToolResults: toolResultCount > 0,
    },
  };
}

// =============================================================================
// ADAPTER FACTORY
// =============================================================================

// =============================================================================
// USAGE TOKEN HELPERS
// =============================================================================

export function getUsageTokens(usage: Anthropic.Types.Usage) {
  return {
    input: usage.input_tokens,
    output: usage.output_tokens,
    cacheRead: usage.cache_read_input_tokens ?? 0,
    cacheWrite: usage.cache_creation_input_tokens ?? 0,
    cacheWrite1h: usage.cache_creation?.ephemeral_1h_input_tokens ?? 0,
  };
}

// Same value as the SDK default, but passing it explicitly opts out of the
// SDK's client-side guard that throws "Streaming is required for operations
// that may take longer than 10 minutes" on non-streaming requests with large
// max_tokens (Claude Code sends max_tokens=32000 non-streaming). The proxy
// must forward such requests — the upstream API is the authority on whether
// they need streaming.
const ANTHROPIC_CLIENT_TIMEOUT_MS = 10 * 60 * 1000;

export const anthropicAdapterFactory: LLMProvider<
  AnthropicRequest,
  AnthropicResponse,
  AnthropicMessages,
  AnthropicStreamChunk,
  AnthropicHeaders
> = {
  provider: "anthropic",
  interactionType: "anthropic:messages",

  createRequestAdapter(
    request: AnthropicRequest,
  ): LLMRequestAdapter<AnthropicRequest, AnthropicMessages> {
    return new AnthropicRequestAdapter(request);
  },

  createResponseAdapter(
    response: AnthropicResponse,
  ): LLMResponseAdapter<AnthropicResponse> {
    return new AnthropicResponseAdapter(response);
  },

  createStreamAdapter(): LLMStreamAdapter<
    AnthropicStreamChunk,
    AnthropicResponse
  > {
    return new AnthropicStreamAdapter();
  },

  extractApiKey(headers: AnthropicHeaders): string | undefined {
    // Check for x-api-key (traditional API key)
    if (headers["x-api-key"]) {
      return headers["x-api-key"];
    }
    // Check for Authorization Bearer token (OAuth) - used by Claude Code
    const authHeader = headers.authorization;
    if (authHeader?.startsWith("Bearer ")) {
      // Return with "Bearer:" prefix to signal it's an authToken
      return `Bearer:${authHeader.slice(7)}`;
    }
    return undefined;
  },

  getBaseUrl(): string | undefined {
    return config.llm.anthropic.baseUrl;
  },

  spanName: "chat",

  createClient(
    apiKey: string | undefined,
    options: CreateClientOptions,
  ): AnthropicProvider {
    // Use observable fetch for request duration metrics if agent is provided
    const customFetch = options.agent
      ? metrics.llm.getObservableFetch(
          "anthropic",
          options.agent,
          options.source,
        )
      : undefined;

    // Check if this is a Bearer token (OAuth) or regular API key
    const isAuthToken = apiKey?.startsWith("Bearer:") ?? false;
    const token = isAuthToken && apiKey ? apiKey.slice(7) : undefined;
    const regularApiKey = isAuthToken ? undefined : apiKey;

    if (!apiKey && isAnthropicAzureFoundryEntraIdEnabled()) {
      return new AnthropicProvider({
        apiKey: null,
        authToken: null,
        baseURL: options.baseUrl,
        fetch: createAnthropicAzureFoundryFetch(customFetch),
        timeout: ANTHROPIC_CLIENT_TIMEOUT_MS,
        defaultHeaders: {
          ...options.defaultHeaders,
          // The fetch wrapper replaces this sentinel with a fresh Entra ID token on every request.
          Authorization: "Bearer <entra-id-managed>",
        },
      });
    }

    if (!apiKey && anthropicWorkloadIdentity.isEnabled()) {
      return new AnthropicProvider({
        apiKey: null,
        authToken: null,
        baseURL: options.baseUrl,
        fetch: anthropicWorkloadIdentity.createFetch(customFetch),
        timeout: ANTHROPIC_CLIENT_TIMEOUT_MS,
        defaultHeaders: {
          ...options.defaultHeaders,
          // The fetch wrapper replaces this sentinel with a fresh WIF access token on every request.
          Authorization: "Bearer <workload-identity-managed>",
        },
      });
    }

    return new AnthropicProvider({
      apiKey: regularApiKey,
      authToken: token,
      baseURL: options.baseUrl,
      fetch: customFetch,
      timeout: ANTHROPIC_CLIENT_TIMEOUT_MS,
      defaultHeaders: options.defaultHeaders,
    });
  },

  async execute(
    client: unknown,
    request: AnthropicRequest,
  ): Promise<AnthropicResponse> {
    const anthropicClient = client as AnthropicProvider;
    return anthropicClient.messages.create({
      ...request,
      stream: false,
    } as AnthropicProvider.Messages.MessageCreateParamsNonStreaming);
  },

  async executeStream(
    client: unknown,
    request: AnthropicRequest,
  ): Promise<AsyncIterable<AnthropicStreamChunk>> {
    const anthropicClient = client as AnthropicProvider;
    // use the raw create() stream rather than the messages.stream() helper: the
    // helper eagerly partial-parses accumulated input_json_delta fragments and
    // throws (unguarded) when a non-conformant upstream emits deltas that
    // concatenate into more than one JSON value. we do our own guarded tool-call
    // accumulation in processChunk, so the raw event stream is all we need.
    return anthropicClient.messages.create({
      ...request,
      stream: true,
    } as AnthropicProvider.Messages.MessageCreateParamsStreaming);
  },

  extractInternalCode(_error: unknown): ArchestraInternalErrorCode | undefined {
    // Anthropic and its compatible gateways signal context overflow only via the
    // message with no structured code, so there is no structured signal to
    // classify overflow against.
    return undefined;
  },

  extractErrorMessage(error: unknown): string {
    // Anthropic SDK wraps errors as: { error: { error: { message: "..." } } }
    const anthropicMessage = get(error, "error.error.message");
    if (typeof anthropicMessage === "string") {
      return anthropicMessage;
    }

    if (error instanceof Error) {
      return error.message;
    }

    return "Internal server error";
  },
};

function createAnthropicAzureFoundryFetch(
  baseFetch: typeof globalThis.fetch | undefined,
): typeof globalThis.fetch {
  return async (input, init) => {
    const tokenProvider = getAzureAiFoundryBearerTokenProvider();
    const headers = new Headers(init?.headers);
    headers.set("Authorization", `Bearer ${await tokenProvider()}`);

    const fetchFn = baseFetch ?? globalThis.fetch;
    return fetchFn(input, {
      ...init,
      headers,
    });
  };
}
