import { describe, expect, it } from 'vitest';
import { DEFAULT_OLLAMA_BASE_URL, LOCAL_AI_MODELS, createProjectAiProviderAdapter, fetchOllamaConnectionInfo, fetchOllamaModelInfo, validateLocalOllamaModel, validateOllamaModel, normalizeOllamaBaseUrl, normalizeProjectAiBaseUrl, parseProjectAiAgentEnvelope } from './index';

describe('parseProjectAiAgentEnvelope', () => {
  it('parses tool calls from a fenced response', () => {
    const parsed = parseProjectAiAgentEnvelope(`\`\`\`json\n{"message":"Checking","toolCalls":[{"id":"1","name":"read_file","arguments":{"file":"main.tex"}}]}\n\`\`\``);
    expect(parsed.message).toBe('Checking');
    expect(parsed.toolCalls[0]).toMatchObject({ id: '1', name: 'read_file', arguments: { file: 'main.tex' } });
  });

  it('treats ordinary text as a final assistant message', () => {
    expect(parseProjectAiAgentEnvelope('Hello')).toEqual({ message: 'Hello', toolCalls: [] });
  });
  it('accepts local-model snake_case tool_calls even when camelCase is empty', () => {
    const parsed = parseProjectAiAgentEnvelope('{"message":"","toolCalls":[],"tool_calls":[{"id":"tool_call_1","name":"insert_equation","arguments":{}}]}');
    expect(parsed.toolCalls).toHaveLength(1);
    expect(parsed.toolCalls[0]).toMatchObject({ id: 'tool_call_1', name: 'insert_equation', arguments: {} });
  });

  it('normalizes OpenAI function calls whose arguments are JSON strings', () => {
    const parsed = parseProjectAiAgentEnvelope(JSON.stringify({
      content: 'Updating the source',
      tool_calls: [{
        id: 'call_1',
        type: 'function',
        function: {
          name: 'apply_patch',
          arguments: JSON.stringify({ file: 'main.tex', replacements: [{ find: 'old', replace: 'new' }] }),
        },
      }],
    }));
    expect(parsed.message).toBe('Updating the source');
    expect(parsed.toolCalls[0]).toMatchObject({
      id: 'call_1',
      name: 'apply_patch',
      arguments: { file: 'main.tex', replacements: [{ find: 'old', replace: 'new' }] },
    });
  });

  it('keeps missing or malformed arguments so recoverable mutation tools are not discarded', () => {
    const missing = parseProjectAiAgentEnvelope('{"message":"","tool_call":{"name":"insert_table"}}');
    expect(missing.toolCalls[0]).toMatchObject({ name: 'insert_table', arguments: {} });

    const malformed = parseProjectAiAgentEnvelope('{"message":"","toolCalls":[{"name":"insert_equation","arguments":"not-json"}]}');
    expect(malformed.toolCalls[0]).toMatchObject({ name: 'insert_equation', arguments: {} });
  });

  it('deduplicates the same tool call emitted in camelCase and snake_case arrays', () => {
    const call = { name: 'read_file', arguments: { file: 'main.tex' } };
    const parsed = parseProjectAiAgentEnvelope(JSON.stringify({
      message: '',
      toolCalls: [{ id: 'camel', ...call }],
      tool_calls: [{ id: 'snake', ...call }],
    }));
    expect(parsed.toolCalls).toHaveLength(1);
    expect(parsed.toolCalls[0]).toMatchObject({ name: 'read_file', arguments: { file: 'main.tex' } });
  });

  it('accepts a bare single tool-call object from small local models', () => {
    const parsed = parseProjectAiAgentEnvelope('{"name":"insert_table","arguments":{}}');
    expect(parsed.toolCalls).toHaveLength(1);
    expect(parsed.toolCalls[0]).toMatchObject({ name: 'insert_table', arguments: {} });
  });


  it('fails closed for truncated internal tool JSON instead of exposing it as prose', () => {
    const parsed = parseProjectAiAgentEnvelope('{"message":"Inserted","toolCalls":[{"name":"insert_equation","arguments":{"latex":"\\\\[x');
    expect(parsed).toEqual({ message: '', toolCalls: [], protocolError: true });
  });

  it('repairs complete local tool JSON with literal LaTeX backslashes and missing closing delimiters', () => {
    const parsed = parseProjectAiAgentEnvelope('{"message":"","toolCalls":[{"name":"insert_equation","arguments":{"latex":"\\alpha + \\beta"}}]');
    expect(parsed.protocolError).toBeUndefined();
    expect(parsed.toolCalls[0]).toMatchObject({
      name: 'insert_equation',
      arguments: { latex: '\\alpha + \\beta' },
    });
  });

  it('repairs JSON-string tool arguments locally without a second model turn', () => {
    const parsed = parseProjectAiAgentEnvelope(JSON.stringify({
      message: '',
      toolCalls: [{ name: 'insert_equation', arguments: '{"latex":"\\alpha + \\beta"}' }],
    }));
    expect(parsed.toolCalls[0]).toMatchObject({
      name: 'insert_equation',
      arguments: { latex: '\\alpha + \\beta' },
    });
  });

  it('fails closed for fenced malformed internal tool JSON while preserving ordinary prose', () => {
    const malformed = parseProjectAiAgentEnvelope('```json\n{"message":"Working","tool_calls":[\n```');
    expect(malformed.protocolError).toBe(true);
    expect(malformed.message).toBe('');
    expect(parseProjectAiAgentEnvelope('Use JSON only when integrating an API.').protocolError).toBeUndefined();
  });

  it('canonicalizes camelCase tool names and common file/LaTeX argument aliases', () => {
    const parsed = parseProjectAiAgentEnvelope(JSON.stringify({
      toolCalls: [{
        name: 'insertTable',
        args: { file_path: 'main.tex', latex_code: '\\begin{tabular}{c}x\\end{tabular}' },
      }],
    }));
    expect(parsed.toolCalls[0]).toMatchObject({
      name: 'insert_table',
      arguments: {
        file: 'main.tex',
        latex: '\\begin{tabular}{c}x\\end{tabular}',
      },
    });
  });

});


describe('provider adapters', () => {
  it('sends an OpenAI-compatible BYOK request directly and returns assistant text', async () => {
    let authorization = '';
    const fetchImpl: typeof fetch = async (_input, init) => {
      authorization = new Headers(init?.headers).get('Authorization') ?? '';
      return new Response(
        JSON.stringify({ choices: [{ message: { content: '{"message":"ok","toolCalls":[]}' } }] }),
        { status: 200, headers: { 'Content-Type': 'application/json' } },
      );
    };
    const adapter = createProjectAiProviderAdapter('openai-compatible', fetchImpl);
    const text = await adapter.complete({
      config: {
        provider: 'openai-compatible',
        apiKey: 'secret-key',
        model: 'test-model',
        baseUrl: 'https://provider.example/v1',
      },
      messages: [{ role: 'user', content: 'hello' }],
    });
    expect(authorization).toBe('Bearer secret-key');
    expect(text).toContain('"message":"ok"');
  });

  it('does not force a custom temperature on OpenAI-compatible models', async () => {
    let requestBody: Record<string, unknown> = {};
    const adapter = createProjectAiProviderAdapter('openai', async (_input, init) => {
      requestBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
      return new Response(
        JSON.stringify({ choices: [{ message: { content: 'ok' } }] }),
        { status: 200, headers: { 'Content-Type': 'application/json' } },
      );
    });

    await adapter.complete({
      config: { provider: 'openai', apiKey: 'k', model: 'reasoning-model' },
      messages: [{ role: 'user', content: 'review' }],
    });

    expect(requestBody).not.toHaveProperty('temperature');
  });

  it('invokes browser-native fetch with the global receiver so provider review cannot fail with Illegal invocation', async () => {
    let receiverOk = false;
    const receiverSensitiveFetch = function (this: typeof globalThis, _input: RequestInfo | URL, _init?: RequestInit): Promise<Response> {
      receiverOk = this === globalThis;
      if (!receiverOk) throw new TypeError("Failed to execute 'fetch' on 'Window': Illegal invocation");
      return Promise.resolve(new Response(
        JSON.stringify({ choices: [{ message: { content: 'ok' } }] }),
        { status: 200, headers: { 'Content-Type': 'application/json' } },
      ));
    } as typeof fetch;

    const adapter = createProjectAiProviderAdapter('openai', receiverSensitiveFetch);
    await expect(adapter.complete({
      config: { provider: 'openai', apiKey: 'k', model: 'test-model' },
      messages: [{ role: 'user', content: 'review' }],
    })).resolves.toBe('ok');
    expect(receiverOk).toBe(true);
  });

  it('encodes transient raster review images using each provider protocol without persisting them', async () => {
    const image = { mimeType: 'image/png' as const, dataBase64: 'iVBORw0KGgo=', name: 'figure.png' };

    let openAiBody: any;
    const openAi = createProjectAiProviderAdapter('openai', async (_input, init) => {
      openAiBody = JSON.parse(String(init?.body));
      return new Response(JSON.stringify({ choices: [{ message: { content: 'ok' } }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
    });
    await openAi.complete({ config: { provider: 'openai', apiKey: 'k', model: 'vision-model' }, messages: [{ role: 'user', content: 'inspect figure' }], images: [image] });
    expect(openAiBody.messages[0].content[1].image_url.url).toBe('data:image/png;base64,iVBORw0KGgo=');

    let anthropicBody: any;
    const anthropic = createProjectAiProviderAdapter('anthropic', async (_input, init) => {
      anthropicBody = JSON.parse(String(init?.body));
      return new Response(JSON.stringify({ content: [{ type: 'text', text: 'ok' }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
    });
    await anthropic.complete({ config: { provider: 'anthropic', apiKey: 'k', model: 'vision-model' }, messages: [{ role: 'user', content: 'inspect figure' }], images: [image] });
    expect(anthropicBody.messages[0].content[1]).toMatchObject({ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } });

    let geminiBody: any;
    const gemini = createProjectAiProviderAdapter('gemini', async (_input, init) => {
      geminiBody = JSON.parse(String(init?.body));
      return new Response(JSON.stringify({ candidates: [{ content: { parts: [{ text: 'ok' }] } }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
    });
    await gemini.complete({ config: { provider: 'gemini', apiKey: 'k', model: 'vision-model' }, messages: [{ role: 'user', content: 'inspect figure' }], images: [image] });
    expect(geminiBody.contents[0].parts[1]).toEqual({ inlineData: { mimeType: 'image/png', data: 'iVBORw0KGgo=' } });
  });
});


describe('AI endpoint security', () => {
  it('requires HTTPS for non-loopback custom endpoints', () => {
    expect(() => normalizeProjectAiBaseUrl('openai-compatible', 'http://provider.example/v1')).toThrow(/HTTPS/);
    expect(normalizeProjectAiBaseUrl('openai-compatible', 'https://provider.example/v1/')).toBe('https://provider.example/v1');
  });

  it('permits loopback HTTP for local development only', () => {
    expect(normalizeProjectAiBaseUrl('openai-compatible', 'http://127.0.0.1:11434/v1')).toBe('http://127.0.0.1:11434/v1');
  });

  it('rejects credentials, query strings, and fragments in provider base URLs', () => {
    expect(() => normalizeProjectAiBaseUrl('openai-compatible', 'https://user:pass@example.com/v1')).toThrow(/credentials/);
    expect(() => normalizeProjectAiBaseUrl('openai-compatible', 'https://example.com/v1?x=1')).toThrow(/query string/);
    expect(() => normalizeProjectAiBaseUrl('openai-compatible', 'https://example.com/v1#x')).toThrow(/fragment/);
  });
});


describe('local AI model registry', () => {
  it('ships explicit browser-compatible Gemma descriptors without embedding credentials', () => {
    expect(LOCAL_AI_MODELS.map((model) => model.id)).toEqual([
      'gemma-4-e2b-it-web',
      'gemma-4-e4b-it-web',
    ]);
    for (const model of LOCAL_AI_MODELS) {
      expect(model.runtime).toBe('LiteRT-LM');
      expect(model.modelUrl).toMatch(/^https:\/\/huggingface\.co\//u);
      expect(model.modelUrl).not.toMatch(/[?&](?:key|token|api_key)=/iu);
      expect(model.artifactRevision).toMatch(new RegExp(`^${model.id}-v\\d+$`, 'u'));
    }
  });
});


describe('Ollama local-service integration', () => {
  it('normalizes only loopback Ollama hosts and rejects API paths/private-network hosts', () => {
    expect(normalizeOllamaBaseUrl()).toBe(DEFAULT_OLLAMA_BASE_URL);
    expect(normalizeOllamaBaseUrl('http://localhost:11434/')).toBe('http://localhost:11434');
    expect(() => normalizeOllamaBaseUrl('http://192.168.1.20:11434')).toThrow(/localhost|127\.0\.0\.1/u);
    expect(() => normalizeOllamaBaseUrl('http://[::1]:11434')).toThrow(/localhost|127\.0\.0\.1/u);
    expect(() => normalizeOllamaBaseUrl('http://127.0.0.1:11434/v1')).toThrow(/must not include an API path/u);
    expect(() => normalizeOllamaBaseUrl('http://user:pass@127.0.0.1:11434')).toThrow(/credentials/u);
  });

  it('discovers the Ollama version and installed models without requiring a credential', async () => {
    const requested: string[] = [];
    const info = await fetchOllamaConnectionInfo('http://127.0.0.1:11434', async (input) => {
      const url = String(input);
      requested.push(url);
      if (url.endsWith('/api/version')) return new Response(JSON.stringify({ version: '0.12.3' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      return new Response(JSON.stringify({ models: [{ name: 'qwen3:8b', model: 'qwen3:8b', size: 5_200_000_000, details: { family: 'qwen3', parameter_size: '8.2B', quantization_level: 'Q4_K_M' } }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
    });
    expect(requested).toEqual(['http://127.0.0.1:11434/api/version', 'http://127.0.0.1:11434/api/tags']);
    expect(info.version).toBe('0.12.3');
    expect(info.models[0]).toMatchObject({ model: 'qwen3:8b', parameterSize: '8.2B', quantizationLevel: 'Q4_K_M' });
  });

  it('classifies remote/cloud-backed models returned through localhost', async () => {
    const info = await fetchOllamaConnectionInfo('http://127.0.0.1:11434', async (input) => {
      const url = String(input);
      if (url.endsWith('/api/version')) return new Response(JSON.stringify({ version: '0.12.3' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      return new Response(JSON.stringify({ models: [
        { name: 'qwen3:8b', model: 'qwen3:8b' },
        { name: 'gpt-oss:cloud', model: 'gpt-oss:cloud', remote_model: 'gpt-oss', remote_host: 'https://ollama.com' },
      ] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
    });
    expect(info.models[0]).toMatchObject({ model: 'qwen3:8b', remote: false });
    expect(info.models[1]).toMatchObject({ model: 'gpt-oss:cloud', remote: true, remoteModel: 'gpt-oss', remoteHost: 'https://ollama.com' });
  });

  it('inspects selected Ollama model capabilities through /api/show', async () => {
    let body = '';
    const info = await fetchOllamaModelInfo('http://127.0.0.1:11434', 'qwen3:8b', async (_input, init) => {
      body = String(init?.body ?? '');
      return new Response(JSON.stringify({ capabilities: ['completion', 'tools'], details: { family: 'qwen3' } }), { status: 200, headers: { 'Content-Type': 'application/json' } });
    });
    expect(JSON.parse(body)).toEqual({ model: 'qwen3:8b' });
    expect(info).toMatchObject({ model: 'qwen3:8b', remote: false, capabilities: ['completion', 'tools'] });
  });

  it('rejects a remote Ollama model before local-service inference', async () => {
    await expect(validateLocalOllamaModel('http://127.0.0.1:11434', 'cloud-model', async (input) => {
      const url = String(input);
      if (url.endsWith('/api/version')) return new Response(JSON.stringify({ version: '0.12.3' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      if (url.endsWith('/api/tags')) return new Response(JSON.stringify({ models: [{ name: 'cloud-model', model: 'cloud-model', remote_host: 'https://ollama.com' }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      throw new Error('show must not be called');
    })).rejects.toThrow(/remote\/cloud-backed/u);
  });


  it('allows an Ollama Cloud model when remote execution is explicitly permitted', async () => {
    const info = await validateOllamaModel('http://127.0.0.1:11434', 'cloud-model', async (input) => {
      const url = String(input);
      if (url.endsWith('/api/version')) return new Response(JSON.stringify({ version: '0.12.3' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      if (url.endsWith('/api/tags')) return new Response(JSON.stringify({ models: [{ name: 'cloud-model', model: 'cloud-model', remote_model: 'cloud-model', remote_host: 'https://ollama.com' }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      if (url.endsWith('/api/show')) return new Response(JSON.stringify({ remote_model: 'cloud-model', remote_host: 'https://ollama.com', capabilities: ['completion'] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      throw new Error(`unexpected ${url}`);
    }, undefined, { allowRemote: true });
    expect(info).toMatchObject({ model: 'cloud-model', remote: true, remoteHost: 'https://ollama.com' });
  });

  it('rejects an installed Ollama model that is not a completion model', async () => {
    await expect(validateLocalOllamaModel('http://127.0.0.1:11434', 'embedder', async (input) => {
      const url = String(input);
      if (url.endsWith('/api/version')) return new Response(JSON.stringify({ version: '0.12.3' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      if (url.endsWith('/api/tags')) return new Response(JSON.stringify({ models: [{ name: 'embedder', model: 'embedder' }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      if (url.endsWith('/api/show')) return new Response(JSON.stringify({ capabilities: ['embedding'] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      throw new Error(`unexpected ${url}`);
    })).rejects.toThrow(/completion capability/u);
  });

  it('turns browser/CORS failures into local Ollama setup guidance', async () => {
    await expect(fetchOllamaConnectionInfo('http://127.0.0.1:11434', async () => { throw new TypeError('Failed to fetch'); })).rejects.toThrow(/OLLAMA_ORIGINS/u);
  });
});



describe('provider capability contract', () => {
  it('exposes native SSE streaming for OpenAI-compatible providers', async () => {
    const adapter = createProjectAiProviderAdapter('openai-compatible', async (_input, init) => {
      expect(JSON.parse(String(init?.body ?? '{}'))).toMatchObject({ stream: true });
      return new Response([
        'data: {"choices":[{"delta":{"content":"o"}}]}',
        '',
        'data: {"choices":[{"delta":{"content":"k"}}]}',
        '',
        'data: [DONE]',
        '',
      ].join('\n'), { status: 200, headers: { 'content-type': 'text/event-stream' } });
    });
    expect(adapter.capabilities()).toEqual({ streaming: true, toolCalling: false, images: true, contextLimit: null });
    expect(adapter.supportsTools()).toBe(false);
    expect(adapter.supportsImages()).toBe(true);
    expect(adapter.contextLimit()).toBeUndefined();
    const chunks: string[] = [];
    for await (const chunk of adapter.stream({
      config: { provider: 'openai-compatible', model: 'local-test', apiKey: 'test-key', baseUrl: 'http://127.0.0.1:1234/v1' },
      messages: [{ role: 'user', content: 'hello' }],
    })) chunks.push(chunk.text);
    expect(chunks).toEqual(['o', 'k']);
  });
});
