import { describe, it, expect, beforeEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';
import { AiWorker } from '@main/ai/AiWorker.js';
import type { AiTelemetryEmitter } from '@main/ai/AiWorker.js';
import type { InferenceProvider } from '@main/ai/InferenceProvider.js';
import type { AiResponse } from '@main/ai/schema.js';
import { ProviderHolder } from '@main/ai/ProviderHolder.js';

type EmittedEvent = { kind: string; payload: unknown };

function makeProvider(overrides: Partial<InferenceProvider> = {}): InferenceProvider {
  return {
    name: 'mock',
    generate: vi.fn(async (): Promise<AiResponse> => ({
      title: '제목', summary: 'a\nb\nc', tags: ['tag'], dueDate: null
    })),
    healthCheck: vi.fn(async () => ({ ok: true })),
    ...overrides
  } as InferenceProvider;
}

describe('AiWorker', () => {
  let db: Database.Database;
  let repo: NoteRepository;

  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('processes a pending job and marks done', async () => {
    const { id } = repo.create({ rawText: 'x' });
    const updates: string[] = [];
    const w = new AiWorker(repo, new ProviderHolder(makeProvider()), {
      backoffsMs: [0, 0, 0],
      onUpdate: (note) => updates.push(note.aiStatus)
    });
    await w.enqueue(id);
    await w.drain();
    expect(repo.findById(id)?.aiStatus).toBe('done');
    expect(updates).toContain('done');
  });

  it('retries 3 times then marks failed', async () => {
    const { id } = repo.create({ rawText: 'x' });
    const provider = makeProvider({
      generate: vi.fn(async () => { throw new Error('boom'); })
    });
    const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] });
    await w.enqueue(id);
    await w.drain();
    const note = repo.findById(id)!;
    expect(note.aiStatus).toBe('failed');
    expect(note.aiError).toContain('boom');
    expect(provider.generate).toHaveBeenCalledTimes(3);
  });

  it('loadFromDb re-queues all pending', async () => {
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    const w = new AiWorker(repo, new ProviderHolder(makeProvider()), { backoffsMs: [0, 0, 0] });
    await w.loadFromDb();
    await w.drain();
    expect(repo.findById(a)?.aiStatus).toBe('done');
    expect(repo.findById(b)?.aiStatus).toBe('done');
  });

  it('processes sequentially (concurrency 1)', async () => {
    const ids = [repo.create({ rawText: 'a' }).id, repo.create({ rawText: 'b' }).id];
    let running = 0;
    let max = 0;
    const provider = makeProvider({
      generate: vi.fn(async () => {
        running++; max = Math.max(max, running);
        await new Promise((r) => setTimeout(r, 10));
        running--;
        return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null };
      })
    });
    const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] });
    for (const id of ids) await w.enqueue(id);
    await w.drain();
    expect(max).toBe(1);
  });

  it('AI dueDate wins over rule candidates (flow reversed from F1)', async () => {
    const provider = {
      name: 'mock',
      generate: async () => ({
        title: '내일',
        summary: 'a\nb\nc',
        tags: [],
        dueDate: '2026-12-31'  // AI says far future, rule has '내일'
      }),
      healthCheck: async () => ({ ok: true })
    } as any;
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0],
      now: () => new Date('2026-04-26T00:00:00.000Z')
    });
    const { id } = repo.create({ rawText: '내일 회의' });
    await w.enqueue(id);
    await w.drain();
    const note = repo.findById(id)!;
    expect(note.dueDate).toBe('2026-12-31'); // AI value, not rule
  });

  it('rule null + AI value → AI used', async () => {
    const provider = {
      name: 'mock',
      generate: async () => ({
        title: '월말 마감',
        summary: 'a\nb\nc',
        tags: [],
        dueDate: '2026-04-30' // AI resolves "월말"
      }),
      healthCheck: async () => ({ ok: true })
    } as any;
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0],
      now: () => new Date('2026-04-26T00:00:00.000Z')
    });
    const { id } = repo.create({ rawText: '월말 마감' });
    await w.enqueue(id);
    await w.drain();
    const note = repo.findById(id)!;
    expect(note.dueDate).toBe('2026-04-30');
  });

  it('rule null + AI null → null', async () => {
    const provider = {
      name: 'mock',
      generate: async () => ({
        title: '아무 메모',
        summary: 'a\nb\nc',
        tags: [],
        dueDate: null
      }),
      healthCheck: async () => ({ ok: true })
    } as any;
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0],
      now: () => new Date('2026-04-26T00:00:00.000Z')
    });
    const { id } = repo.create({ rawText: '아무 메모' });
    await w.enqueue(id);
    await w.drain();
    const note = repo.findById(id)!;
    expect(note.dueDate).toBeNull();
  });

  it('passes todayKst to provider.generate', async () => {
    const seen: any = {};
    const provider = {
      name: 'mock',
      generate: async (input: any) => {
        seen.todayKst = input.todayKst;
        seen.dueDateCandidates = input.dueDateCandidates;
        return { title: '메모', summary: 'a\nb\nc', tags: [], dueDate: null };
      },
      healthCheck: async () => ({ ok: true })
    } as any;
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0],
      now: () => new Date('2026-04-26T15:00:00.000Z') // 04-27 00:00 KST
    });
    const { id } = repo.create({ rawText: 'x' });
    await w.enqueue(id);
    await w.drain();
    expect(seen.todayKst).toBe('2026-04-27');
    expect(Array.isArray(seen.dueDateCandidates)).toBe(true);
    expect(seen.dueDateCandidates.length).toBe(0);
  });

  it('passes parseAllCandidates result to provider.generate as dueDateCandidates', async () => {
    let captured: any = null;
    const provider = {
      name: 'mock',
      generate: async (input: any) => {
        captured = input;
        return { title: '내일', summary: 'a\nb\nc', tags: [], dueDate: null };
      },
      healthCheck: async () => ({ ok: true })
    } as any;
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0],
      now: () => new Date('2026-04-26T00:00:00.000Z')
    });
    const { id } = repo.create({ rawText: '내일 모레 회의' });
    await w.enqueue(id);
    await w.drain();
    expect(captured.dueDateCandidates).toBeDefined();
    expect(Array.isArray(captured.dueDateCandidates)).toBe(true);
    expect(captured.dueDateCandidates.length).toBe(2); // 내일 + 모레
  });
});

describe('AiWorker telemetry emit', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  let events: Array<{ kind: string; payload: { noteId?: string; durationMs?: number; reason?: string; attempts?: number; tagId?: number; vocabSize?: number } }>;
  const collectingTelemetry: AiTelemetryEmitter = {
    emit: async (ev) => {
      events.push(ev as typeof events[number]);
    }
  };

  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
    events = [];
  });

  it('emits ai_succeeded with durationMs/attempts on success', async () => {
    const { id } = repo.create({ rawText: '수요일 회의 메모' });
    const w = new AiWorker(repo, new ProviderHolder(makeProvider()), {
      backoffsMs: [0, 0, 0],
      telemetry: collectingTelemetry
    });
    await w.enqueue(id);
    await w.drain();
    const succeeded = events.find((e) => e.kind === 'ai_succeeded');
    expect(succeeded).toBeDefined();
    expect(succeeded!.payload.noteId).toBe(id);
    // attempts = 시도한 횟수 (count, 1-based). 첫 시도 성공이므로 1.
    // 회차 1 review (PR #13) 의 비대칭 의미 통일 결과 — 실패 경로의 `attempt + 1` 과 동일 의미.
    expect(succeeded!.payload.attempts).toBe(1);
    expect(succeeded!.payload.durationMs).toBeGreaterThanOrEqual(0);
  });

  it('unreachable error — ai_failed NOT emitted (infinite retry, no markAiFailed)', async () => {
    const { id } = repo.create({ rawText: '메모' });
    const provider = makeProvider({
      generate: vi.fn(async () => { throw new Error('fetch failed: ECONNREFUSED 11434'); })
    });
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0, 0, 0],
      unreachableBackoffsMs: [10, 10, 10, 10, 10, 10],
      telemetry: collectingTelemetry
    });
    await w.enqueue(id);
    await new Promise((r) => setTimeout(r, 200));
    const failed = events.find((e) => e.kind === 'ai_failed');
    expect(failed).toBeUndefined();
    expect(repo.findById(id)!.aiStatus).toBe('pending');
  });

  it('emits ai_failed with reason=schema on zod failure', async () => {
    const { id } = repo.create({ rawText: '메모' });
    const { ZodError } = await import('zod');
    const provider = makeProvider({
      generate: vi.fn(async () => { throw new ZodError([]); })
    });
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0, 0, 0],
      telemetry: collectingTelemetry
    });
    await w.enqueue(id);
    await w.drain();
    const failed = events.find((e) => e.kind === 'ai_failed');
    expect(failed).toBeDefined();
    expect(failed!.payload.reason).toBe('schema');
  });

  it('emits ai_failed with reason=other on unrecognized error', async () => {
    const { id } = repo.create({ rawText: '메모' });
    const provider = makeProvider({
      generate: vi.fn(async () => { throw new Error('mystery'); })
    });
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0, 0, 0],
      telemetry: collectingTelemetry
    });
    await w.enqueue(id);
    await w.drain();
    const failed = events.find((e) => e.kind === 'ai_failed');
    expect(failed).toBeDefined();
    expect(failed!.payload.reason).toBe('other');
  });
});

describe('AiWorker — deletedAt guard (v0.2.3 #4)', () => {
  let db: Database.Database;
  let repo: NoteRepository;

  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('skips notes with deleted_at IS NOT NULL — provider.generate not called', async () => {
    const { id } = repo.create({ rawText: 'x' });
    // 먼저 trash — pending_jobs cleanup 됨
    repo.trash(id, '2026-05-01T12:00:00.000Z');
    // 강제로 pending_jobs row 다시 삽입 (race 시뮬레이션 — AiWorker 가 이미 dequeue 한 상태 흉내)
    db.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, '2026-05-01T12:00:00.000Z');
    const generate = vi.fn();
    const provider = makeProvider({ generate: generate as any });
    const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] });
    await w.loadFromDb();
    await w.drain();
    expect(generate).not.toHaveBeenCalled();
    expect(repo.findById(id)!.aiStatus).toBe('pending');
  });
});

describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => {
  let db: Database.Database;
  let repo: NoteRepository;

  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('unreachable — markAiFailed 안 호출, attempts 증가 안 함', async () => {
    const provider = makeProvider({
      generate: vi.fn(async () => { throw new Error('ECONNREFUSED'); })
    });
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0, 30_000, 120_000],
      unreachableBackoffsMs: [10, 10, 10, 10, 10, 10]
    });
    const { id } = repo.create({ rawText: 'x' });
    await w.enqueue(id);
    // 무한 retry — drain() 은 끝나지 않음. 짧게 대기 후 검증.
    await new Promise((r) => setTimeout(r, 200));
    expect(repo.findById(id)!.aiStatus).toBe('pending');
    expect(provider.generate).toHaveBeenCalled();
    expect((provider.generate as any).mock.calls.length).toBeGreaterThanOrEqual(2);
    const job = repo.getAllPendingJobs().find((j) => j.noteId === id)!;
    expect(job.attempts).toBe(0);
  });

  it('timeout — unreachable 동일 (Q2=A)', async () => {
    const provider = makeProvider({
      generate: vi.fn(async () => { throw new Error('Request timeout'); })
    });
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0, 30_000, 120_000],
      unreachableBackoffsMs: [10, 10, 10, 10, 10, 10]
    });
    const { id } = repo.create({ rawText: 'x' });
    await w.enqueue(id);
    await new Promise((r) => setTimeout(r, 200));
    expect(repo.findById(id)!.aiStatus).toBe('pending');
    expect((provider.generate as any).mock.calls.length).toBeGreaterThanOrEqual(2);
  });

  it('schema fail max 3 — markAiFailed + ai_failed emit (reason=schema)', async () => {
    const { ZodError } = await import('zod');
    const provider = makeProvider({
      generate: vi.fn(async () => {
        throw new ZodError([{ code: 'custom', message: 'bad', path: [] } as any]);
      })
    });
    const events: any[] = [];
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0, 0, 0],
      telemetry: { emit: async (e) => { events.push(e); } }
    });
    const { id } = repo.create({ rawText: 'x' });
    await w.enqueue(id);
    await w.drain();
    expect(repo.findById(id)!.aiStatus).toBe('failed');
    expect((provider.generate as any).mock.calls.length).toBe(3);
    const failed = events.find((e) => e.kind === 'ai_failed');
    expect(failed).toBeDefined();
    expect(failed.payload.reason).toBe('schema');
  });

  it('other fail max 3 — markAiFailed + ai_failed emit (reason=other)', async () => {
    const provider = makeProvider({
      generate: vi.fn(async () => { throw new Error('something weird'); })
    });
    const events: any[] = [];
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0, 0, 0],
      telemetry: { emit: async (e) => { events.push(e); } }
    });
    const { id } = repo.create({ rawText: 'x' });
    await w.enqueue(id);
    await w.drain();
    expect(repo.findById(id)!.aiStatus).toBe('failed');
    const failed = events.find((e) => e.kind === 'ai_failed');
    expect(failed.payload.reason).toBe('other');
  });

  it('unreachable backoff schedule — nextBackoffMs(step) cap at index 5 (15분)', async () => {
    const w = new AiWorker(repo, new ProviderHolder(makeProvider()), {
      backoffsMs: [0, 30_000, 120_000],
      unreachableBackoffsMs: [30_000, 60_000, 120_000, 240_000, 480_000, 900_000]
    });
    expect((w as any).nextBackoffMs(0)).toBe(30_000);
    expect((w as any).nextBackoffMs(2)).toBe(120_000);
    expect((w as any).nextBackoffMs(5)).toBe(900_000);
    expect((w as any).nextBackoffMs(10)).toBe(900_000);  // cap
  });

  it('success 후 unreachableBackoffStep reset', async () => {
    let callCount = 0;
    const provider = makeProvider({
      generate: vi.fn(async (): Promise<AiResponse> => {
        callCount += 1;
        if (callCount <= 2) throw new Error('ECONNREFUSED');
        return { title: 't', summary: 's', tags: [], dueDate: null };
      })
    });
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0, 0, 0],
      unreachableBackoffsMs: [10, 10, 10, 10, 10, 10]
    });
    const { id } = repo.create({ rawText: 'x' });
    await w.enqueue(id);
    await w.drain();
    expect(repo.findById(id)!.aiStatus).toBe('done');
    expect(callCount).toBe(3);
    expect((w as any).unreachableBackoffStep).toBe(0);
  });
});

describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
  let db: Database.Database;
  let repo: NoteRepository;

  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('fetches vocab and passes to provider.generate', async () => {
    // Pre-seed 1 note with tag 'design' so vocab non-empty
    const seed = repo.create({ rawText: 'seed' }).id;
    repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });

    const { id } = repo.create({ rawText: 'x' });
    const generateMock = vi.fn(async () => ({
      title: '제목', summary: 'a\nb\nc', tags: ['design'], dueDate: null
    }));
    const w = new AiWorker(repo, new ProviderHolder(makeProvider({ generate: generateMock })), {
      backoffsMs: [0, 0, 0]
    });
    await w.enqueue(id);
    await w.drain();
    expect(generateMock).toHaveBeenCalledWith(
      expect.objectContaining({ vocab: expect.arrayContaining(['design']) }),
      expect.anything()
    );
  });

  it('emits tag_vocab_hit for vocab tags + tag_vocab_miss for new tags', async () => {
    // Pre-seed: 'design' in vocab
    const seed = repo.create({ rawText: 'seed' }).id;
    repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });

    const { id } = repo.create({ rawText: 'x' });
    const provider = makeProvider({
      generate: vi.fn(async () => ({
        title: 't', summary: 'a\nb\nc',
        tags: ['design', 'newtag'],  // 1 hit + 1 miss
        dueDate: null
      }))
    });
    const emits: EmittedEvent[] = [];
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0, 0, 0],
      telemetry: {
        emit: vi.fn(async (input) => { emits.push(input); })
      }
    });
    await w.enqueue(id);
    await w.drain();
    const hit = emits.filter((e) => e.kind === 'tag_vocab_hit');
    const miss = emits.filter((e) => e.kind === 'tag_vocab_miss');
    expect(hit).toHaveLength(1);
    expect(miss).toHaveLength(1);
    const hitPayload = hit[0]!.payload as { tagId: number; vocabSize: number };
    const missPayload = miss[0]!.payload as { vocabSize: number };
    expect(hitPayload.tagId).toBeGreaterThan(0);
    expect(hitPayload.vocabSize).toBe(1);
    expect(missPayload.vocabSize).toBe(1);
  });

  it('all tags miss when vocab is empty', async () => {
    // No seed → vocab=[]
    const { id } = repo.create({ rawText: 'x' });
    const provider = makeProvider({
      generate: vi.fn(async () => ({
        title: 't', summary: 'a\nb\nc',
        tags: ['design', 'meeting', 'qa'],
        dueDate: null
      }))
    });
    const emits: EmittedEvent[] = [];
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0, 0, 0],
      telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
    });
    await w.enqueue(id);
    await w.drain();
    const miss = emits.filter((e) => e.kind === 'tag_vocab_miss');
    expect(miss).toHaveLength(3);
    expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(0);
  });

  it('emits one event per tag (3 tags → 3 events)', async () => {
    // Pre-seed: all 3 in vocab
    const seed = repo.create({ rawText: 'seed' }).id;
    repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' });

    const { id } = repo.create({ rawText: 'x' });
    const provider = makeProvider({
      generate: vi.fn(async () => ({
        title: 't', summary: 'a\nb\nc',
        tags: ['design', 'meeting', 'qa'],
        dueDate: null
      }))
    });
    const emits: EmittedEvent[] = [];
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0, 0, 0],
      telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
    });
    await w.enqueue(id);
    await w.drain();
    const hits = emits.filter((e) => e.kind === 'tag_vocab_hit');
    expect(hits).toHaveLength(3);
  });

  it('dedupes duplicate tags in AI response (one emit per unique tag)', async () => {
    // Pre-seed: 'design' in vocab
    const seed = repo.create({ rawText: 'seed' }).id;
    repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });

    const { id } = repo.create({ rawText: 'x' });
    const provider = makeProvider({
      generate: vi.fn(async () => ({
        title: 't', summary: 'a\nb\nc',
        tags: ['design', 'design', 'meeting'],  // 중복 'design' 의도적
        dueDate: null
      }))
    });
    const emits: EmittedEvent[] = [];
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0, 0, 0],
      telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
    });
    await w.enqueue(id);
    await w.drain();
    const hit = emits.filter((e) => e.kind === 'tag_vocab_hit');
    const miss = emits.filter((e) => e.kind === 'tag_vocab_miss');
    expect(hit).toHaveLength(1);  // 'design' 중복 → 1 hit (dedup)
    expect(miss).toHaveLength(1);  // 'meeting' 1 miss
  });
});

describe('vocab COLLATE NOCASE', () => {
  let db: Database.Database;
  let repo: NoteRepository;

  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('hits when vocab has lowercase and AI returns capital', async () => {
    // Pre-seed: 'design' in vocab (lowercase)
    const seed = repo.create({ rawText: 'seed' }).id;
    repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });

    const { id } = repo.create({ rawText: 'x' });
    const provider = makeProvider({
      generate: vi.fn(async () => ({
        title: 't', summary: 'a\nb\nc',
        tags: ['Design'],  // AI returns capitalized — DB COLLATE NOCASE matches 'design'
        dueDate: null
      }))
    });
    const emits: EmittedEvent[] = [];
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0, 0, 0],
      telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
    });
    await w.enqueue(id);
    await w.drain();
    expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(1);
    expect(emits.filter((e) => e.kind === 'tag_vocab_miss')).toHaveLength(0);
  });

  it('hits when vocab has capital and AI returns lowercase', async () => {
    // Scenario: vocab contains 'Design' (capital), AI returns 'design' (lowercase).
    // getTopUsedTags filters via KEBAB_CASE_RE (/^[a-z0-9-]+$/) so 'Design' would be
    // stripped in production. We stub getTopUsedTags to inject the capital vocab directly,
    // and pre-seed the DB so getTagIdByName (COLLATE NOCASE) can resolve 'design' → tagId.
    const seed = repo.create({ rawText: 'seed' }).id;
    repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['Design'], provider: 'p' });
    // Inject capital vocab bypassing the kebab filter
    vi.spyOn(repo, 'getTopUsedTags').mockReturnValueOnce(['Design']);

    const { id } = repo.create({ rawText: 'x' });
    const provider = makeProvider({
      generate: vi.fn(async () => ({
        title: 't', summary: 'a\nb\nc',
        tags: ['design'],  // AI returns lowercase — DB COLLATE NOCASE matches 'Design'
        dueDate: null
      }))
    });
    const emits: EmittedEvent[] = [];
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0, 0, 0],
      telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
    });
    await w.enqueue(id);
    await w.drain();
    expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(1);
    expect(emits.filter((e) => e.kind === 'tag_vocab_miss')).toHaveLength(0);
  });

  it('still hits when both vocab and AI tag are same lowercase (regression)', async () => {
    // Pre-seed: 'design' in vocab (lowercase)
    const seed = repo.create({ rawText: 'seed' }).id;
    repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });

    const { id } = repo.create({ rawText: 'x' });
    const provider = makeProvider({
      generate: vi.fn(async () => ({
        title: 't', summary: 'a\nb\nc',
        tags: ['design'],  // same lowercase — should still hit
        dueDate: null
      }))
    });
    const emits: EmittedEvent[] = [];
    const w = new AiWorker(repo, new ProviderHolder(provider), {
      backoffsMs: [0, 0, 0],
      telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
    });
    await w.enqueue(id);
    await w.drain();
    expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(1);
    expect(emits.filter((e) => e.kind === 'tag_vocab_miss')).toHaveLength(0);
  });
});
