import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';
import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js';

describe('LocalOllamaProvider', () => {
  let mock: MockAgent;
  let original: ReturnType<typeof getGlobalDispatcher>;

  beforeEach(() => {
    original = getGlobalDispatcher();
    mock = new MockAgent();
    mock.disableNetConnect();
    setGlobalDispatcher(mock);
  });

  afterEach(async () => {
    setGlobalDispatcher(original);
    await mock.close();
  });

  it('generate parses Ollama JSON', async () => {
    mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
      response: JSON.stringify({ title: '회의', summary: '첫\n둘\n셋', tags: ['api'] })
    });
    const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
    expect(r.title).toBe('회의');
  });

  it('generate passes vocab into prompt body', async () => {
    let capturedBody: string = '';
    mock.get('http://localhost:11434').intercept({
      path: '/api/generate', method: 'POST'
    }).reply((opts) => {
      capturedBody = opts.body as string;
      return { statusCode: 200, data: JSON.stringify({
        response: JSON.stringify({ title: '회의', summary: 'a\nb\nc', tags: ['design'] })
      }) };
    });
    await new LocalOllamaProvider().generate({
      text: 'x', todayKst: '2026-05-02', dueDateCandidates: [],
      vocab: ['design', 'meeting']
    });
    const parsed = JSON.parse(capturedBody) as { prompt: string };
    expect(parsed.prompt).toContain('design, meeting');
    expect(parsed.prompt).toContain('Prefer reusing');
  });

  it('generate throws on non-JSON', async () => {
    mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
      response: 'not json'
    });
    await expect(
      new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] })
    ).rejects.toThrow(/json/i);
  });

  it('generate aborts on timeout', async () => {
    mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply((async () => {
      await new Promise<void>((r) => setTimeout(r, 500));
      return { statusCode: 200, data: '{}' };
    }) as never);
    await expect(
      new LocalOllamaProvider({ timeoutMs: 50 }).generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] })
    ).rejects.toThrow();
  }, 2000);

  it('healthCheck ok=true when model present', async () => {
    mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }).reply(200, {
      models: [{ name: 'gemma4:e4b' }]
    });
    const h = await new LocalOllamaProvider().healthCheck();
    expect(h.ok).toBe(true);
    expect(h.model).toBe('gemma4:e4b');
  });

  it('healthCheck ok=false when missing', async () => {
    mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }).reply(200, {
      models: [{ name: 'other:latest' }]
    });
    const h = await new LocalOllamaProvider().healthCheck();
    expect(h.ok).toBe(false);
    expect(h.reason).toMatch(/gemma4:e4b/);
  });

  it('healthCheck ok=false on connection error', async () => {
    mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' })
      .replyWithError(new Error('ECONNREFUSED'));
    const h = await new LocalOllamaProvider().healthCheck();
    expect(h.ok).toBe(false);
    expect(h.reason).toMatch(/connect|refused|unreachable/i);
  });

  it('abort() cancels in-flight generate (rejects with AbortError)', async () => {
    mock.get('http://localhost:11434').intercept({
      path: '/api/generate', method: 'POST'
    }).reply((async () => {
      await new Promise<void>((r) => setTimeout(r, 5000)); // long-running
      return { statusCode: 200, data: '{}' };
    }) as never);
    const provider = new LocalOllamaProvider({ timeoutMs: 30_000 });
    const generatePromise = provider.generate({
      text: 'x', todayKst: '2026-05-04', dueDateCandidates: []
    });
    setTimeout(() => provider.abort(), 50);
    await expect(generatePromise).rejects.toThrow();
  });

  it('constructor uses provided model param (not just default)', () => {
    const provider = new LocalOllamaProvider({ model: 'gemma4:26b' });
    expect(provider.name).toBe('local-ollama/gemma4:26b');
  });

  describe('healthCheck PII reason masking', () => {
    it('classifies ECONNREFUSED as network', async () => {
      mock.get('http://192.168.1.5:11434').intercept({ path: '/api/tags', method: 'GET' })
        .replyWithError(new Error('connect ECONNREFUSED 192.168.1.5:11434'));
      const provider = new LocalOllamaProvider({ endpoint: 'http://192.168.1.5:11434' });
      const h = await provider.healthCheck();
      expect(h.ok).toBe(false);
      expect(h.reason).toBe('unreachable:network');
      expect(h.reason).not.toContain('192.168.1.5');
    });

    it('classifies AbortError/timeout as timeout', async () => {
      mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' })
        .replyWithError(new Error('The operation was aborted due to timeout'));
      const h = await new LocalOllamaProvider().healthCheck();
      expect(h.ok).toBe(false);
      expect(h.reason).toBe('unreachable:timeout');
    });

    it('classifies ENOTFOUND as dns', async () => {
      mock.get('http://nonexistent.local:11434').intercept({ path: '/api/tags', method: 'GET' })
        .replyWithError(new Error('getaddrinfo ENOTFOUND nonexistent.local'));
      const provider = new LocalOllamaProvider({ endpoint: 'http://nonexistent.local:11434' });
      const h = await provider.healthCheck();
      expect(h.ok).toBe(false);
      expect(h.reason).toBe('unreachable:dns');
      expect(h.reason).not.toContain('nonexistent.local');
    });

    it('falls back to other for unknown errors', async () => {
      mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' })
        .replyWithError(new Error('something weird happened'));
      const h = await new LocalOllamaProvider().healthCheck();
      expect(h.ok).toBe(false);
      expect(h.reason).toBe('unreachable:other');
    });
  });

  describe('vision path (v0.3.1 Cut F)', () => {
    it('visionModel + images → body.images + model=visionModel + buildVisionPrompt', async () => {
      let capturedBody: string = '';
      mock.get('http://x').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => {
        capturedBody = opts.body as string;
        return { statusCode: 200, data: JSON.stringify({
          response: JSON.stringify({ title: '비전테스트', summary: 'a\nb\nc', tags: [], due_date: null })
        }) };
      });
      const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
      await provider.generate(
        { text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [], images: [{ base64: 'AAAA', mime: 'image/png' }] },
        { visionModel: 'gemma3:12b-vision' }
      );
      const parsed = JSON.parse(capturedBody) as { model: string; prompt: string; images?: string[] };
      expect(parsed.model).toBe('gemma3:12b-vision');
      expect(parsed.prompt).toContain('이미지');
      expect(parsed.images).toEqual(['AAAA']);
    });

    it('visionModel 있어도 images 없으면 text-only (model = this.model, no body.images)', async () => {
      let capturedBody: string = '';
      mock.get('http://x').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => {
        capturedBody = opts.body as string;
        return { statusCode: 200, data: JSON.stringify({
          response: JSON.stringify({ title: '텍스트전용', summary: 'a\nb\nc', tags: [], due_date: null })
        }) };
      });
      const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
      await provider.generate(
        { text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] },
        { visionModel: 'gemma3:12b-vision' }
      );
      const parsed = JSON.parse(capturedBody) as { model: string; images?: string[] };
      expect(parsed.model).toBe('gemma4:e4b');
      expect(parsed.images).toBeUndefined();
    });

    it('opts 미전달 → 기존 text-only (회귀)', async () => {
      let capturedBody: string = '';
      mock.get('http://x').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => {
        capturedBody = opts.body as string;
        return { statusCode: 200, data: JSON.stringify({
          response: JSON.stringify({ title: '기본텍스트', summary: 'a\nb\nc', tags: [], due_date: null })
        }) };
      });
      const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
      await provider.generate({ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] });
      const parsed = JSON.parse(capturedBody) as { model: string; images?: string[] };
      expect(parsed.model).toBe('gemma4:e4b');
      expect(parsed.images).toBeUndefined();
    });
  });
});
