export type ProjectAiProviderId =
  | 'openai'
  | 'anthropic'
  | 'gemini'
  | 'openrouter'
  | 'openai-compatible';


export type ProjectAiProviderMode = 'disabled' | 'auto' | 'local' | 'ollama' | ProjectAiProviderId;

export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434';

export type OllamaModelDescriptor = {
  name: string;
  model: string;
  size?: number;
  family?: string;
  parameterSize?: string;
  quantizationLevel?: string;
  /** Ollama can proxy cloud-backed models through localhost; these are not local inference. */
  remoteModel?: string;
  remoteHost?: string;
  remote: boolean;
  /** Advertised runtime capabilities from /api/show when available. */
  capabilities?: readonly string[];
};

export type OllamaModelInfo = OllamaModelDescriptor & {
  baseUrl: string;
};

export type OllamaConnectionInfo = {
  baseUrl: string;
  version?: string;
  models: readonly OllamaModelDescriptor[];
};


export function normalizeOllamaBaseUrl(configuredBaseUrl?: string): string {
  const raw = configuredBaseUrl?.trim() || DEFAULT_OLLAMA_BASE_URL;
  let url: URL;
  try {
    url = new URL(raw);
  } catch {
    throw new Error('Ollama host must be a valid URL');
  }
  if (url.username || url.password) throw new Error('Ollama host must not contain credentials');
  if (url.search || url.hash) throw new Error('Ollama host must not contain a query string or fragment');
  const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
  if (!loopback) throw new Error('Ollama host must use localhost or 127.0.0.1');
  if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('Ollama host must use HTTP or HTTPS');
  if (url.pathname && url.pathname !== '/') throw new Error('Ollama host must not include an API path; Learnitive adds it automatically');
  return url.origin;
}

export class OllamaUnavailableError extends Error {
  override name = 'OllamaUnavailableError';
}

export function isOllamaUnavailableError(reason: unknown): reason is OllamaUnavailableError {
  return reason instanceof OllamaUnavailableError || (reason instanceof Error && reason.name === 'OllamaUnavailableError');
}

function ollamaNetworkError(reason: unknown): Error {
  const detail = reason instanceof Error ? reason.message : String(reason ?? '');
  return new OllamaUnavailableError(`Unable to reach Ollama on this computer${detail ? `: ${detail}` : ''}. Start Ollama and allow your Learnitive origin in OLLAMA_ORIGINS.`);
}

export async function fetchOllamaConnectionInfo(
  configuredBaseUrl?: string,
  fetchImpl: typeof fetch = fetch,
  signal?: AbortSignal,
): Promise<OllamaConnectionInfo> {
  const baseUrl = normalizeOllamaBaseUrl(configuredBaseUrl);
  let versionResponse: Response;
  let tagsResponse: Response;
  try {
    [versionResponse, tagsResponse] = await Promise.all([
      fetchImpl(`${baseUrl}/api/version`, { signal, cache: 'no-store' }),
      fetchImpl(`${baseUrl}/api/tags`, { signal, cache: 'no-store' }),
    ]);
  } catch (reason) {
    throw ollamaNetworkError(reason);
  }
  if (!versionResponse.ok) throw new Error(`Ollama version check failed (${versionResponse.status})`);
  if (!tagsResponse.ok) throw new Error(`Ollama model discovery failed (${tagsResponse.status})`);
  const versionPayload = await versionResponse.json() as { version?: unknown };
  const tagsPayload = await tagsResponse.json() as { models?: unknown };
  const models = Array.isArray(tagsPayload.models)
    ? tagsPayload.models.flatMap((item): OllamaModelDescriptor[] => {
        if (!item || typeof item !== 'object') return [];
        const value = item as Record<string, unknown>;
        const details = value.details && typeof value.details === 'object' ? value.details as Record<string, unknown> : {};
        const name = typeof value.name === 'string' ? value.name : typeof value.model === 'string' ? value.model : '';
        const model = typeof value.model === 'string' ? value.model : name;
        if (!name || !model) return [];
        return [{
          name,
          model,
          ...(typeof value.size === 'number' ? { size: value.size } : {}),
          ...(typeof details.family === 'string' ? { family: details.family } : {}),
          ...(typeof details.parameter_size === 'string' ? { parameterSize: details.parameter_size } : {}),
          ...(typeof details.quantization_level === 'string' ? { quantizationLevel: details.quantization_level } : {}),
          ...(typeof value.remote_model === 'string' && value.remote_model.trim() ? { remoteModel: value.remote_model.trim() } : {}),
          ...(typeof value.remote_host === 'string' && value.remote_host.trim() ? { remoteHost: value.remote_host.trim() } : {}),
          remote: Boolean(
            (typeof value.remote_model === 'string' && value.remote_model.trim())
            || (typeof value.remote_host === 'string' && value.remote_host.trim()),
          ),
        }];
      })
    : [];
  return {
    baseUrl,
    ...(typeof versionPayload.version === 'string' ? { version: versionPayload.version } : {}),
    models,
  };
}

export async function fetchOllamaModelInfo(
  configuredBaseUrl: string | undefined,
  model: string,
  fetchImpl: typeof fetch = fetch,
  signal?: AbortSignal,
): Promise<OllamaModelInfo> {
  const baseUrl = normalizeOllamaBaseUrl(configuredBaseUrl);
  const selected = model.trim();
  if (!selected) throw new Error('Choose an installed Ollama model first');
  let response: Response;
  try {
    response = await fetchImpl(`${baseUrl}/api/show`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ model: selected }),
      signal,
      cache: 'no-store',
    });
  } catch (reason) {
    throw ollamaNetworkError(reason);
  }
  if (!response.ok) throw new Error(`Ollama model inspection failed (${response.status})`);
  const payload = await response.json() as Record<string, unknown>;
  const details = payload.details && typeof payload.details === 'object' ? payload.details as Record<string, unknown> : {};
  const capabilities = Array.isArray(payload.capabilities)
    ? payload.capabilities.filter((value): value is string => typeof value === 'string' && Boolean(value.trim())).map((value) => value.trim())
    : [];
  const remoteModel = typeof payload.remote_model === 'string' && payload.remote_model.trim() ? payload.remote_model.trim() : undefined;
  const remoteHost = typeof payload.remote_host === 'string' && payload.remote_host.trim() ? payload.remote_host.trim() : undefined;
  return {
    baseUrl,
    name: selected,
    model: selected,
    ...(typeof payload.size === 'number' ? { size: payload.size } : {}),
    ...(typeof details.family === 'string' ? { family: details.family } : {}),
    ...(typeof details.parameter_size === 'string' ? { parameterSize: details.parameter_size } : {}),
    ...(typeof details.quantization_level === 'string' ? { quantizationLevel: details.quantization_level } : {}),
    ...(remoteModel ? { remoteModel } : {}),
    ...(remoteHost ? { remoteHost } : {}),
    remote: Boolean(remoteModel || remoteHost),
    ...(capabilities.length ? { capabilities } : {}),
  };
}

export async function validateOllamaModel(
  configuredBaseUrl: string | undefined,
  model: string,
  fetchImpl: typeof fetch = fetch,
  signal?: AbortSignal,
  options: { allowRemote?: boolean } = {},
): Promise<OllamaModelInfo> {
  const selected = model.trim();
  if (!selected) throw new Error('Choose an Ollama model first');
  const connection = await fetchOllamaConnectionInfo(configuredBaseUrl, fetchImpl, signal);
  const listed = connection.models.find((candidate) => candidate.model === selected || candidate.name === selected);
  if (!listed) throw new Error(`Ollama model ${selected} is not available through this Ollama service`);
  if (listed.remote && !options.allowRemote) throw new Error(`Ollama model ${selected} is cloud-backed and is not allowed in Local Ollama mode`);
  const inspected = await fetchOllamaModelInfo(connection.baseUrl, listed.model, fetchImpl, signal);
  if (inspected.remote && !options.allowRemote) throw new Error(`Ollama model ${selected} is cloud-backed and is not allowed in Local Ollama mode`);
  if (inspected.capabilities?.length && !inspected.capabilities.includes('completion')) {
    throw new Error(`Ollama model ${selected} does not advertise text-completion capability`);
  }
  return {
    ...listed,
    ...inspected,
    baseUrl: connection.baseUrl,
    remote: Boolean(listed.remote || inspected.remote),
  };
}

export async function validateLocalOllamaModel(
  configuredBaseUrl: string | undefined,
  model: string,
  fetchImpl: typeof fetch = fetch,
  signal?: AbortSignal,
): Promise<OllamaModelInfo> {
  return validateOllamaModel(configuredBaseUrl, model, fetchImpl, signal, { allowRemote: false });
}

export type ProjectAiModelPreset = {
  id: string;
  label: string;
  description: string;
  recommended?: boolean;
};

/**
 * Friendly, curated defaults for browser-held BYOK providers. These are UI
 * conveniences only: Learnitive still accepts a custom model ID and never sends
 * provider credentials anywhere except the selected provider from the browser.
 */
export const PROJECT_AI_MODEL_PRESETS: Readonly<Partial<Record<ProjectAiProviderId, readonly ProjectAiModelPreset[]>>> = {
  openai: [
    { id: 'gpt-5.6-terra', label: 'GPT-5.6 Terra · Recommended', description: 'Balanced intelligence and cost for paper review.', recommended: true },
    { id: 'gpt-5.6-sol', label: 'GPT-5.6 Sol · Highest quality', description: 'Frontier reasoning for the most demanding reviews.' },
    { id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna · Economy', description: 'Lower-cost option for lighter review workloads.' },
  ],
  anthropic: [
    { id: 'claude-sonnet-5', label: 'Claude Sonnet 5 · Recommended', description: 'Strong quality/speed balance for scientific review.', recommended: true },
    { id: 'claude-opus-5', label: 'Claude Opus 5 · Highest quality', description: 'Use for the most complex methodological reasoning.' },
    { id: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5 · Fast', description: 'Fast, lower-cost option for quick review tasks.' },
  ],
  gemini: [
    { id: 'gemini-3.7-flash', label: 'Gemini 3.7 Flash · Recommended', description: 'Fast multimodal model suited to paper and figure review.', recommended: true },
    { id: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro · Advanced reasoning', description: 'Higher-reasoning preview model for difficult reviews.' },
    { id: 'gemini-3.5-flash-lite', label: 'Gemini 3.5 Flash-Lite · Economy', description: 'Low-latency, cost-sensitive review option.' },
    { id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro · Stable', description: 'Stable high-capability fallback.' },
  ],
  openrouter: [
    { id: 'openrouter/auto', label: 'OpenRouter Auto · Recommended', description: 'Lets OpenRouter route each review request to an appropriate model.', recommended: true },
    { id: '~anthropic/claude-sonnet-latest', label: 'Claude Sonnet · Latest', description: 'OpenRouter latest Claude Sonnet family alias.' },
    { id: '~google/gemini-flash-latest', label: 'Gemini Flash · Latest', description: 'OpenRouter latest Gemini Flash family alias.' },
    { id: 'openrouter/free', label: 'OpenRouter Free Router', description: 'Routes among currently available free models; quality can vary.' },
  ],
  'openai-compatible': [],
} as const;

export function projectAiModelPresets(provider: ProjectAiProviderId): readonly ProjectAiModelPreset[] {
  return PROJECT_AI_MODEL_PRESETS[provider] ?? [];
}

export function defaultProjectAiModel(provider: ProjectAiProviderId): string {
  const presets = projectAiModelPresets(provider);
  return presets.find((preset) => preset.recommended)?.id ?? presets[0]?.id ?? '';
}

export type LocalAiModelId = 'gemma-4-e2b-it-web' | 'gemma-4-e4b-it-web';

export type LocalAiModelDescriptor = {
  id: LocalAiModelId;
  label: string;
  family: 'Gemma 4';
  runtime: 'LiteRT-LM';
  modelUrl: string;
  /** Stable cache compatibility identity. Change only when the model artifact bytes/schema change. */
  artifactRevision: string;
  description: string;
};

export const LOCAL_AI_MODELS: readonly LocalAiModelDescriptor[] = [
  {
    id: 'gemma-4-e2b-it-web',
    label: 'Gemma 4 E2B',
    family: 'Gemma 4',
    runtime: 'LiteRT-LM',
    modelUrl: 'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it-web.litertlm',
    artifactRevision: 'gemma-4-e2b-it-web-v1',
    description: 'Smaller browser model; recommended default for local Learnitive AI.',
  },
  {
    id: 'gemma-4-e4b-it-web',
    label: 'Gemma 4 E4B',
    family: 'Gemma 4',
    runtime: 'LiteRT-LM',
    modelUrl: 'https://huggingface.co/litert-community/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.litertlm',
    artifactRevision: 'gemma-4-e4b-it-web-v1',
    description: 'Larger browser model with higher resource requirements.',
  },
] as const;

export type LocalAiCapabilities = {
  webGpu: boolean;
  opfs: boolean;
  supported: boolean;
  reason?: string;
};

export type LocalAiModelState = {
  modelId: LocalAiModelId;
  status: 'not-installed' | 'downloading' | 'installed' | 'loading' | 'ready' | 'error';
  bytesDownloaded?: number;
  totalBytes?: number;
  installedBytes?: number;
  error?: string;
};

export type GenerationRequest = {
  messages: readonly ProjectAiProviderMessage[];
  maxTokens?: number;
};

export type GenerationChunk = {
  text: string;
};

export interface LocalGenerationEngine {
  load(signal?: AbortSignal): Promise<void>;
  unload(): Promise<void>;
  /** Total prompt + completion context configured for this loaded browser runtime. */
  contextWindowTokens?(): number;
  generate(request: GenerationRequest, signal?: AbortSignal): AsyncIterable<GenerationChunk>;
}

export interface LocalAiPlatform {
  readonly models: readonly LocalAiModelDescriptor[];
  capabilities(): Promise<LocalAiCapabilities>;
  modelState(modelId: LocalAiModelId): Promise<LocalAiModelState>;
  downloadModel(
    modelId: LocalAiModelId,
    onProgress: (state: LocalAiModelState) => void,
    signal?: AbortSignal,
  ): Promise<LocalAiModelState>;
  removeModel(modelId: LocalAiModelId): Promise<void>;
  createGenerationEngine(modelId: LocalAiModelId): LocalGenerationEngine;
}

export type ProjectAiProviderConfig = {
  provider: ProjectAiProviderId;
  model: string;
  apiKey: string;
  baseUrl?: string;
};

export type ProjectAiMessageRole = 'system' | 'user' | 'assistant';

export type ProjectAiProviderMessage = {
  role: ProjectAiMessageRole;
  content: string;
};

export type ProjectAiImageInput = {
  /** Browser-memory image bytes encoded only for the outbound provider request. */
  dataBase64: string;
  mimeType: 'image/png' | 'image/jpeg' | 'image/webp';
  name?: string;
};

export type ProjectAiToolCall = {
  id: string;
  name: string;
  arguments: Record<string, unknown>;
};

export type ProjectAiAgentEnvelope = {
  message: string;
  toolCalls: ProjectAiToolCall[];
  /** Internal protocol was detected but could not be parsed safely. */
  protocolError?: boolean;
};

export type ProjectAiCompletionRequest = {
  config: ProjectAiProviderConfig;
  messages: readonly ProjectAiProviderMessage[];
  images?: readonly ProjectAiImageInput[];
  signal?: AbortSignal;
};

export type ProjectAiProviderCapabilities = {
  /** True only when the adapter performs incremental network streaming itself. */
  streaming: boolean;
  /** Native provider tool/function calling. Learnitive currently uses its bounded agent envelope instead. */
  toolCalling: boolean;
  /** Whether the adapter can encode image inputs for supported models. */
  images: boolean;
  /** Provider/model context limit when known without a network lookup. */
  contextLimit: number | null;
};

export interface ProjectAiProviderAdapter {
  complete(request: ProjectAiCompletionRequest): Promise<string>;
  /** Stable capability contract used by model routing and future provider expansion. */
  capabilities(): ProjectAiProviderCapabilities;
  supportsTools(): boolean;
  supportsImages(): boolean;
  contextLimit(): number | undefined;
  /**
   * Streaming-compatible interface. Adapters that do not yet implement native
   * incremental transport yield a single completion chunk and advertise
   * streaming=false through capabilities().
   */
  stream(request: ProjectAiCompletionRequest): AsyncIterable<GenerationChunk>;
}

const DEFAULT_BASE_URLS: Record<ProjectAiProviderId, string> = {
  openai: 'https://api.openai.com/v1',
  anthropic: 'https://api.anthropic.com/v1',
  gemini: 'https://generativelanguage.googleapis.com/v1beta',
  openrouter: 'https://openrouter.ai/api/v1',
  'openai-compatible': '',
};

export function normalizeProjectAiBaseUrl(
  provider: ProjectAiProviderId,
  configuredBaseUrl?: string,
): string {
  const raw = configuredBaseUrl?.trim() || DEFAULT_BASE_URLS[provider];
  if (!raw) return '';
  let url: URL;
  try {
    url = new URL(raw);
  } catch {
    throw new Error('AI provider base URL must be a valid URL');
  }
  if (url.username || url.password) {
    throw new Error('AI provider base URL must not contain credentials');
  }
  if (url.search || url.hash) {
    throw new Error('AI provider base URL must not contain a query string or fragment');
  }
  const loopback =
    url.hostname === 'localhost' ||
    url.hostname === '127.0.0.1' ||
    url.hostname === '[::1]' ||
    url.hostname === '::1';
  if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
    throw new Error('AI provider base URL must use HTTPS (HTTP is allowed only for loopback development)');
  }
  return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
}

function baseUrl(config: ProjectAiProviderConfig): string {
  return normalizeProjectAiBaseUrl(config.provider, config.baseUrl);
}

function required(value: string, label: string): string {
  const trimmed = value.trim();
  if (!trimmed) throw new Error(`${label} is required`);
  return trimmed;
}

async function responseJson(response: Response): Promise<unknown> {
  const text = await response.text();
  if (!response.ok) {
    let detail = text.slice(0, 500);
    try {
      const parsed = JSON.parse(text) as { error?: { message?: string } | string; message?: string };
      detail =
        typeof parsed.error === 'string'
          ? parsed.error
          : parsed.error?.message ?? parsed.message ?? detail;
    } catch {
      // Keep the bounded response body. Never include request headers/API keys.
    }
    throw new Error(`AI provider request failed (${response.status}): ${detail}`);
  }
  try {
    return JSON.parse(text) as unknown;
  } catch {
    throw new Error('AI provider returned an invalid JSON response');
  }
}

function openAiContent(payload: unknown): string {
  const candidate = payload as {
    choices?: Array<{ message?: { content?: string | Array<{ type?: string; text?: string }> } }>;
  };
  const content = candidate.choices?.[0]?.message?.content;
  if (typeof content === 'string') return content;
  if (Array.isArray(content)) {
    return content.map((part) => (part?.type === 'text' ? part.text ?? '' : '')).join('');
  }
  throw new Error('AI provider response did not contain assistant text');
}

function boundedImages(images: readonly ProjectAiImageInput[] | undefined): readonly ProjectAiImageInput[] {
  return (images ?? []).filter((image) => image.dataBase64.length > 0 && ['image/png', 'image/jpeg', 'image/webp'].includes(image.mimeType)).slice(0, 4);
}

function openAiMessages(messages: readonly ProjectAiProviderMessage[], images: readonly ProjectAiImageInput[] | undefined): unknown[] {
  const visual = boundedImages(images);
  if (visual.length === 0) return [...messages];
  let attached = false;
  return [...messages].reverse().map((message) => {
    if (!attached && message.role === 'user') {
      attached = true;
      return {
        role: message.role,
        content: [
          { type: 'text', text: message.content },
          ...visual.map((image) => ({ type: 'image_url', image_url: { url: `data:${image.mimeType};base64,${image.dataBase64}` } })),
        ],
      };
    }
    return message;
  }).reverse();
}

function invokeProviderFetch(
  fetchImpl: typeof fetch,
  input: RequestInfo | URL,
  init?: RequestInit,
): Promise<Response> {
  // Browser-native `window.fetch` is receiver-sensitive in Chromium. Provider
  // adapters store the implementation on an object, so invoking it as
  // `this.fetchImpl(...)` would bind `this` to the adapter and can throw
  // "Failed to execute 'fetch' on 'Window': Illegal invocation". Always
  // invoke the supplied implementation with the browser/global receiver.
  const receiver = typeof window === 'undefined' ? globalThis : window;
  return Reflect.apply(fetchImpl, receiver, [input, init]) as Promise<Response>;
}

async function* sseJson(response: Response): AsyncIterable<unknown> {
  if (!response.ok) {
    await responseJson(response);
    return;
  }
  if (!response.body) throw new Error('AI provider streaming response did not contain a body');
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  const parseBlock = (block: string): unknown | undefined => {
    const data = block
      .split(/\r?\n/u)
      .filter((line) => line.startsWith('data:'))
      .map((line) => line.slice(5).trim())
      .join('\n');
    if (!data || data === '[DONE]') return undefined;
    try { return JSON.parse(data) as unknown; } catch { return undefined; }
  };
  while (true) {
    const { value, done } = await reader.read();
    buffer += decoder.decode(value, { stream: !done }).replace(/\r\n/gu, '\n');
    let boundary = buffer.indexOf('\n\n');
    while (boundary >= 0) {
      const parsed = parseBlock(buffer.slice(0, boundary));
      buffer = buffer.slice(boundary + 2);
      if (parsed !== undefined) yield parsed;
      boundary = buffer.indexOf('\n\n');
    }
    if (done) break;
  }
  const parsed = parseBlock(buffer);
  if (parsed !== undefined) yield parsed;
}

function anthropicConversation(
  messages: readonly ProjectAiProviderMessage[],
  images: readonly ProjectAiImageInput[] | undefined,
): { system: string; conversation: Array<Record<string, unknown>> } {
  const system = messages.filter((item) => item.role === 'system').map((item) => item.content).join('\n\n');
  const visual = boundedImages(images);
  let attached = false;
  const conversation = [...messages]
    .filter((item) => item.role !== 'system')
    .reverse()
    .map((item) => {
      const role = item.role === 'assistant' ? 'assistant' : 'user';
      if (!attached && role === 'user' && visual.length > 0) {
        attached = true;
        return {
          role,
          content: [
            { type: 'text', text: item.content },
            ...visual.map((image) => ({ type: 'image', source: { type: 'base64', media_type: image.mimeType, data: image.dataBase64 } })),
          ],
        };
      }
      return { role, content: item.content };
    })
    .reverse();
  return { system, conversation };
}

function geminiConversation(
  messages: readonly ProjectAiProviderMessage[],
  images: readonly ProjectAiImageInput[] | undefined,
): { system: string; contents: Array<Record<string, unknown>> } {
  const system = messages.filter((item) => item.role === 'system').map((item) => item.content).join('\n\n');
  const visual = boundedImages(images);
  let attached = false;
  const contents = [...messages]
    .filter((item) => item.role !== 'system')
    .reverse()
    .map((item) => {
      const role = item.role === 'assistant' ? 'model' : 'user';
      const parts: Array<Record<string, unknown>> = [{ text: item.content }];
      if (!attached && role === 'user' && visual.length > 0) {
        attached = true;
        parts.push(...visual.map((image) => ({ inlineData: { mimeType: image.mimeType, data: image.dataBase64 } })));
      }
      return { role, parts };
    })
    .reverse();
  return { system, contents };
}

class OpenAiCompatibleAdapter implements ProjectAiProviderAdapter {
  constructor(private readonly fetchImpl: typeof fetch = fetch) {}

  capabilities(): ProjectAiProviderCapabilities { return { streaming: true, toolCalling: false, images: true, contextLimit: null }; }
  supportsTools(): boolean { return this.capabilities().toolCalling; }
  supportsImages(): boolean { return this.capabilities().images; }
  contextLimit(): number | undefined { return this.capabilities().contextLimit ?? undefined; }

  async *stream({ config, messages, images, signal }: ProjectAiCompletionRequest): AsyncIterable<GenerationChunk> {
    const endpoint = required(baseUrl(config), 'OpenAI-compatible base URL');
    const response = await invokeProviderFetch(this.fetchImpl, `${endpoint}/chat/completions`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${required(config.apiKey, 'API key')}`,
        'Content-Type': 'application/json',
        ...(config.provider === 'openrouter'
          ? { 'HTTP-Referer': 'https://www.learnitive.com', 'X-Title': 'Learnitive' }
          : {}),
      },
      body: JSON.stringify({
        model: required(config.model, 'Model'),
        messages: openAiMessages(messages, images),
        stream: true,
      }),
      signal,
    });
    for await (const event of sseJson(response)) {
      const payload = event as { choices?: Array<{ delta?: { content?: string | Array<{ type?: string; text?: string }> } }> };
      const content = payload.choices?.[0]?.delta?.content;
      const text = typeof content === 'string'
        ? content
        : Array.isArray(content)
          ? content.map((part) => (part?.type === 'text' ? part.text ?? '' : '')).join('')
          : '';
      if (text) yield { text };
    }
  }

  async complete({ config, messages, images, signal }: ProjectAiCompletionRequest): Promise<string> {
    const endpoint = required(baseUrl(config), 'OpenAI-compatible base URL');
    const response = await invokeProviderFetch(this.fetchImpl, `${endpoint}/chat/completions`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${required(config.apiKey, 'API key')}`,
        'Content-Type': 'application/json',
        ...(config.provider === 'openrouter'
          ? { 'HTTP-Referer': 'https://www.learnitive.com', 'X-Title': 'Learnitive' }
          : {}),
      },
      body: JSON.stringify({
        model: required(config.model, 'Model'),
        messages: openAiMessages(messages, images),
      }),
      signal,
    });
    return openAiContent(await responseJson(response));
  }
}

class AnthropicAdapter implements ProjectAiProviderAdapter {
  constructor(private readonly fetchImpl: typeof fetch = fetch) {}

  capabilities(): ProjectAiProviderCapabilities { return { streaming: true, toolCalling: false, images: true, contextLimit: null }; }
  supportsTools(): boolean { return this.capabilities().toolCalling; }
  supportsImages(): boolean { return this.capabilities().images; }
  contextLimit(): number | undefined { return this.capabilities().contextLimit ?? undefined; }

  async *stream({ config, messages, images, signal }: ProjectAiCompletionRequest): AsyncIterable<GenerationChunk> {
    const { system, conversation } = anthropicConversation(messages, images);
    const response = await invokeProviderFetch(this.fetchImpl, `${required(baseUrl(config), 'Anthropic base URL')}/messages`, {
      method: 'POST',
      headers: {
        'x-api-key': required(config.apiKey, 'API key'),
        'anthropic-version': '2023-06-01',
        'anthropic-dangerous-direct-browser-access': 'true',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: required(config.model, 'Model'),
        max_tokens: 4096,
        ...(system ? { system } : {}),
        messages: conversation,
        stream: true,
      }),
      signal,
    });
    for await (const event of sseJson(response)) {
      const payload = event as { type?: string; delta?: { type?: string; text?: string } };
      const text = payload.type === 'content_block_delta' && payload.delta?.type === 'text_delta'
        ? payload.delta.text ?? ''
        : '';
      if (text) yield { text };
    }
  }

  async complete({ config, messages, images, signal }: ProjectAiCompletionRequest): Promise<string> {
    const { system, conversation } = anthropicConversation(messages, images);
    const response = await invokeProviderFetch(this.fetchImpl, `${required(baseUrl(config), 'Anthropic base URL')}/messages`, {
      method: 'POST',
      headers: {
        'x-api-key': required(config.apiKey, 'API key'),
        'anthropic-version': '2023-06-01',
        'anthropic-dangerous-direct-browser-access': 'true',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: required(config.model, 'Model'),
        max_tokens: 4096,
        ...(system ? { system } : {}),
        messages: conversation,
      }),
      signal,
    });
    const payload = (await responseJson(response)) as {
      content?: Array<{ type?: string; text?: string }>;
    };
    const text = payload.content?.filter((item) => item.type === 'text').map((item) => item.text ?? '').join('') ?? '';
    if (!text) throw new Error('Anthropic response did not contain assistant text');
    return text;
  }
}

class GeminiAdapter implements ProjectAiProviderAdapter {
  constructor(private readonly fetchImpl: typeof fetch = fetch) {}

  capabilities(): ProjectAiProviderCapabilities { return { streaming: true, toolCalling: false, images: true, contextLimit: null }; }
  supportsTools(): boolean { return this.capabilities().toolCalling; }
  supportsImages(): boolean { return this.capabilities().images; }
  contextLimit(): number | undefined { return this.capabilities().contextLimit ?? undefined; }

  async *stream({ config, messages, images, signal }: ProjectAiCompletionRequest): AsyncIterable<GenerationChunk> {
    const { system, contents } = geminiConversation(messages, images);
    const endpoint = required(baseUrl(config), 'Gemini base URL');
    const model = encodeURIComponent(required(config.model, 'Model'));
    const response = await invokeProviderFetch(this.fetchImpl, `${endpoint}/models/${model}:streamGenerateContent?alt=sse`, {
      method: 'POST',
      headers: {
        'x-goog-api-key': required(config.apiKey, 'API key'),
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}),
        contents,
      }),
      signal,
    });
    for await (const event of sseJson(response)) {
      const payload = event as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> };
      const text = payload.candidates?.[0]?.content?.parts?.map((part) => part.text ?? '').join('') ?? '';
      if (text) yield { text };
    }
  }

  async complete({ config, messages, images, signal }: ProjectAiCompletionRequest): Promise<string> {
    const { system, contents } = geminiConversation(messages, images);
    const endpoint = required(baseUrl(config), 'Gemini base URL');
    const model = encodeURIComponent(required(config.model, 'Model'));
    const response = await invokeProviderFetch(this.fetchImpl, `${endpoint}/models/${model}:generateContent`, {
      method: 'POST',
      headers: {
        'x-goog-api-key': required(config.apiKey, 'API key'),
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}),
        contents,
      }),
      signal,
    });
    const payload = (await responseJson(response)) as {
      candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
    };
    const text = payload.candidates?.[0]?.content?.parts?.map((part) => part.text ?? '').join('') ?? '';
    if (!text) throw new Error('Gemini response did not contain assistant text');
    return text;
  }
}

export function createProjectAiProviderAdapter(
  provider: ProjectAiProviderId,
  fetchImpl: typeof fetch = fetch,
): ProjectAiProviderAdapter {
  if (provider === 'anthropic') return new AnthropicAdapter(fetchImpl);
  if (provider === 'gemini') return new GeminiAdapter(fetchImpl);
  return new OpenAiCompatibleAdapter(fetchImpl);
}

function unwrapJson(text: string): string {
  const trimmed = text.trim();
  if (trimmed.startsWith('```')) {
    const firstBreak = trimmed.indexOf('\n');
    const lastFence = trimmed.lastIndexOf('```');
    if (firstBreak >= 0 && lastFence > firstBreak) return trimmed.slice(firstBreak + 1, lastFence).trim();
  }
  const firstBrace = trimmed.indexOf('{');
  const lastBrace = trimmed.lastIndexOf('}');
  return firstBrace >= 0 && lastBrace > firstBrace ? trimmed.slice(firstBrace, lastBrace + 1) : trimmed;
}

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

/**
 * Recover common small-model JSON syntax damage without asking the model for a
 * second formatting turn. This intentionally does not invent missing string
 * content: an unterminated JSON string still fails closed. Safe repairs are
 * limited to literal control characters, LaTeX-style unknown backslash escapes,
 * trailing commas and missing closing object/array delimiters.
 */
function recoverJsonObject(value: string): Record<string, unknown> | undefined {
  const trimmed = value
    .replace(/\[LEARNITIVE_[A-Z0-9_]+\]/gu, '')
    .trim()
    .replace(/^```(?:json)?\s*/iu, '')
    .replace(/\s*```$/u, '');
  const start = trimmed.indexOf('{');
  if (start < 0) return undefined;
  const source = trimmed.slice(start);

  let repaired = '';
  const stack: Array<'}' | ']'> = [];
  let inString = false;
  let escaped = false;
  for (let index = 0; index < source.length; index += 1) {
    const char = source[index]!;
    if (inString) {
      if (escaped) {
        repaired += char;
        escaped = false;
        continue;
      }
      if (char === '\\') {
        const next = source[index + 1];
        if (next && /["\\/bfnrtu]/u.test(next)) {
          repaired += char;
          escaped = true;
        } else {
          // JSON does not recognise LaTeX escapes such as \cite or \alpha.
          // Preserve the literal backslash by escaping it for JSON parsing.
          repaired += '\\\\';
        }
        continue;
      }
      if (char === '"') {
        repaired += char;
        inString = false;
        continue;
      }
      if (char === '\n') { repaired += '\\n'; continue; }
      if (char === '\r') { repaired += '\\r'; continue; }
      if (char === '\t') { repaired += '\\t'; continue; }
      repaired += char;
      continue;
    }

    repaired += char;
    if (char === '"') { inString = true; continue; }
    if (char === '{') stack.push('}');
    else if (char === '[') stack.push(']');
    else if (char === '}' || char === ']') {
      if (stack.at(-1) !== char) return undefined;
      stack.pop();
      if (stack.length === 0) {
        repaired = repaired.slice(0, repaired.length);
        break;
      }
    }
  }

  // Never guess the tail of a truncated mutation/tool string.
  if (inString) return undefined;
  repaired = repaired.trimEnd().replace(/,\s*$/u, '').replace(/,\s*([}\]])/gu, '$1');
  repaired += [...stack].reverse().join('');
  repaired = repaired.replace(/,\s*([}\]])/gu, '$1');
  try {
    const parsed = JSON.parse(repaired) as unknown;
    return isRecord(parsed) ? parsed : undefined;
  } catch {
    return undefined;
  }
}

function toolArguments(value: unknown): Record<string, unknown> {
  if (isRecord(value)) return value;
  if (typeof value !== 'string') return {};
  const trimmed = value.trim();
  if (!trimmed) return {};
  try {
    const parsed = JSON.parse(trimmed) as unknown;
    return isRecord(parsed) ? parsed : {};
  } catch {
    const repaired = recoverJsonObject(trimmed);
    if (repaired) return repaired;
    // Keep the call instead of discarding it. Tool-specific validation can
    // produce a useful error, while insert_table/insert_equation can recover
    // their content from the user's instruction.
    return {};
  }
}

function canonicalToolName(value: string): string {
  return value
    .trim()
    .replace(/([a-z0-9])([A-Z])/gu, '$1_$2')
    .replace(/[\s-]+/gu, '_')
    .toLowerCase();
}

function canonicalToolArguments(name: string, raw: Record<string, unknown>): Record<string, unknown> {
  const args = { ...raw };
  if (args.file === undefined) args.file = args.filePath ?? args.file_path ?? args.path;
  delete args.filePath;
  delete args.file_path;
  delete args.path;
  if (name === 'insert_table' || name === 'insert_equation') {
    if (args.latex === undefined) args.latex = args.latexCode ?? args.latex_code ?? args.code;
    delete args.latexCode;
    delete args.latex_code;
    delete args.code;
  }
  return Object.fromEntries(Object.entries(args).filter(([, value]) => value !== undefined));
}

function normalizedToolCall(item: unknown): ProjectAiToolCall | undefined {
  if (!isRecord(item)) return undefined;
  const fn = isRecord(item.function) ? item.function : undefined;
  const rawName = typeof item.name === 'string' ? item.name : typeof fn?.name === 'string' ? fn.name : undefined;
  const name = rawName ? canonicalToolName(rawName) : '';
  if (!name) return undefined;
  const rawArguments = item.arguments ?? item.args ?? item.parameters ?? fn?.arguments;
  const arguments_ = canonicalToolArguments(name, toolArguments(rawArguments));
  const id =
    (typeof item.id === 'string' && item.id.trim())
    || (typeof item.tool_call_id === 'string' && item.tool_call_id.trim())
    || crypto.randomUUID();
  return { id, name, arguments: arguments_ };
}

function rawToolCalls(value: Record<string, unknown>): unknown[] {
  const calls = [
    ...(Array.isArray(value.toolCalls) ? value.toolCalls : []),
    ...(Array.isArray(value.tool_calls) ? value.tool_calls : []),
  ];
  const single = value.toolCall ?? value.tool_call;
  if (single !== undefined) calls.push(single);
  // Some small models omit the envelope and return a tool call object itself.
  if (calls.length === 0 && (typeof value.name === 'string' || isRecord(value.function))) calls.push(value);
  return calls;
}

function stableToolCallKey(call: ProjectAiToolCall): string {
  const args = Object.keys(call.arguments)
    .sort()
    .reduce<Record<string, unknown>>((result, key) => {
      result[key] = call.arguments[key];
      return result;
    }, {});
  return `${call.name}:${JSON.stringify(args)}`;
}

export function parseProjectAiAgentEnvelope(text: string): ProjectAiAgentEnvelope {
  let value: unknown;
  const unwrapped = unwrapJson(text).trim();
  const looksLikeProtocol =
    /^\s*\{/u.test(unwrapped) &&
    /["']?(?:message|toolCalls|tool_calls|toolCall|tool_call|arguments|function)["']?\s*:/u.test(unwrapped);
  try {
    value = JSON.parse(unwrapped);
  } catch {
    value = recoverJsonObject(unwrapped);
    if (value) {
      // Continue through the exact same canonicalisation + schema validation as
      // strict JSON. Recovery only fixes syntax; it never authorises a tool.
    } else {
    // Never surface malformed/truncated internal tool JSON as assistant prose.
    // The service can ask the model to repair the protocol turn, while plain
    // natural-language answers continue to pass through unchanged.
      return looksLikeProtocol
        ? { message: '', toolCalls: [], protocolError: true }
        : { message: text.trim(), toolCalls: [] };
    }
  }
  if (!isRecord(value)) {
    return looksLikeProtocol
      ? { message: '', toolCalls: [], protocolError: true }
      : { message: text.trim(), toolCalls: [] };
  }
  const message =
    typeof value.message === 'string'
      ? value.message
      : typeof value.content === 'string'
        ? value.content
        : typeof value.response === 'string'
          ? value.response
          : '';
  // Local/browser models are not perfectly consistent about tool-call shape.
  // Normalise Learnitive camelCase/snake_case, OpenAI function calls, JSON-string
  // arguments, missing arguments, and single-call envelopes before dispatch.
  const toolCalls: ProjectAiToolCall[] = [];
  const seen = new Set<string>();
  for (const item of rawToolCalls(value)) {
    const call = normalizedToolCall(item);
    if (!call) continue;
    const key = stableToolCallKey(call);
    if (seen.has(key)) continue;
    seen.add(key);
    toolCalls.push(call);
  }
  return { message, toolCalls };
}

export type AiCapabilityClass = 'LOCAL' | 'LOCAL+SEARCH' | 'PRO';
export type AiExecutionMode = 'Private Local' | 'Research Search' | 'Vendor AI';
export type AiRoutableCapability =
  | 'assistant'
  | 'papers'
  | 'citation-intelligence'
  | 'scholarly-search'
  | 'related-papers'
  | 'systematic-review'
  | 'deep-research'
  | 'verification'
  | 'research-insights'
  | 'ai-review';

const AI_CAPABILITY_POLICY: Readonly<Record<AiRoutableCapability, AiCapabilityClass>> = Object.freeze({
  assistant: 'LOCAL',
  papers: 'LOCAL',
  'citation-intelligence': 'LOCAL',
  'scholarly-search': 'LOCAL+SEARCH',
  'related-papers': 'LOCAL+SEARCH',
  'systematic-review': 'LOCAL+SEARCH',
  'deep-research': 'LOCAL',
  verification: 'LOCAL',
  'research-insights': 'LOCAL+SEARCH',
  'ai-review': 'PRO',
});

/**
 * Central execution policy. LOCAL is the default. A caller must explicitly opt
 * into vendor execution for a PRO capability; this function never upgrades a
 * local request to Vendor AI on its own.
 */
export function routeAiCapability(
  capability: AiRoutableCapability,
  options: { vendorRequested?: boolean } = {},
): { capabilityClass: AiCapabilityClass; executionMode: AiExecutionMode } {
  const capabilityClass = AI_CAPABILITY_POLICY[capability] ?? 'LOCAL';
  if (capabilityClass === 'PRO' && options.vendorRequested === true) {
    return { capabilityClass, executionMode: 'Vendor AI' };
  }
  if (capabilityClass === 'LOCAL+SEARCH') {
    return { capabilityClass, executionMode: 'Research Search' };
  }
  return { capabilityClass, executionMode: 'Private Local' };
}
