import 'fake-indexeddb/auto';
import { LOCAL_AI_MODELS, type GenerationRequest, type LocalAiModelId, type LocalAiPlatform, type ProjectAiProviderMessage } from '@learnitive/project-ai';
import { createTestEnvironment } from '@learnitive/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ProjectAiService } from '../project-ai-service';

type CapturedGeneration = {
  modelId: LocalAiModelId;
  request: GenerationRequest;
};

function fakeLocalAiPlatform(options: {
  contextWindowTokens?: number;
  overflowFirstGeneration?: boolean;
  loadDelayMs?: number;
  generationDelayMs?: number;
} = {}): LocalAiPlatform & { dispose(): void; generations: CapturedGeneration[]; downloads: LocalAiModelId[]; loads: LocalAiModelId[] } {
  const generations: CapturedGeneration[] = [];
  const downloads: LocalAiModelId[] = [];
  const loads: LocalAiModelId[] = [];
  let generationCount = 0;
  return {
    models: LOCAL_AI_MODELS,
    generations,
    downloads,
    loads,
    capabilities: async () => ({ webGpu: true, opfs: true, supported: true }),
    modelState: async (modelId) => ({ modelId, status: 'installed', installedBytes: 1024 }),
    downloadModel: async (modelId, onProgress) => {
      downloads.push(modelId);
      const state = { modelId, status: 'installed' as const, installedBytes: 1024 };
      onProgress(state);
      return state;
    },
    removeModel: async () => undefined,
    createGenerationEngine: (modelId) => ({
      load: async () => {
        loads.push(modelId);
        if (options.loadDelayMs) await new Promise((resolve) => setTimeout(resolve, options.loadDelayMs));
      },
      unload: async () => undefined,
      contextWindowTokens: () => options.contextWindowTokens ?? 2048,
      async *generate(request, signal) {
        generations.push({ modelId, request });
        generationCount += 1;
        if (options.generationDelayMs) {
          await new Promise<void>((resolve, reject) => {
            const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, options.generationDelayMs);
            const abort = () => { clearTimeout(timer); reject(signal?.reason ?? new DOMException('Aborted', 'AbortError')); };
            if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true });
          });
        }
        signal?.throwIfAborted();
        if (options.overflowFirstGeneration && generationCount === 1) {
          throw new Error('Input token ids are too long: 3711 >= 2048');
        }
        yield { text: 'Local response' };
      },
    }),
    dispose() {},
  };
}

function configureLocal(projectAi: ProjectAiService, modelId: LocalAiModelId) {
  projectAi.configure({
    enabled: true,
    provider: 'local',
    localModelId: modelId,
    model: '',
    rememberApiKey: false,
    privacyMode: true,
    autoExplainErrors: false,
  });
}

async function clearCredentialDb(): Promise<void> {
  await new Promise<void>((resolve) => {
    const request = indexedDB.deleteDatabase('interactive-ai-credentials-v1');
    request.onsuccess = () => resolve();
    request.onerror = () => resolve();
    request.onblocked = () => resolve();
  });
}

describe('ProjectAiService local reliability', () => {
  beforeEach(async () => { localStorage.clear(); await clearCredentialDb(); });

  it('runs browser-local generation when the current provider is Disabled', async () => {
    const platform = fakeLocalAiPlatform();
    const env = createTestEnvironment({
      coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => platform }) },
    });
    const services = env.instantiateAll();
    await env.mountAll();
    services.projectAi.configure({
      enabled: false,
      provider: 'disabled',
      localModelId: LOCAL_AI_MODELS[0]!.id,
      model: '',
      rememberApiKey: false,
      privacyMode: false,
      autoExplainErrors: false,
    });

    const output = await services.projectAi.generateLocal([{ role: 'user', content: 'Review this line' }], new AbortController().signal);
    expect(output).toBe('Local response');
    expect(platform.generations).toHaveLength(1);
    env.controller.abort();
  });

  it('persists a selected local model immediately and uses that exact model for generation', async () => {
    const platform = fakeLocalAiPlatform();
    const env = createTestEnvironment({
      coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => platform }) },
    });
    const services = env.instantiateAll();
    await env.mountAll();
    configureLocal(services.projectAi, LOCAL_AI_MODELS[0]!.id);

    const selected = LOCAL_AI_MODELS[1]!.id;
    await services.projectAi.selectLocalModel(selected);
    expect(env.store.get(services.projectAi.$settings).localModelId).toBe(selected);
    expect(JSON.parse(localStorage.getItem('learnitive:ai:settings:v1') ?? '{}')).toMatchObject({ localModelId: selected });

    await services.projectAi.generateLocal([{ role: 'user', content: 'hello' }], new AbortController().signal);
    expect(platform.generations.at(-1)?.modelId).toBe(selected);
    env.controller.abort();
  });

  it('makes Download an authoritative model selection even without Save configuration', async () => {
    const platform = fakeLocalAiPlatform();
    const env = createTestEnvironment({
      coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => platform }) },
    });
    const services = env.instantiateAll();
    await env.mountAll();
    configureLocal(services.projectAi, LOCAL_AI_MODELS[0]!.id);

    const selected = LOCAL_AI_MODELS[1]!.id;
    await services.projectAi.downloadLocalModel(selected);

    expect(platform.downloads).toEqual([selected]);
    expect(env.store.get(services.projectAi.$settings).localModelId).toBe(selected);
    await services.projectAi.generateLocal([{ role: 'user', content: 'use downloaded model' }], new AbortController().signal);
    expect(platform.generations.at(-1)?.modelId).toBe(selected);
    env.controller.abort();
  });

  it('fits oversized local prompts to a 2K context while retaining system and current instruction', async () => {
    const platform = fakeLocalAiPlatform({ contextWindowTokens: 2048 });
    const env = createTestEnvironment({
      coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => platform }) },
    });
    const services = env.instantiateAll();
    await env.mountAll();
    configureLocal(services.projectAi, LOCAL_AI_MODELS[0]!.id);

    const messages: ProjectAiProviderMessage[] = [
      { role: 'system', content: `SYSTEM CONTRACT ${'S'.repeat(12_000)}` },
      { role: 'user', content: `OLD CONTEXT ${'O'.repeat(12_000)}` },
      { role: 'assistant', content: `OLD ANSWER ${'A'.repeat(8_000)}` },
      { role: 'user', content: 'CURRENT TASK: insert the requested equation safely.' },
    ];
    await services.projectAi.generateLocal(messages, new AbortController().signal);

    const request = platform.generations.at(-1)?.request;
    expect(request).toBeDefined();
    const fitted = request?.messages ?? [];
    expect(fitted[0]?.role).toBe('system');
    expect(fitted.at(-1)?.content).toContain('CURRENT TASK');
    expect(fitted.reduce((total, message) => total + message.content.length, 0)).toBeLessThan(4_200);
    expect(request?.maxTokens).toBeLessThanOrEqual(512);
    env.controller.abort();
  });

  it('retries one context overflow with a tighter emergency prompt', async () => {
    const platform = fakeLocalAiPlatform({ contextWindowTokens: 2048, overflowFirstGeneration: true });
    const env = createTestEnvironment({
      coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => platform }) },
    });
    const services = env.instantiateAll();
    await env.mountAll();
    configureLocal(services.projectAi, LOCAL_AI_MODELS[0]!.id);

    const response = await services.projectAi.generateLocal([
      { role: 'system', content: `SYSTEM ${'S'.repeat(10_000)}` },
      { role: 'assistant', content: `HISTORY ${'H'.repeat(10_000)}` },
      { role: 'user', content: `CURRENT TASK ${'Q'.repeat(5_000)}` },
    ], new AbortController().signal);

    expect(response).toBe('Local response');
    expect(platform.generations).toHaveLength(2);
    const firstChars = platform.generations[0]!.request.messages.reduce((sum, message) => sum + message.content.length, 0);
    const retryChars = platform.generations[1]!.request.messages.reduce((sum, message) => sum + message.content.length, 0);
    expect(retryChars).toBeLessThan(firstChars);
    env.controller.abort();
  });

  it('single-flights model prewarm and the first real Local AI request', async () => {
    const platform = fakeLocalAiPlatform({ loadDelayMs: 25 });
    const env = createTestEnvironment({
      coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => platform }) },
    });
    const services = env.instantiateAll();
    await env.mountAll();
    configureLocal(services.projectAi, LOCAL_AI_MODELS[0]!.id);

    const response = await services.projectAi.generateLocal(
      [{ role: 'user', content: 'first real request' }],
      new AbortController().signal,
      undefined,
      128,
      'interactive',
    );
    expect(response).toBe('Local response');
    await new Promise((resolve) => setTimeout(resolve, 40));
    expect(platform.loads).toEqual([LOCAL_AI_MODELS[0]!.id]);
    env.controller.abort();
  });

  it('runs explicit interactive requests ahead of queued background suggestions', async () => {
    const platform = fakeLocalAiPlatform({ generationDelayMs: 25 });
    const env = createTestEnvironment({
      coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => platform }) },
    });
    const services = env.instantiateAll();
    await env.mountAll();
    configureLocal(services.projectAi, LOCAL_AI_MODELS[0]!.id);

    const controller = new AbortController();
    const first = services.projectAi.generateLocal([{ role: 'user', content: 'normal-first' }], controller.signal, undefined, 96, 'normal');
    await vi.waitFor(() => expect(platform.generations).toHaveLength(1));
    const background = services.projectAi.generateLocal([{ role: 'user', content: 'background-second' }], controller.signal, undefined, 96, 'background');
    const interactive = services.projectAi.generateLocal([{ role: 'user', content: 'interactive-third' }], controller.signal, undefined, 96, 'interactive');

    await Promise.all([first, background, interactive]);
    expect(platform.generations.map((item) => item.request.messages.at(-1)?.content)).toEqual([
      'normal-first',
      'interactive-third',
      'background-second',
    ]);
    env.controller.abort();
  });

  it('drops an aborted queued background request without blocking later Local AI work', async () => {
    const platform = fakeLocalAiPlatform({ generationDelayMs: 25 });
    const env = createTestEnvironment({
      coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => platform }) },
    });
    const services = env.instantiateAll();
    await env.mountAll();
    configureLocal(services.projectAi, LOCAL_AI_MODELS[0]!.id);

    const firstController = new AbortController();
    const staleController = new AbortController();
    const first = services.projectAi.generateLocal([{ role: 'user', content: 'active' }], firstController.signal, undefined, 96, 'normal');
    await vi.waitFor(() => expect(platform.generations).toHaveLength(1));
    const stale = services.projectAi.generateLocal([{ role: 'user', content: 'stale-auto' }], staleController.signal, undefined, 96, 'background');
    staleController.abort();
    await expect(stale).rejects.toMatchObject({ name: 'AbortError' });
    const next = services.projectAi.generateLocal([{ role: 'user', content: 'manual-next' }], firstController.signal, undefined, 96, 'interactive');
    await Promise.all([first, next]);
    expect(platform.generations.map((item) => item.request.messages.at(-1)?.content)).toEqual(['active', 'manual-next']);
    env.controller.abort();
  });

  it('preempts only an active background suggestion when an interactive request arrives', async () => {
    const platform = fakeLocalAiPlatform({ generationDelayMs: 80 });
    const env = createTestEnvironment({
      coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => platform }) },
    });
    const services = env.instantiateAll();
    await env.mountAll();
    configureLocal(services.projectAi, LOCAL_AI_MODELS[0]!.id);

    const backgroundController = new AbortController();
    const interactiveController = new AbortController();
    const background = services.projectAi.generateLocal(
      [{ role: 'user', content: 'automatic-background' }],
      backgroundController.signal,
      undefined,
      96,
      'background',
    );
    await vi.waitFor(() => expect(platform.generations).toHaveLength(1));
    const interactive = services.projectAi.generateLocal(
      [{ role: 'user', content: 'explicit-user-request' }],
      interactiveController.signal,
      undefined,
      96,
      'interactive',
    );

    await expect(background).rejects.toMatchObject({ name: 'AbortError' });
    await expect(interactive).resolves.toBe('Local response');
    expect(platform.generations.map((item) => item.request.messages.at(-1)?.content)).toEqual([
      'automatic-background',
      'explicit-user-request',
    ]);
    env.controller.abort();
  });
});

it('keeps multiple BYOK provider credentials browser-local and never cross-reuses them', async () => {
  localStorage.clear();
  await clearCredentialDb();
  const platform = fakeLocalAiPlatform();
  const env = createTestEnvironment({
    coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => platform }) },
  });
  const services = env.instantiateAll();
  await env.mountAll();
  const common = { enabled: true, localModelId: LOCAL_AI_MODELS[0]!.id, rememberApiKey: false, privacyMode: false, autoExplainErrors: false } as const;

  services.projectAi.configure({ ...common, provider: 'openai', model: 'gpt-test', apiKey: 'openai-secret' });
  services.projectAi.configure({ ...common, provider: 'anthropic', model: 'claude-test', apiKey: 'anthropic-secret' });
  expect(services.projectAi.getEphemeralCloudProfiles().map((item) => item.provider).sort()).toEqual(['anthropic', 'openai']);
  expect(services.projectAi.getApiKeyForProvider('openai')).toBe('openai-secret');
  expect(services.projectAi.getApiKeyForProvider('anthropic')).toBe('anthropic-secret');
  expect(localStorage.getItem('learnitive:ai:remembered-api-key:v1')).toBeNull();
  expect(JSON.stringify([...Array(localStorage.length)].map((_, index) => localStorage.getItem(localStorage.key(index) ?? '')))).not.toContain('openai-secret');
  expect(JSON.stringify([...Array(localStorage.length)].map((_, index) => localStorage.getItem(localStorage.key(index) ?? '')))).not.toContain('anthropic-secret');
  await services.projectAi.flushCloudCredentialPersistence();

  services.projectAi.configure({ ...common, provider: 'openai', model: 'gpt-test' });
  expect(env.store.get(services.projectAi.$settings).hasApiKey).toBe(true);
  expect(services.projectAi.getApiKeyForDisplay()).toBe('openai-secret');
  services.projectAi.clearApiKey('openai');
  await services.projectAi.flushCloudCredentialPersistence();
  expect(services.projectAi.getApiKeyForProvider('openai')).toBe('');
  expect(services.projectAi.getApiKeyForProvider('anthropic')).toBe('anthropic-secret');
  env.controller.abort();
});


it('restores BYOK keys from IndexedDB after a hard-reload style service remount', async () => {
  localStorage.clear();
  await clearCredentialDb();
  const common = { enabled: true, localModelId: LOCAL_AI_MODELS[0]!.id, rememberApiKey: false, privacyMode: false, autoExplainErrors: false } as const;

  const env1 = createTestEnvironment({
    coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => fakeLocalAiPlatform() }) },
  });
  const first = env1.instantiateAll();
  await env1.mountAll();
  first.projectAi.configure({ ...common, provider: 'openai', model: 'gpt-persisted', apiKey: 'persist-through-reload' });
  await first.projectAi.flushCloudCredentialPersistence();
  expect(first.projectAi.getApiKeyForDisplay()).toBe('persist-through-reload');
  expect(JSON.stringify([...Array(localStorage.length)].map((_, index) => localStorage.getItem(localStorage.key(index) ?? '')))).not.toContain('persist-through-reload');
  env1.controller.abort();

  const env2 = createTestEnvironment({
    coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => fakeLocalAiPlatform() }) },
  });
  const second = env2.instantiateAll();
  await env2.mountAll();
  for (let attempt = 0; attempt < 40 && second.projectAi.getApiKeyForDisplay() !== 'persist-through-reload'; attempt += 1) {
    await new Promise((resolve) => setTimeout(resolve, 5));
  }
  expect(second.projectAi.getApiKeyForDisplay()).toBe('persist-through-reload');
  expect(env2.store.get(second.projectAi.$settings)).toMatchObject({ provider: 'openai', model: 'gpt-persisted', hasApiKey: true });
  expect(env2.store.get(second.projectAi.$credentialsHydrated)).toBe(true);

  second.projectAi.clearApiKey('openai');
  await second.projectAi.flushCloudCredentialPersistence();
  expect(second.projectAi.getApiKeyForDisplay()).toBe('');
  env2.controller.abort();

  const env3 = createTestEnvironment({
    coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => fakeLocalAiPlatform() }) },
  });
  const third = env3.instantiateAll();
  await env3.mountAll();
  await new Promise((resolve) => setTimeout(resolve, 20));
  expect(third.projectAi.getApiKeyForDisplay()).toBe('');
  expect(env3.store.get(third.projectAi.$settings).hasApiKey).toBe(false);
  env3.controller.abort();
});

describe('ProjectAiService Ollama local-service semantics', () => {
  beforeEach(async () => {
    localStorage.clear();
    await clearCredentialDb();
    vi.unstubAllGlobals();
  });

  it('uses Ollama in Privacy Mode without a user API key and never stores it as a BYOK profile', async () => {
    const calls: Array<{ url: string; init?: RequestInit }> = [];
    vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
      const url = String(input);
      calls.push({ url, ...(init ? { init } : {}) });
      if (url.endsWith('/api/version')) return new Response(JSON.stringify({ version: '0.11.10' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      if (url.endsWith('/api/tags')) return new Response(JSON.stringify({ models: [{ name: 'qwen3:8b', model: 'qwen3:8b', size: 5_000_000_000, details: { family: 'qwen3', parameter_size: '8.2B', quantization_level: 'Q4_K_M' } }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      if (url.endsWith('/api/show')) return new Response(JSON.stringify({ capabilities: ['completion'], details: { family: 'qwen3', parameter_size: '8.2B', quantization_level: 'Q4_K_M' } }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      if (url.endsWith('/v1/chat/completions')) return new Response(JSON.stringify({ choices: [{ message: { content: 'Ollama response' } }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      throw new Error(`Unexpected network request: ${url}`);
    }));

    const env = createTestEnvironment({
      coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => fakeLocalAiPlatform() }) },
    });
    const services = env.instantiateAll();
    await env.mountAll();
    services.projectAi.configure({
      enabled: true,
      provider: 'ollama',
      localModelId: LOCAL_AI_MODELS[0]!.id,
      model: 'qwen3:8b',
      baseUrl: 'http://127.0.0.1:11434',
      apiKey: 'must-not-be-stored',
      rememberApiKey: false,
      privacyMode: true,
      autoExplainErrors: false,
    });

    const ollama = await services.projectAi.refreshOllamaState();
    expect(ollama).toMatchObject({ status: 'connected', version: '0.11.10', baseUrl: 'http://127.0.0.1:11434' });
    expect(ollama.models[0]).toMatchObject({ model: 'qwen3:8b', family: 'qwen3', parameterSize: '8.2B', quantizationLevel: 'Q4_K_M' });
    expect(env.store.get(services.projectAi.$settings)).toMatchObject({ provider: 'ollama', hasApiKey: false, privacyMode: true });
    expect(services.projectAi.getApiKeyForProvider('ollama')).toBe('');
    expect(services.projectAi.getEphemeralCloudProfiles().some((profile) => profile.provider === 'openai-compatible' && profile.baseUrl?.includes('11434'))).toBe(false);

    await expect(services.projectAi.generateConfigured([{ role: 'user', content: 'Review this paragraph' }], new AbortController().signal)).resolves.toBe('Ollama response');
    expect(calls.some((call) => call.url === 'http://127.0.0.1:11434/v1/chat/completions')).toBe(true);
    expect(calls.every((call) => call.url.startsWith('http://127.0.0.1:11434/'))).toBe(true);
    env.controller.abort();
  });

  it('allows an Ollama Cloud-backed model when Privacy Mode is off', async () => {
    const calls: string[] = [];
    vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
      const url = String(input);
      calls.push(url);
      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: 'gpt-oss:cloud', model: 'gpt-oss:cloud', remote_model: 'gpt-oss', remote_host: 'https://ollama.com' }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      if (url.endsWith('/api/show')) return new Response(JSON.stringify({ remote_model: 'gpt-oss', remote_host: 'https://ollama.com', capabilities: ['completion'] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      if (url.endsWith('/v1/chat/completions')) return new Response(JSON.stringify({ choices: [{ message: { content: 'Ollama Cloud response' } }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      throw new Error(`Unexpected network request: ${url}`);
    }));

    const env = createTestEnvironment({
      coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => fakeLocalAiPlatform() }) },
    });
    const services = env.instantiateAll();
    await env.mountAll();
    services.projectAi.configure({
      enabled: true,
      provider: 'ollama',
      localModelId: LOCAL_AI_MODELS[0]!.id,
      model: 'gpt-oss:cloud',
      baseUrl: 'http://localhost:11434',
      rememberApiKey: false,
      privacyMode: false,
      autoExplainErrors: false,
    });
    await services.projectAi.refreshOllamaState();
    await expect(services.projectAi.generateConfigured([{ role: 'user', content: 'hello cloud' }], new AbortController().signal)).resolves.toBe('Ollama Cloud response');
    expect(calls.some((url) => url.endsWith('/v1/chat/completions'))).toBe(true);
    env.controller.abort();
  });

  it('blocks an Ollama Cloud-backed model when Privacy Mode is on', async () => {
    const calls: string[] = [];
    vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
      const url = String(input);
      calls.push(url);
      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: 'gpt-oss:cloud', model: 'gpt-oss:cloud', remote_model: 'gpt-oss', remote_host: 'https://ollama.com' }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      if (url.endsWith('/api/show')) return new Response(JSON.stringify({ remote_model: 'gpt-oss', remote_host: 'https://ollama.com', capabilities: ['completion'] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      if (url.endsWith('/v1/chat/completions')) return new Response(JSON.stringify({ choices: [{ message: { content: 'must not run' } }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      throw new Error(`Unexpected network request: ${url}`);
    }));

    const env = createTestEnvironment({
      coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => fakeLocalAiPlatform() }) },
    });
    const services = env.instantiateAll();
    await env.mountAll();
    services.projectAi.configure({
      enabled: true,
      provider: 'ollama',
      localModelId: LOCAL_AI_MODELS[0]!.id,
      model: 'gpt-oss:cloud',
      baseUrl: 'http://localhost:11434',
      rememberApiKey: false,
      privacyMode: true,
      autoExplainErrors: false,
    });
    await services.projectAi.refreshOllamaState();
    await expect(services.projectAi.generateConfigured([{ role: 'user', content: 'hello cloud' }], new AbortController().signal)).rejects.toThrow(/Privacy Mode blocks Ollama Cloud/u);
    expect(calls.some((url) => url.endsWith('/v1/chat/completions'))).toBe(false);
    env.controller.abort();
  });

  it('automatically falls back to the last configured BYOK AI provider when Ollama stops', async () => {
    const calls: string[] = [];
    vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
      const url = String(input);
      calls.push(url);
      if (url.startsWith('http://localhost:11434/')) throw new TypeError('connection refused');
      if (url === 'https://api.openai.com/v1/chat/completions') {
        return new Response(JSON.stringify({ choices: [{ message: { content: 'BYOK fallback response' } }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      }
      throw new Error(`Unexpected network request: ${url}`);
    }));

    const env = createTestEnvironment({
      coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => fakeLocalAiPlatform() }) },
    });
    const services = env.instantiateAll();
    await env.mountAll();
    services.projectAi.configure({
      enabled: true,
      provider: 'openai',
      localModelId: LOCAL_AI_MODELS[0]!.id,
      model: 'gpt-4o-mini',
      apiKey: 'test-byok-key',
      rememberApiKey: false,
      privacyMode: false,
      autoExplainErrors: false,
    });
    services.projectAi.configure({
      enabled: true,
      provider: 'ollama',
      localModelId: LOCAL_AI_MODELS[0]!.id,
      model: 'qwen3:8b',
      baseUrl: 'http://localhost:11434',
      rememberApiKey: false,
      privacyMode: false,
      autoExplainErrors: false,
    });

    await expect(services.projectAi.generateConfigured([{ role: 'user', content: 'hello' }], new AbortController().signal)).resolves.toBe('BYOK fallback response');
    expect(calls.some((url) => url.startsWith('http://localhost:11434/'))).toBe(true);
    expect(calls).toContain('https://api.openai.com/v1/chat/completions');
    expect(env.store.get(services.projectAi.$ollama).fallback).toMatchObject({ provider: 'openai', model: 'gpt-4o-mini' });
    env.controller.abort();
  });

  it('keeps Privacy Mode fail-closed when Ollama stops instead of sending manuscript context to BYOK AI', async () => {
    const calls: string[] = [];
    vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
      const url = String(input);
      calls.push(url);
      if (url.startsWith('http://localhost:11434/')) throw new TypeError('connection refused');
      if (url === 'https://api.openai.com/v1/chat/completions') {
        return new Response(JSON.stringify({ choices: [{ message: { content: 'must not be used' } }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
      }
      throw new Error(`Unexpected network request: ${url}`);
    }));

    const env = createTestEnvironment({
      coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => fakeLocalAiPlatform() }) },
    });
    const services = env.instantiateAll();
    await env.mountAll();
    services.projectAi.configure({
      enabled: true,
      provider: 'openai',
      localModelId: LOCAL_AI_MODELS[0]!.id,
      model: 'gpt-4o-mini',
      apiKey: 'test-byok-key',
      rememberApiKey: false,
      privacyMode: false,
      autoExplainErrors: false,
    });
    services.projectAi.configure({
      enabled: true,
      provider: 'ollama',
      localModelId: LOCAL_AI_MODELS[0]!.id,
      model: 'qwen3:8b',
      baseUrl: 'http://localhost:11434',
      rememberApiKey: false,
      privacyMode: true,
      autoExplainErrors: false,
    });

    await expect(services.projectAi.generateConfigured([{ role: 'user', content: 'hello' }], new AbortController().signal)).rejects.toThrow(/Privacy Mode prevents.*cloud fallback/u);
    expect(calls.some((url) => url === 'https://api.openai.com/v1/chat/completions')).toBe(false);
    env.controller.abort();
  });

  it('rejects non-loopback Ollama hosts before any request can be made', async () => {
    const env = createTestEnvironment({
      coreConfigOverrides: { projectAi: () => ({ createLocalAiPlatform: () => fakeLocalAiPlatform() }) },
    });
    const services = env.instantiateAll();
    await env.mountAll();
    expect(() => services.projectAi.configure({
      enabled: true,
      provider: 'ollama',
      localModelId: LOCAL_AI_MODELS[0]!.id,
      model: 'qwen3:8b',
      baseUrl: 'http://192.168.1.25:11434',
      rememberApiKey: false,
      privacyMode: true,
      autoExplainErrors: false,
    })).toThrow(/localhost|127\.0\.0\.1/u);
    env.controller.abort();
  });
});
