/**
 * Anthropic Claude LLM provider for Patter's pipeline mode.
 *
 * Implements the ``LLMProvider`` interface from ``../llm-loop`` on top
 * of Anthropic's Messages API with streaming via Server-Sent Events.
 * OpenAI-style ``messages`` / ``tools`` inputs are translated into the
 * Anthropic shape and the vendor event stream is normalised back into
 * Patter's ``{ type: 'text' | 'tool_call' | 'done' }`` chunk protocol.
 *
 * Implementation notes:
 *   * Single TypeScript class satisfying Patter's ``LLMProvider`` interface.
 *   * Uses native ``fetch`` + SSE parsing instead of the official
 *     ``@anthropic-ai/sdk`` to keep Patter's runtime dependencies lean
 *     (mirrors how ``OpenAILLMProvider`` is implemented in
 *     ``llm-loop.ts``).
 *   * Maps Anthropic event types (``content_block_start``,
 *     ``content_block_delta``, ``content_block_stop``) to the Patter
 *     chunk protocol.
 */

import type { LLMChunk, LLMProvider, LLMStreamOptions } from "../llm-loop";
import {
  mergeAbortSignals,
  createStreamIdleWatchdog,
  LLM_STREAM_IDLE_TIMEOUT_MS,
} from "../llm-loop";
import { getLogger } from '../logger';
import { PatterConnectionError } from '../errors';

const DEFAULT_ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';

/** Canonical Anthropic Claude model identifiers and aliases. */
export const AnthropicModel = {
  CLAUDE_HAIKU_4_5_ALIAS: 'claude-haiku-4-5',
  CLAUDE_SONNET_4_6_ALIAS: 'claude-sonnet-4-6',
  CLAUDE_OPUS_4_7_ALIAS: 'claude-opus-4-7',
  CLAUDE_3_5_SONNET_ALIAS: 'claude-3-5-sonnet-latest',
  CLAUDE_3_5_HAIKU_ALIAS: 'claude-3-5-haiku-latest',
  CLAUDE_HAIKU_4_5_20251001: 'claude-haiku-4-5-20251001',
  CLAUDE_3_5_SONNET_20241022: 'claude-3-5-sonnet-20241022',
  CLAUDE_3_5_HAIKU_20241022: 'claude-3-5-haiku-20241022',
} as const;
/** Union of {@link AnthropicModel} string values. */
export type AnthropicModel = (typeof AnthropicModel)[keyof typeof AnthropicModel];

const DEFAULT_MODEL: string = AnthropicModel.CLAUDE_HAIKU_4_5_20251001;
const DEFAULT_MAX_TOKENS = 1024;

/**
 * Anthropic prompt-caching beta header. Caching is now generally available
 * but the explicit beta opt-in remains supported and ensures consistent
 * behaviour across model snapshots that haven't yet promoted the feature.
 *
 * See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
 */
const PROMPT_CACHING_BETA = 'prompt-caching-2024-07-31';

/** Constructor options for {@link AnthropicLLMProvider}. */
export interface AnthropicLLMOptions {
  apiKey: string;
  model?: string;
  maxTokens?: number;
  temperature?: number;
  baseUrl?: string;
  anthropicVersion?: string;
  /**
   * Enable Anthropic prompt caching for the system prompt and tools.
   * Defaults to ``true`` — for voice agents with long instruction-dense
   * system prompts, the cache saves ~100-400 ms TTFT and ~90% of input-
   * token cost on every cached turn. The cache lives ~5 minutes; the
   * first request writes it, subsequent requests within that window
   * hit it.
   *
   * Disable when the system prompt + tools combined are smaller than
   * Anthropic's minimum cacheable size (~1024 tokens for Sonnet/Opus,
   * ~2048 for Haiku) — caching has no effect below that threshold.
   */
  promptCaching?: boolean;
}

interface OpenAIToolDef {
  type?: string;
  function?: {
    name: string;
    description?: string;
    parameters?: Record<string, unknown>;
  };
  name?: string;
  description?: string;
  parameters?: Record<string, unknown>;
}

interface AnthropicTool {
  name: string;
  description: string;
  input_schema: Record<string, unknown>;
}

interface AnthropicMessage {
  role: 'user' | 'assistant';
  content: string | Array<Record<string, unknown>>;
}

interface AnthropicSystemBlock {
  type: 'text';
  text: string;
  cache_control?: { type: 'ephemeral' };
}

/** LLM provider backed by Anthropic's Messages API (streaming). */
export class AnthropicLLMProvider implements LLMProvider {
  /** Stable pricing/dashboard key — read by stream-handler/metrics. */
  static readonly providerKey = 'anthropic';
  private readonly apiKey: string;
  private readonly model: string;
  private readonly maxTokens: number;
  private readonly temperature?: number;
  private readonly url: string;
  private readonly anthropicVersion: string;
  private readonly promptCaching: boolean;

  constructor(options: AnthropicLLMOptions) {
    if (!options.apiKey) {
      throw new Error(
        'Anthropic API key is required. Pass it via { apiKey } or set the ' +
          'ANTHROPIC_API_KEY environment variable before constructing the provider.',
      );
    }
    this.apiKey = options.apiKey;
    this.model = options.model ?? DEFAULT_MODEL;
    this.maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
    this.temperature = options.temperature;
    this.url = options.baseUrl ?? DEFAULT_ANTHROPIC_URL;
    this.anthropicVersion = options.anthropicVersion ?? DEFAULT_ANTHROPIC_VERSION;
    this.promptCaching = options.promptCaching ?? true;
  }

  /**
   * Pre-call DNS / TLS warmup for the Anthropic Messages API.
   * Issues a lightweight ``GET https://api.anthropic.com/v1/models`` so
   * DNS, TLS and HTTP/2 are already up by the time the first ``messages``
   * call lands. Best-effort: 5 s timeout, exceptions swallowed at debug.
   */
  async warmup(): Promise<void> {
    try {
      // ``url`` points at .../messages — derive the .../models sibling.
      const modelsUrl = this.url.replace(/\/messages\/?$/, '/models');
      await fetch(modelsUrl, {
        method: 'GET',
        headers: {
          'x-api-key': this.apiKey,
          'anthropic-version': this.anthropicVersion,
        },
        signal: AbortSignal.timeout(5_000),
      });
    } catch (err) {
      getLogger().debug(`Anthropic LLM warmup failed (best-effort): ${String(err)}`);
    }
  }

  /** Stream Patter-format LLM chunks for the given OpenAI-style chat history. */
  async *stream(
    messages: Array<Record<string, unknown>>,
    tools?: Array<Record<string, unknown>> | null,
    opts?: LLMStreamOptions,
  ): AsyncGenerator<LLMChunk, void, unknown> {
    const { system, messages: anthropicMessages } = toAnthropicMessages(messages);
    const anthropicTools = tools ? toAnthropicTools(tools as OpenAIToolDef[]) : null;

    const body: Record<string, unknown> = {
      model: this.model,
      messages: anthropicMessages,
      max_tokens: this.maxTokens,
      stream: true,
    };
    if (system) {
      if (this.promptCaching) {
        // Convert the system string into a single text block tagged with
        // ``cache_control: ephemeral``. Anthropic caches every block up
        // to and including the marked one, so a single marker on the
        // only block is sufficient.
        const block: AnthropicSystemBlock = {
          type: 'text',
          text: system,
          cache_control: { type: 'ephemeral' },
        };
        body.system = [block];
      } else {
        body.system = system;
      }
    }
    if (anthropicTools && anthropicTools.length > 0) {
      if (this.promptCaching) {
        // Per Anthropic's recommended pattern, tagging only the LAST
        // tool block with ``cache_control`` caches the entire tool list
        // (everything before the marker is cached implicitly).
        const cachedTools: Array<Record<string, unknown>> = anthropicTools.map(
          (t) => ({ ...t }),
        );
        cachedTools[cachedTools.length - 1] = {
          ...cachedTools[cachedTools.length - 1],
          cache_control: { type: 'ephemeral' },
        };
        body.tools = cachedTools;
      } else {
        body.tools = anthropicTools;
      }
    }
    if (this.temperature !== undefined) body.temperature = this.temperature;

    const headers: Record<string, string> = {
      'Content-Type': 'application/json',
      'x-api-key': this.apiKey,
      'anthropic-version': this.anthropicVersion,
    };
    if (this.promptCaching) {
      headers['anthropic-beta'] = PROMPT_CACHING_BETA;
    }

    // Idle watchdog (re-armed per chunk) instead of a whole-stream ceiling:
    // a 30 s total cap chopped long turns mid-utterance and the resulting
    // AbortError was misclassified as a clean barge-in upstream.
    const idle = createStreamIdleWatchdog();
    const response = await fetch(this.url, {
      method: 'POST',
      headers,
      body: JSON.stringify(body),
      signal: mergeAbortSignals(opts?.signal, idle.signal),
    });

    if (!response.ok) {
      const errText = await response.text();
      // Cap the logged/thrown body — provider 401 bodies have been observed to
      // embed the rejected API-key prefix, which would otherwise land in logs.
      getLogger().error(`Anthropic API error: ${response.status} ${errText.slice(0, 200)}`);
      // Throw (don't return silently) so the LLM fallback chain fails over and
      // the spoken error fallback can fire — a silent return looks like success.
      throw new PatterConnectionError(
        `Anthropic API returned ${response.status}: ${errText.slice(0, 200)}`,
      );
    }

    const reader = response.body?.getReader();
    if (!reader) return;

    const decoder = new TextDecoder();
    let buffer = '';

    const toolIndexByBlock = new Map<number, number>();
    const toolIdByBlock = new Map<number, string>();
    let nextIndex = 0;

    // Track token usage from message_start / message_delta events.
    let inputTokens = 0;
    let outputTokens = 0;
    let cacheReadTokens = 0;
    let cacheWriteTokens = 0;

    try {
      while (true) {
        const { done, value } = await reader.read();
        idle.touch();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n');
        buffer = lines.pop() || '';

        for (const line of lines) {
          const trimmed = line.trim();
          if (!trimmed.startsWith('data: ')) continue;
          const data = trimmed.slice(6);
          if (!data || data === '[DONE]') continue;

          let event: {
            type?: string;
            index?: number;
            content_block?: { type?: string; id?: string; name?: string };
            delta?: { type?: string; text?: string; partial_json?: string };
            message?: {
              usage?: {
                input_tokens?: number;
                cache_creation_input_tokens?: number;
                cache_read_input_tokens?: number;
              };
            };
            usage?: { output_tokens?: number };
          };
          try {
            event = JSON.parse(data);
          } catch {
            continue;
          }

          // Anthropic streams can deliver an in-band error event (e.g.
          // overloaded_error) before closing. Without this branch the stream
          // ends as if it completed — truncated reply, no failover, no
          // spoken llmErrorMessage. (The Python provider is safe: the
          // official SDK raises.)
          if (event.type === 'error') {
            const errPayload = (event as unknown as { error?: { type?: string; message?: string } })
              .error;
            const detail = `${errPayload?.type ?? 'unknown'}: ${errPayload?.message ?? ''}`.slice(
              0,
              200,
            );
            getLogger().error(`Anthropic in-stream error event: ${detail}`);
            throw new PatterConnectionError(`Anthropic stream error — ${detail}`);
          }

          // Capture input + prompt-cache token counts from the opening message event.
          if (event.type === 'message_start' && event.message?.usage) {
            const u = event.message.usage;
            if (u.input_tokens) inputTokens = u.input_tokens;
            if (u.cache_creation_input_tokens) cacheWriteTokens = u.cache_creation_input_tokens;
            if (u.cache_read_input_tokens) cacheReadTokens = u.cache_read_input_tokens;
            continue;
          }

          // Capture output token count from the closing message delta event.
          if (event.type === 'message_delta' && event.usage?.output_tokens) {
            outputTokens = event.usage.output_tokens;
            continue;
          }

          if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
            const blockIdx = event.index ?? 0;
            const toolId = event.content_block.id ?? '';
            const toolName = event.content_block.name ?? '';
            const patterIndex = nextIndex++;
            toolIndexByBlock.set(blockIdx, patterIndex);
            toolIdByBlock.set(blockIdx, toolId);
            yield {
              type: 'tool_call',
              index: patterIndex,
              id: toolId,
              name: toolName,
              arguments: '',
            };
            continue;
          }

          if (event.type === 'content_block_delta') {
            if (event.delta?.type === 'text_delta' && event.delta.text) {
              yield { type: 'text', content: event.delta.text };
              continue;
            }
            if (event.delta?.type === 'input_json_delta' && event.delta.partial_json) {
              const blockIdx = event.index ?? 0;
              const patterIndex = toolIndexByBlock.get(blockIdx);
              if (patterIndex !== undefined) {
                yield {
                  type: 'tool_call',
                  index: patterIndex,
                  id: toolIdByBlock.get(blockIdx),
                  arguments: event.delta.partial_json,
                };
              }
            }
          }
        }
      }
    } catch (err) {
      if (idle.fired && !opts?.signal?.aborted) {
        throw new PatterConnectionError(
          `Anthropic stream idle timeout — no data for ${LLM_STREAM_IDLE_TIMEOUT_MS / 1000}s`,
        );
      }
      throw err;
    } finally {
      idle.clear();
      reader.cancel().catch(() => {});
    }

    // Emit token usage before done so the llm-loop can record accurate billing.
    if (inputTokens > 0 || outputTokens > 0 || cacheReadTokens > 0 || cacheWriteTokens > 0) {
      yield {
        type: 'usage',
        inputTokens,
        outputTokens,
        cacheReadInputTokens: cacheReadTokens,
        cacheWriteInputTokens: cacheWriteTokens,
      } as LLMChunk;
    }

    yield { type: 'done' };
  }
}

// ---------------------------------------------------------------------------
// Translation helpers (OpenAI format -> Anthropic Messages API)
// ---------------------------------------------------------------------------

function toAnthropicTools(tools: OpenAIToolDef[]): AnthropicTool[] {
  return tools.map((t) => {
    const fn = t.function ?? t;
    return {
      name: String(fn.name ?? ''),
      description: String(fn.description ?? ''),
      input_schema:
        (fn.parameters as Record<string, unknown>) ?? { type: 'object', properties: {} },
    };
  });
}

interface OpenAIStyleMessage {
  role?: string;
  content?: string | Array<Record<string, unknown>>;
  tool_calls?: Array<{
    id?: string;
    function?: { name?: string; arguments?: string };
  }>;
  tool_call_id?: string;
  name?: string;
}

function toAnthropicMessages(
  messages: Array<Record<string, unknown>>,
): { system: string; messages: AnthropicMessage[] } {
  const systemParts: string[] = [];
  const out: AnthropicMessage[] = [];

  for (const rawMsg of messages as OpenAIStyleMessage[]) {
    const role = rawMsg.role;

    if (role === 'system') {
      if (typeof rawMsg.content === 'string' && rawMsg.content) {
        systemParts.push(rawMsg.content);
      }
      continue;
    }

    if (role === 'user') {
      if (typeof rawMsg.content === 'string') {
        out.push({ role: 'user', content: rawMsg.content });
      } else if (rawMsg.content) {
        out.push({ role: 'user', content: rawMsg.content });
      }
      continue;
    }

    if (role === 'assistant') {
      const blocks: Array<Record<string, unknown>> = [];
      if (typeof rawMsg.content === 'string' && rawMsg.content) {
        blocks.push({ type: 'text', text: rawMsg.content });
      }
      for (const tc of rawMsg.tool_calls ?? []) {
        let args: unknown = {};
        try {
          args = JSON.parse(tc.function?.arguments ?? '{}');
        } catch {
          args = {};
        }
        blocks.push({
          type: 'tool_use',
          id: tc.id ?? '',
          name: tc.function?.name ?? '',
          input: args,
        });
      }
      if (blocks.length > 0) {
        out.push({ role: 'assistant', content: blocks });
      }
      continue;
    }

    if (role === 'tool') {
      const contentStr =
        typeof rawMsg.content === 'string' ? rawMsg.content : JSON.stringify(rawMsg.content);
      const toolResultBlock = {
        type: 'tool_result',
        tool_use_id: rawMsg.tool_call_id ?? '',
        content: contentStr,
      };
      // Anthropic requires that all tool_result blocks for a single assistant
      // turn appear in ONE user message.  If the previous message is already a
      // user message whose content consists entirely of tool_result blocks,
      // append to it instead of pushing a new message.
      const prev = out.length > 0 ? out[out.length - 1] : undefined;
      if (
        prev &&
        prev.role === 'user' &&
        Array.isArray(prev.content) &&
        prev.content.length > 0 &&
        (prev.content as Array<Record<string, unknown>>).every((b) => b['type'] === 'tool_result')
      ) {
        (prev.content as Array<Record<string, unknown>>).push(toolResultBlock);
      } else {
        out.push({ role: 'user', content: [toolResultBlock] });
      }
      continue;
    }
  }

  // The Messages API requires the first message to use the ``user`` role.
  // Voice agents almost always open with ``firstMessage``, so history starts
  // with an assistant greeting — prepend a synthetic user turn so every turn
  // of the call doesn't 400. Mirrors the Python provider.
  if (out.length > 0 && out[0].role === 'assistant') {
    out.unshift({ role: 'user', content: '(call connected)' });
  }

  return { system: systemParts.join('\n\n'), messages: out };
}
