// Copyright 2026 CRMy Contributors
// SPDX-License-Identifier: Apache-2.0

import type { AgentConfig, ConversationMessage, AgentToolDef, ToolCallRecord } from '../types.js';
import { buildOpenAICompatibleHeaders, chatCompletionsUrl, DEFAULT_LLM_TIMEOUT_MS, formatProviderHttpError, resolveLlmTimeoutMs } from '../provider-utils.js';

const AGENT_STREAM_TIMEOUT_MS = Number(process.env.AGENT_STREAM_TIMEOUT_MS ?? DEFAULT_LLM_TIMEOUT_MS);

function timeoutSignal(timeoutMs = AGENT_STREAM_TIMEOUT_MS, externalSignal?: AbortSignal): { signal: AbortSignal; done: () => void } {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  const onAbort = () => controller.abort();
  externalSignal?.addEventListener('abort', onAbort, { once: true });
  return {
    signal: controller.signal,
    done: () => {
      clearTimeout(timer);
      externalSignal?.removeEventListener('abort', onAbort);
    },
  };
}

/**
 * Call any OpenAI-compatible API or gateway configured in the shared provider catalog
 * with streaming. Uses raw fetch — no SDK dependency.
 */
export async function callOpenAICompat(
  messages: ConversationMessage[],
  tools: AgentToolDef[],
  config: AgentConfig,
  apiKey: string | null,
  onDelta: (text: string) => void,
  opts?: { abortSignal?: AbortSignal; timeoutMs?: number },
): Promise<{ content: string; tool_calls: ToolCallRecord[] }> {
  const openaiMessages = toOpenAIFormat(messages);
  const openaiTools = tools.length > 0 ? tools.map(t => ({
    type: 'function' as const,
    function: {
      name: t.name,
      description: t.description,
      parameters: t.parameters,
    },
  })) : undefined;

  const body: Record<string, unknown> = {
    model: config.model,
    max_tokens: config.max_tokens_per_turn,
    messages: openaiMessages,
    stream: true,
  };
  if (openaiTools) body.tools = openaiTools;

  const baseUrl = config.base_url.replace(/\/+$/, '');
  const url = chatCompletionsUrl(baseUrl);
  const headers = buildOpenAICompatibleHeaders(config.provider, baseUrl, apiKey ?? '');

  const timeout = timeoutSignal(opts?.timeoutMs ?? resolveLlmTimeoutMs(config, AGENT_STREAM_TIMEOUT_MS), opts?.abortSignal);
  try {
    const res = await fetch(url, {
      method: 'POST',
      headers,
      body: JSON.stringify(body),
      signal: timeout.signal,
    });

    if (!res.ok) {
      const errBody = await res.text();
      throw new Error(formatProviderHttpError(providerLabel(config.provider), res.status, errBody));
    }

    return await parseOpenAIStream(res, onDelta, new Set(tools.map(tool => tool.name)));
  } finally {
    timeout.done();
  }
}

function providerLabel(provider: string | null | undefined): string {
  if (!provider) return 'Model provider';
  if (provider === 'openrouter') return 'OpenRouter';
  if (provider === 'ollama') return 'Ollama';
  if (provider === 'databricks') return 'Databricks AI Gateway';
  if (provider === 'nvidia') return 'NVIDIA';
  return 'Model provider';
}

/** Convert generic messages to OpenAI chat format. */
function toOpenAIFormat(messages: ConversationMessage[]): Record<string, unknown>[] {
  return messages.map(msg => {
    if (msg.role === 'assistant' && msg.tool_calls?.length) {
      return {
        role: 'assistant',
        content: msg.content || null,
        tool_calls: msg.tool_calls.map(tc => ({
          id: tc.id,
          type: 'function',
          function: { name: tc.name, arguments: sanitizeToolArguments(tc.arguments) },
        })),
      };
    }

    if (msg.role === 'tool') {
      return {
        role: 'tool',
        tool_call_id: msg.tool_call_id,
        content: msg.content,
      };
    }

    return { role: msg.role, content: msg.content };
  });
}

/** Parse OpenAI streaming SSE response. */
async function parseOpenAIStream(
  res: Response,
  onDelta: (text: string) => void,
  knownToolNames: Set<string>,
): Promise<{ content: string; tool_calls: ToolCallRecord[] }> {
  let fullText = '';
  const toolCallsMap = new Map<number, { id: string; name: string; arguments: string }>();
  const idToIndex = new Map<string, number>();
  let nextFallbackIndex = 10_000;
  let lastFallbackIndex: number | null = null;

  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

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

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

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

      let chunk: Record<string, unknown>;
      try { chunk = JSON.parse(data); } catch { continue; }

      const choices = chunk.choices as { delta: Record<string, unknown> }[] | undefined;
      if (!choices?.length) continue;
      const delta = choices[0].delta;

      // Text content
      if (delta.content) {
        const text = delta.content as string;
        fullText += text;
        onDelta(text);
      }

      // Tool calls (streamed incrementally)
      const tcDeltas = delta.tool_calls as { index?: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
      if (tcDeltas) {
        for (const tcd of tcDeltas) {
          const index = resolveToolCallIndex(tcd, {
            toolCallsMap,
            idToIndex,
            knownToolNames,
            getNextFallbackIndex: () => nextFallbackIndex++,
            getLastFallbackIndex: () => lastFallbackIndex,
            setLastFallbackIndex: (value) => { lastFallbackIndex = value; },
          });
          const existing = toolCallsMap.get(index);
          if (!existing) {
            toolCallsMap.set(index, {
              id: tcd.id ?? '',
              name: tcd.function?.name ?? '',
              arguments: tcd.function?.arguments ?? '',
            });
          } else {
            if (tcd.id) existing.id = tcd.id;
            if (tcd.function?.name) existing.name = mergeToolNameChunk(existing.name, tcd.function.name);
            if (tcd.function?.arguments) existing.arguments += tcd.function.arguments;
          }
        }
      }
    }
  }

  const tool_calls: ToolCallRecord[] = [];
  for (const [, tc] of [...toolCallsMap.entries()].sort((a, b) => a[0] - b[0])) {
    tool_calls.push({ id: tc.id || crypto.randomUUID(), name: tc.name, arguments: sanitizeToolArguments(tc.arguments) });
  }

  return { content: fullText, tool_calls };
}

function sanitizeToolArguments(raw: string | undefined): string {
  if (!raw?.trim()) return '{}';
  try {
    JSON.parse(raw);
    return raw;
  } catch {
    return '{}';
  }
}

function resolveToolCallIndex(
  tcd: { index?: number; id?: string; function?: { name?: string; arguments?: string } },
  state: {
    toolCallsMap: Map<number, { id: string; name: string; arguments: string }>;
    idToIndex: Map<string, number>;
    knownToolNames: Set<string>;
    getNextFallbackIndex: () => number;
    getLastFallbackIndex: () => number | null;
    setLastFallbackIndex: (value: number) => void;
  },
): number {
  if (typeof tcd.index === 'number') {
    state.setLastFallbackIndex(tcd.index);
    if (tcd.id) state.idToIndex.set(tcd.id, tcd.index);
    return tcd.index;
  }

  if (tcd.id) {
    const existing = state.idToIndex.get(tcd.id);
    if (existing !== undefined) {
      state.setLastFallbackIndex(existing);
      return existing;
    }
    const next = state.getNextFallbackIndex();
    state.idToIndex.set(tcd.id, next);
    state.setLastFallbackIndex(next);
    return next;
  }

  const nameChunk = tcd.function?.name ?? '';
  const last = state.getLastFallbackIndex();
  if (last === null) {
    const next = state.getNextFallbackIndex();
    state.setLastFallbackIndex(next);
    return next;
  }

  const existing = state.toolCallsMap.get(last);
  const existingNameIsKnown = Boolean(existing?.name && state.knownToolNames.has(existing.name));
  const chunkIsDifferentKnownTool = Boolean(
    nameChunk
      && state.knownToolNames.has(nameChunk)
      && existing?.name
      && existing.name !== nameChunk
      && existingNameIsKnown,
  );

  if (chunkIsDifferentKnownTool) {
    const next = state.getNextFallbackIndex();
    state.setLastFallbackIndex(next);
    return next;
  }

  return last;
}

function mergeToolNameChunk(existing: string, chunk: string): string {
  if (!chunk) return existing;
  if (!existing) return chunk;

  // Some OpenAI-compatible providers repeat the full function name in multiple
  // deltas instead of streaming only the missing suffix. Treat exact or prefix
  // repeats as replacement/no-op, while still supporting true incremental chunks.
  if (existing === chunk) return existing;
  if (chunk.startsWith(existing)) return chunk;
  if (existing.endsWith(chunk)) return existing;

  let overlap = 0;
  const max = Math.min(existing.length, chunk.length);
  for (let size = 1; size <= max; size += 1) {
    if (existing.slice(-size) === chunk.slice(0, size)) overlap = size;
  }
  return existing + chunk.slice(overlap);
}
