import { describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';

function freshDb() {
  const db = new Database(':memory:');
  runMigrations(db);
  return db;
}

describe('NoteRepository', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  beforeEach(() => { db = freshDb(); repo = new NoteRepository(db); });

  it('create stores raw_text, defaults edited flags to 0, intent fields NULL, enqueues pending job', () => {
    const { id } = repo.create({ rawText: '회의 메모' });
    const note = repo.findById(id)!;
    expect(note.rawText).toBe('회의 메모');
    expect(note.titleEditedByUser).toBe(false);
    expect(note.summaryEditedByUser).toBe(false);
    expect(note.userIntent).toBeNull();
    expect(note.intentPromptedAt).toBeNull();
    expect(note.aiStatus).toBe('pending');
    const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
    expect(job).toBeDefined();
  });

  it('updateAiResult does not overwrite user-edited title/summary', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.updateAiResult(id, { title: 'AI 제목', summary: 'a\nb\nc', tags: [], provider: 'p' });
    repo.updateUserAiFields(id, { title: '내 제목', summary: '내 요약\n둘\n셋' });
    repo.updateAiResult(id, { title: 'AI 제목 2', summary: 'x\ny\nz', tags: [], provider: 'p' });
    const note = repo.findById(id)!;
    expect(note.aiTitle).toBe('내 제목');
    expect(note.aiSummary).toBe('내 요약\n둘\n셋');
    expect(note.titleEditedByUser).toBe(true);
    expect(note.summaryEditedByUser).toBe(true);
  });

  it('updateAiResult marks done, replaces ai tags, removes pending job', () => {
    const { id } = repo.create({ rawText: '원문' });
    repo.updateAiResult(id, {
      title: '제목', summary: '1줄\n2줄\n3줄',
      tags: ['api-timeout', 'meeting'], provider: 'local-ollama/gemma4:e4b'
    });
    const note = repo.findById(id)!;
    expect(note.aiStatus).toBe('done');
    expect(note.tags.map((t) => t.name).sort()).toEqual(['api-timeout', 'meeting']);
    expect(note.tags.every((t) => t.source === 'ai')).toBe(true);
    expect(db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id)).toBeUndefined();
  });

  it('markAiFailed truncates and clears pending job', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.markAiFailed(id, 'E'.repeat(600));
    const note = repo.findById(id)!;
    expect(note.aiStatus).toBe('failed');
    expect(note.aiError?.length).toBe(500);
  });

  it('updateUserAiFields replaces user-sourced tags', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.updateAiResult(id, { title: 'ai', summary: 'a\nb\nc', tags: ['ai-tag'], provider: 'p' });
    repo.updateUserAiFields(id, { tags: ['user-tag'] });
    const note = repo.findById(id)!;
    expect(note.tags).toEqual([{ name: 'user-tag', source: 'user' }]);
  });

  it('setIntent stores user_intent, sets intent_prompted_at first time, preserves on subsequent', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.setIntent(id, '내일의 나에게');
    const a = repo.findById(id)!;
    expect(a.userIntent).toBe('내일의 나에게');
    expect(a.intentPromptedAt).not.toBeNull();
    const firstStamp = a.intentPromptedAt!;
    repo.setIntent(id, '수정');
    const b = repo.findById(id)!;
    expect(b.userIntent).toBe('수정');
    expect(b.intentPromptedAt).toBe(firstStamp);
  });

  it('dismissIntent stamps intent_prompted_at without setting user_intent', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.dismissIntent(id);
    const note = repo.findById(id)!;
    expect(note.userIntent).toBeNull();
    expect(note.intentPromptedAt).not.toBeNull();
  });

  it('setIntent truncates to 200 chars', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.setIntent(id, 'X'.repeat(300));
    expect(repo.findById(id)!.userIntent?.length).toBe(200);
  });

  it('delete cascades note_tags, media, pending_jobs', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.insertMedia([{ noteId: id, kind: 'image', relPath: 'm/x.png', mime: 'image/png', bytes: 10 }]);
    repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['z'], provider: 'p' });
    repo.delete(id);
    expect(repo.findById(id)).toBeNull();
    expect(db.prepare('SELECT COUNT(*) AS c FROM media').get()).toEqual({ c: 0 });
    expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags').get()).toEqual({ c: 0 });
  });

  it('list returns notes in descending created_at', () => {
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    expect(repo.list({ limit: 10 }).map((n) => n.id)).toEqual([b, a]);
  });

  it('getPendingCount counts pending notes', () => {
    repo.create({ rawText: 'a' });
    const { id } = repo.create({ rawText: 'b' });
    expect(repo.getPendingCount()).toBe(2);
    repo.markAiFailed(id, 'err');
    expect(repo.getPendingCount()).toBe(1);
  });

  it('incrementJobAttempt bumps attempts and stores last_error', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.incrementJobAttempt(id, new Date().toISOString(), 'boom');
    const row = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id) as any;
    expect(row.attempts).toBe(1);
    expect(row.last_error).toBe('boom');
  });

  it('hydrate returns dueDate=null + dueDateEditedByUser=false on new note', () => {
    const { id } = repo.create({ rawText: 'x' });
    const note = repo.findById(id)!;
    expect(note.dueDate).toBeNull();
    expect(note.dueDateEditedByUser).toBe(false);
  });

  it('updateAiResult writes dueDate when edited flag is 0', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.updateAiResult(id, { title: 'AI 제목', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: '2026-05-01' });
    const note = repo.findById(id)!;
    expect(note.dueDate).toBe('2026-05-01');
    expect(note.dueDateEditedByUser).toBe(false);
  });

  it('updateAiResult does NOT overwrite dueDate when edited flag is 1', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.updateAiResult(id, { title: 'AI', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: '2026-05-01' });
    repo.setDueDate(id, '2026-05-15');
    repo.updateAiResult(id, { title: 'AI 2', summary: 'd\ne\nf', tags: [], provider: 'p', dueDate: '2026-05-30' });
    const note = repo.findById(id)!;
    expect(note.dueDate).toBe('2026-05-15');
    expect(note.dueDateEditedByUser).toBe(true);
  });

  it('setDueDate sets due_date and edited flag', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.setDueDate(id, '2026-06-01');
    const note = repo.findById(id)!;
    expect(note.dueDate).toBe('2026-06-01');
    expect(note.dueDateEditedByUser).toBe(true);
  });

  it('setDueDate(null) clears due_date but keeps edited flag', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.setDueDate(id, '2026-06-01');
    repo.setDueDate(id, null);
    const note = repo.findById(id)!;
    expect(note.dueDate).toBeNull();
    expect(note.dueDateEditedByUser).toBe(true);
  });

  it('updateAiResult without dueDate field treats it as null', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.updateAiResult(id, { title: 'AI', summary: 'a\nb\nc', tags: [], provider: 'p' });
    const note = repo.findById(id)!;
    expect(note.dueDate).toBeNull();
  });

  it('countToday returns 0 for empty DB', () => {
    expect(repo.countToday(new Date('2026-04-26T12:00:00Z'))).toBe(0);
  });

  it('countToday counts notes created on the KST date of "now"', () => {
    // now = 2026-04-26 14:00 KST (= 2026-04-26T05:00:00Z UTC).
    // a, b: 2026-04-26 KST → counted.
    // c: 2026-04-25 KST → excluded.
    db.prepare(
      `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
       VALUES (?, 'x', 'pending', ?, ?)`
    ).run('a', '2026-04-25T17:00:00Z', '2026-04-25T17:00:00Z'); // 04-26 02:00 KST
    db.prepare(
      `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
       VALUES (?, 'x', 'pending', ?, ?)`
    ).run('b', '2026-04-25T18:00:00Z', '2026-04-25T18:00:00Z'); // 04-26 03:00 KST
    db.prepare(
      `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
       VALUES (?, 'x', 'pending', ?, ?)`
    ).run('c', '2026-04-25T14:00:00Z', '2026-04-25T14:00:00Z'); // 04-25 23:00 KST
    expect(repo.countToday(new Date('2026-04-26T05:00:00Z'))).toBe(2);
  });

  it('countToday handles KST midnight crossover', () => {
    // now = 2026-04-26 14:00 KST. A note at 2026-04-26T23:30Z = 2026-04-27 08:30 KST
    // belongs to "tomorrow" (KST), so MUST NOT be counted as "today".
    db.prepare(
      `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
       VALUES (?, 'x', 'pending', ?, ?)`
    ).run('a', '2026-04-26T23:30:00Z', '2026-04-26T23:30:00Z');
    expect(repo.countToday(new Date('2026-04-26T05:00:00Z'))).toBe(0);
  });

  it('countToday default arg uses Date.now()', () => {
    const n = repo.countToday();
    expect(typeof n).toBe('number');
    expect(n).toBeGreaterThanOrEqual(0);
  });

  it('findRecallCandidate returns null for empty db', () => {
    expect(repo.findRecallCandidate()).toBeNull();
  });

  it('findRecallCandidate excludes notes recalled within 7 days', () => {
    const id = repo.create({ rawText: 'x' }).id;
    repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
    // 5일 전 본 노트 → 제외
    const fiveDaysAgo = new Date(Date.now() - 5 * 86_400_000).toISOString();
    repo.markRecallOpened(id, fiveDaysAgo);
    expect(repo.findRecallCandidate()).toBeNull();
  });

  it('findRecallCandidate includes notes recalled 8+ days ago', () => {
    const id = repo.create({ rawText: 'x' }).id;
    repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
    const eightDaysAgo = new Date(Date.now() - 8 * 86_400_000).toISOString();
    repo.markRecallOpened(id, eightDaysAgo);
    expect(repo.findRecallCandidate()?.id).toBe(id);
  });

  it('findRecallCandidate respects dismiss expiry (25일 제외, 35일 후보)', () => {
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
    repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
    const twentyFiveDaysAgo = new Date(Date.now() - 25 * 86_400_000).toISOString();
    const thirtyFiveDaysAgo = new Date(Date.now() - 35 * 86_400_000).toISOString();
    repo.dismissRecall(a, twentyFiveDaysAgo);  // 25일 — 아직 dismiss 만료 안 됨
    repo.dismissRecall(b, thirtyFiveDaysAgo);  // 35일 — dismiss 만료, 재추천 가능
    const candidate = repo.findRecallCandidate();
    expect(candidate?.id).toBe(b);
  });

  it('findRecallCandidate excludes deleted/pending/imminent due', () => {
    const todayKst = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10);
    const yesterdayKst = new Date(Date.now() + 9 * 60 * 60 * 1000 - 86_400_000).toISOString().slice(0, 10);
    // (a) deleted
    const a = repo.create({ rawText: 'a' }).id;
    repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
    repo.trash(a, new Date().toISOString());
    // (b) pending (no AI)
    repo.create({ rawText: 'b' });
    // (c) due_date 어제
    const c = repo.create({ rawText: 'c' }).id;
    repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: yesterdayKst, provider: 'p' });
    expect(repo.findRecallCandidate()).toBeNull();
    // (d) due_date today 는 OK (>=today 통과)
    const d = repo.create({ rawText: 'd' }).id;
    repo.updateAiResult(d, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: todayKst, provider: 'p' });
    expect(repo.findRecallCandidate()?.id).toBe(d);
  });

  it('restoreNote re-enqueues failed note (ai_status reset to pending + pending_jobs INSERT)', () => {
    const id = repo.create({ rawText: 'x' }).id;
    repo.markAiFailed(id, 'unreachable');
    repo.trash(id, new Date().toISOString());
    expect(repo.findById(id)!.aiStatus).toBe('failed');

    repo.restoreNote(id);

    const after = repo.findById(id)!;
    expect(after.deletedAt).toBeNull();
    expect(after.aiStatus).toBe('pending');
    expect(after.aiError).toBeNull();
    const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
    expect(job).toBeDefined();
  });

  it('restoreNote does not re-enqueue done note', () => {
    const id = repo.create({ rawText: 'x' }).id;
    repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
    repo.trash(id, new Date().toISOString());
    expect(repo.findById(id)!.aiStatus).toBe('done');

    repo.restoreNote(id);

    expect(repo.findById(id)!.aiStatus).toBe('done');
    const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
    expect(job).toBeUndefined();
  });

  it('restoreNote re-enqueues pending note (defensive)', () => {
    const id = repo.create({ rawText: 'x' }).id;
    // 인공적으로 pending_jobs 비운 후 trash
    db.prepare('DELETE FROM pending_jobs WHERE note_id=?').run(id);
    repo.trash(id, new Date().toISOString());
    expect(repo.findById(id)!.aiStatus).toBe('pending');

    repo.restoreNote(id);

    expect(repo.findById(id)!.aiStatus).toBe('pending');
    const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
    expect(job).toBeDefined();
  });

  it('create accepts explicit now param', () => {
    const fixed = new Date('2026-05-09T10:00:00.000Z');
    const { id } = repo.create({ rawText: 'hello' }, fixed);
    const note = repo.findById(id)!;
    expect(note.createdAt).toBe('2026-05-09T10:00:00.000Z');
    expect(note.updatedAt).toBe('2026-05-09T10:00:00.000Z');
  });

  it('create defaults now to new Date when omitted', () => {
    const before = Date.now();
    const { id } = repo.create({ rawText: 'hello' });
    const after = Date.now();
    const note = repo.findById(id)!;
    const ts = new Date(note.createdAt).getTime();
    expect(ts).toBeGreaterThanOrEqual(before);
    expect(ts).toBeLessThanOrEqual(after);
  });
});

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

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

  it('sets deleted_at and removes pending_jobs row atomically', () => {
    const { id } = repo.create({ rawText: 'x' });
    expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
    repo.trash(id, '2026-05-01T12:00:00.000Z');
    const note = repo.findById(id)!;
    expect(note.deletedAt).toBe('2026-05-01T12:00:00.000Z');
    expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
  });

  it('updates updated_at to deletedAt timestamp', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.trash(id, '2026-05-01T12:00:00.000Z');
    const note = repo.findById(id)!;
    expect(note.updatedAt).toBe('2026-05-01T12:00:00.000Z');
  });

  it('is no-op when note does not exist', () => {
    expect(() => repo.trash('nonexistent', '2026-05-01T12:00:00.000Z')).not.toThrow();
  });
});

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

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

  it('clears deleted_at on a trashed note', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.trash(id, '2026-05-01T12:00:00.000Z');
    repo.restore(id);
    const note = repo.findById(id)!;
    expect(note.deletedAt).toBeNull();
  });

  it('updates updated_at', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.trash(id, '2026-05-01T12:00:00.000Z');
    const before = repo.findById(id)!.updatedAt;
    repo.restore(id);
    const after = repo.findById(id)!.updatedAt;
    expect(after).not.toBe(before);
  });

  it('is no-op on already-active note', () => {
    const { id } = repo.create({ rawText: 'x' });
    expect(() => repo.restore(id)).not.toThrow();
    expect(repo.findById(id)!.deletedAt).toBeNull();
  });
});

describe('NoteRepository.permanentDelete', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('removes notes row + cascades note_tags / pending_jobs', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.updateAiResult(id, { title: 'T', summary: 'a\nb\nc', tags: ['tag-a'], provider: 'p', dueDate: null });
    expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
    repo.permanentDelete(id);
    expect(repo.findById(id)).toBeNull();
    expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
    expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
  });
});

describe('NoteRepository.emptyTrash', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('hard-deletes all trashed notes and returns their ids', () => {
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    const c = repo.create({ rawText: 'c' }).id;
    repo.trash(a, '2026-05-01T00:00:00.000Z');
    repo.trash(c, '2026-05-01T01:00:00.000Z');
    const r = repo.emptyTrash();
    expect(r.noteIds.sort()).toEqual([a, c].sort());
    expect(repo.findById(a)).toBeNull();
    expect(repo.findById(b)).not.toBeNull();
    expect(repo.findById(c)).toBeNull();
  });

  it('returns empty array when trash is empty', () => {
    expect(repo.emptyTrash()).toEqual({ noteIds: [] });
  });
});

describe('NoteRepository.listTrashed', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('returns trashed notes ordered by deleted_at DESC', () => {
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    const c = repo.create({ rawText: 'c' }).id;
    repo.trash(a, '2026-05-01T00:00:00.000Z');
    repo.trash(c, '2026-05-01T02:00:00.000Z');
    repo.trash(b, '2026-05-01T01:00:00.000Z');
    const r = repo.listTrashed({ limit: 50 });
    expect(r.map((n) => n.id)).toEqual([c, b, a]);
  });

  it('excludes active notes', () => {
    repo.create({ rawText: 'active' });
    const r = repo.listTrashed({ limit: 50 });
    expect(r).toEqual([]);
  });

  it('respects limit', () => {
    for (let i = 0; i < 5; i++) {
      const id = repo.create({ rawText: `n${i}` }).id;
      repo.trash(id, `2026-05-01T0${i}:00:00.000Z`);
    }
    const r = repo.listTrashed({ limit: 3 });
    expect(r).toHaveLength(3);
  });
});

describe('NoteRepository.countTrashed', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('returns 0 when no trash', () => {
    repo.create({ rawText: 'active' });
    expect(repo.countTrashed()).toBe(0);
  });

  it('counts only trashed notes', () => {
    const a = repo.create({ rawText: 'a' }).id;
    repo.create({ rawText: 'b (active)' });
    const c = repo.create({ rawText: 'c' }).id;
    repo.trash(a, '2026-05-01T00:00:00.000Z');
    repo.trash(c, '2026-05-01T01:00:00.000Z');
    expect(repo.countTrashed()).toBe(2);
  });

  it('returns count beyond listTrashed limit (no 200 cap drift)', () => {
    // listTrashed limit cap is 200; countTrashed must reflect actual count.
    for (let i = 0; i < 10; i++) {
      const id = repo.create({ rawText: `n${i}` }).id;
      repo.trash(id, `2026-05-01T${String(i).padStart(2, '0')}:00:00.000Z`);
    }
    expect(repo.countTrashed()).toBe(10);
    expect(repo.listTrashed({ limit: 5 })).toHaveLength(5);
  });

  it('countTrashed returns accurate count (>200 not capped)', () => {
    const now = new Date().toISOString();
    for (let i = 0; i < 250; i++) {
      const id = repo.create({ rawText: `n${i}` }).id;
      repo.trash(id, now);
    }
    expect(repo.countTrashed()).toBe(250);
  });

  it('countTrashed returns 0 for empty trash', () => {
    expect(repo.countTrashed()).toBe(0);
  });
});

describe('Active queries exclude deleted notes', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('list() excludes trashed', () => {
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    repo.trash(a, '2026-05-01T00:00:00.000Z');
    const r = repo.list({ limit: 50 });
    expect(r.map((n) => n.id)).toEqual([b]);
  });

  it('listAll() excludes trashed', () => {
    const a = repo.create({ rawText: 'a' }).id;
    repo.create({ rawText: 'b' });
    repo.trash(a, '2026-05-01T00:00:00.000Z');
    const r = repo.listAll();
    expect(r.map((n) => n.rawText)).toEqual(['b']);
  });

  it('countToday() excludes trashed', () => {
    const a = repo.create({ rawText: 'a' }).id;
    repo.create({ rawText: 'b' });
    repo.trash(a, new Date().toISOString());
    expect(repo.countToday(new Date())).toBe(1);
  });

  it('findById() returns trashed notes (does NOT filter)', () => {
    const { id } = repo.create({ rawText: 'a' });
    repo.trash(id, '2026-05-01T00:00:00.000Z');
    const note = repo.findById(id);
    expect(note).not.toBeNull();
    expect(note!.deletedAt).toBe('2026-05-01T00:00:00.000Z');
  });

  it('getPendingCount() excludes trashed pending notes (drift guard)', () => {
    const a = repo.create({ rawText: 'a' }).id; // ai_status=pending
    repo.create({ rawText: 'b' }); // ai_status=pending
    expect(repo.getPendingCount()).toBe(2);
    // trash() 가 pending_jobs row 는 정리하지만 notes.ai_status 는 'pending' 그대로.
    // getPendingCount 가 deleted_at IS NOT NULL 노트 포함하면 영구 over-count.
    repo.trash(a, '2026-05-01T00:00:00.000Z');
    expect(repo.getPendingCount()).toBe(1);
  });
});

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

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

  function makeDone(opts: {
    rawText: string;
    dueDate: string | null;
    edited?: boolean;
    deletedAt?: string | null;
    aiStatus?: 'pending' | 'done' | 'failed';
  }): string {
    const { id } = repo.create({ rawText: opts.rawText });
    db.prepare(
      `UPDATE notes
          SET due_date = ?,
              due_date_edited_by_user = ?,
              ai_status = ?,
              deleted_at = ?
        WHERE id = ?`
    ).run(
      opts.dueDate,
      opts.edited ? 1 : 0,
      opts.aiStatus ?? 'done',
      opts.deletedAt ?? null,
      id
    );
    return id;
  }

  it('returns notes with due_date < today (KST), ORDER BY created_at DESC', () => {
    const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
    db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T10:00:00Z', a);
    const b = makeDone({ rawText: 'b', dueDate: '2026-04-25' });
    db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T11:00:00Z', b);
    const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
    expect(r.map((n) => n.id)).toEqual([b, a]);
  });

  it('includes both AI-extracted and user-edited due_date (Q1=B 회귀 가드)', () => {
    const ai = makeDone({ rawText: 'a', dueDate: '2026-04-20', edited: false });
    const manual = makeDone({ rawText: 'b', dueDate: '2026-04-22', edited: true });
    const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
    expect(r.map((n) => n.id).sort()).toEqual([ai, manual].sort());
  });

  it('excludes trashed notes (deleted_at IS NOT NULL)', () => {
    const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
    makeDone({ rawText: 'b', dueDate: '2026-04-21', deletedAt: '2026-04-30T00:00:00Z' });
    const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
    expect(r.map((n) => n.id)).toEqual([a]);
  });

  it('excludes pending / failed notes (ai_status != done)', () => {
    const done = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
    makeDone({ rawText: 'b', dueDate: '2026-04-20', aiStatus: 'pending' });
    makeDone({ rawText: 'c', dueDate: '2026-04-20', aiStatus: 'failed' });
    const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
    expect(r.map((n) => n.id)).toEqual([done]);
  });

  it('excludes notes with NULL due_date (NULL < string 평가 가드)', () => {
    const dated = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
    makeDone({ rawText: 'b', dueDate: null });
    const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
    expect(r.map((n) => n.id)).toEqual([dated]);
  });

  it('excludes notes with due_date == today (boundary, not expired)', () => {
    const past = makeDone({ rawText: 'a', dueDate: '2026-04-30' });
    makeDone({ rawText: 'b', dueDate: '2026-05-01' });
    const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
    expect(r.map((n) => n.id)).toEqual([past]);
  });
});

describe('NoteRepository.trashBatch', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('atomically trashes all valid ids and returns trashedCount', () => {
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    const c = repo.create({ rawText: 'c' }).id;
    const r = repo.trashBatch([a, b, c], '2026-05-01T12:00:00.000Z');
    expect(r.trashedCount).toBe(3);
    expect(repo.findById(a)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
    expect(repo.findById(b)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
    expect(repo.findById(c)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
    expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id IN (?,?,?)').get(a, b, c))
      .toMatchObject({ c: 0 });
  });

  it('returns trashedCount=0 for empty array (no-op)', () => {
    const r = repo.trashBatch([], '2026-05-01T12:00:00.000Z');
    expect(r.trashedCount).toBe(0);
  });

  it('skips ids that are already trashed (idempotent — count = 0 transitions)', () => {
    const a = repo.create({ rawText: 'a' }).id;
    repo.trash(a, '2026-04-30T00:00:00.000Z');
    const r = repo.trashBatch([a], '2026-05-01T12:00:00.000Z');
    expect(r.trashedCount).toBe(0);
    expect(repo.findById(a)!.deletedAt).toBe('2026-04-30T00:00:00.000Z');
  });

  it('counts only the valid active ids (mix of valid + invalid + already-trashed)', () => {
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    repo.trash(b, '2026-04-30T00:00:00.000Z');
    const r = repo.trashBatch([a, b, 'nonexistent-id'], '2026-05-01T12:00:00.000Z');
    expect(r.trashedCount).toBe(1);
    expect(repo.findById(a)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
  });
});

describe('NoteRepository — failed retry helpers', () => {
  let db: Database.Database;
  let repo: NoteRepository;

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

  function makeFailed(rawText: string, deletedAt: string | null = null): string {
    const { id } = repo.create({ rawText });
    db.prepare(
      `UPDATE notes SET ai_status='failed', ai_error='boom', deleted_at=? WHERE id=?`
    ).run(deletedAt, id);
    db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
    return id;
  }

  it('findFailedIds returns ai_status=failed AND deleted_at IS NULL only', () => {
    const a = makeFailed('a');
    makeFailed('b', '2026-04-30T00:00:00Z');  // trashed
    repo.create({ rawText: 'pending' });  // pending status
    expect(repo.findFailedIds().sort()).toEqual([a].sort());
  });

  it('countFailed counts active failed notes only', () => {
    makeFailed('a');
    makeFailed('b');
    makeFailed('c', '2026-04-30T00:00:00Z');
    expect(repo.countFailed()).toBe(2);
  });

  it('retryAllFailed atomic — ai_status reset + pending_jobs 재투입', () => {
    const a = makeFailed('a');
    const b = makeFailed('b');
    const r = repo.retryAllFailed('2026-05-01T12:00:00.000Z');
    expect(r.ids.sort()).toEqual([a, b].sort());
    expect(repo.findById(a)!.aiStatus).toBe('pending');
    expect(repo.findById(b)!.aiStatus).toBe('pending');
    expect(repo.findById(a)!.aiError).toBeNull();
    const jobs = repo.getAllPendingJobs();
    expect(jobs.map((j) => j.noteId).sort()).toEqual([a, b].sort());
    for (const j of jobs) {
      expect(j.attempts).toBe(0);
      expect(j.nextRunAt).toBe('2026-05-01T12:00:00.000Z');
    }
  });

  it('retryAllFailed empty — { ids: [] }', () => {
    expect(repo.retryAllFailed('2026-05-01T12:00:00.000Z')).toEqual({ ids: [] });
  });

  it('retryAllFailed — pending_jobs 이미 존재 시 OR IGNORE (race 안전)', () => {
    // failed 노트인데 pending_jobs row 가 이미 존재하는 비정상 race 상태 시뮬레이션.
    // attempts=2, next_run_at=과거 — retryAllFailed 가 INSERT OR IGNORE 라 그대로 보존되어야.
    const id = makeFailed('a');
    db.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 2, ?)`)
      .run(id, '2026-04-30T00:00:00.000Z');
    const r = repo.retryAllFailed('2026-05-01T12:00:00.000Z');
    expect(r.ids).toEqual([id]);
    const jobs = repo.getAllPendingJobs().filter((j) => j.noteId === id);
    expect(jobs).toHaveLength(1);  // duplicate 안 됨
    // OR IGNORE 라 기존 row 보존 — attempts=2, nextRunAt 그대로
    expect(jobs[0]!.attempts).toBe(2);
    expect(jobs[0]!.nextRunAt).toBe('2026-04-30T00:00:00.000Z');
  });

  it('setNextRunAt — attempts 변경 없이 next_run_at + last_error 갱신', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.incrementJobAttempt(id, '2026-05-01T11:00:00.000Z', 'first error');
    // attempts 가 1 이 됨
    repo.setNextRunAt(id, '2026-05-01T12:00:00.000Z', 'unreachable');
    const job = repo.getAllPendingJobs().find((j) => j.noteId === id)!;
    expect(job.attempts).toBe(1);  // 변화 없음
    expect(job.nextRunAt).toBe('2026-05-01T12:00:00.000Z');
  });

  it('getTopUsedTags returns [] when no notes', () => {
    expect(repo.getTopUsedTags()).toEqual([]);
  });

  it('getTopUsedTags orders by count desc, id asc tiebreaker', () => {
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    const c = repo.create({ rawText: 'c' }).id;
    repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting'], provider: 'p' });
    repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
    repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' });
    // counts: design=3, meeting=2, qa=1
    expect(repo.getTopUsedTags()).toEqual(['design', 'meeting', 'qa']);
  });

  it('getTopUsedTags filters non-kebab-case (한글/대문자/공백)', () => {
    const a = repo.create({ rawText: 'a' }).id;
    // user route 가 한글/공백 태그 들어올 수 있음 → vocab 에서 제외 검증
    repo.updateUserAiFields(a, { tags: ['design', '회의', 'Meeting', 'two words', 'api-timeout'] });
    expect(repo.getTopUsedTags()).toEqual(expect.arrayContaining(['design', 'api-timeout']));
    expect(repo.getTopUsedTags()).not.toContain('회의');
    expect(repo.getTopUsedTags()).not.toContain('Meeting');
    expect(repo.getTopUsedTags()).not.toContain('two words');
  });

  it('getTopUsedTags counts AI + user sources together', () => {
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    const c = repo.create({ rawText: 'c' }).id;
    // design: 1 AI (a) + 1 user (b) = 2 total; meeting: 1 AI (c) = 1 total
    // → design must rank first (proves source merging, not AI-only count)
    // Note: updateUserAiFields REPLACES tags (DELETE+reinsert), so each note
    // gets exactly the tags passed in the call.
    repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['design'], provider: 'p' });
    repo.updateUserAiFields(b, { tags: ['design'] });
    repo.updateAiResult(c, { title: 't', summary: 'x\ny\nz', tags: ['meeting'], provider: 'p' });
    const top = repo.getTopUsedTags();
    expect(top[0]).toBe('design');  // 2 (AI+user) > 1 (AI only)
    expect(top.indexOf('meeting')).toBeGreaterThan(0);
  });

  it('getTopUsedTags excludes tags from deleted notes', () => {
    const a = repo.create({ rawText: 'a' }).id;
    repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['lonely'], provider: 'p' });
    repo.trash(a, new Date().toISOString());
    expect(repo.getTopUsedTags()).not.toContain('lonely');
  });

  it('getTopUsedTags respects LIMIT parameter', () => {
    const ids: string[] = [];
    for (let i = 0; i < 5; i++) {
      const id = repo.create({ rawText: `n${i}` }).id;
      ids.push(id);
      repo.updateAiResult(id, {
        title: 't', summary: 'a\nb\nc',
        tags: [`tag-${i}`],
        provider: 'p'
      });
    }
    expect(repo.getTopUsedTags(3)).toHaveLength(3);
    expect(repo.getTopUsedTags(10)).toHaveLength(5);
  });

  it('getTopUsedTags result may be shorter than limit when top-N includes non-kebab tags', () => {
    // 비-kebab 1개 (한글) + kebab 2개 → top-3 으로 SQL 가져온 후 regex 필터로 한글 제외
    // 결과 length = 2 (limit=3 보다 작음)
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    const c = repo.create({ rawText: 'c' }).id;
    repo.updateUserAiFields(a, { tags: ['회의'] });  // 한글 — SQL top 에 포함될 수 있지만 regex 통과 X
    repo.updateUserAiFields(b, { tags: ['design'] });
    repo.updateUserAiFields(c, { tags: ['meeting'] });
    const top = repo.getTopUsedTags(3);
    expect(top.length).toBeLessThan(3);  // SQL 은 3개 가져왔지만 regex 가 1개 제거
    expect(top).not.toContain('회의');
    expect(top).toEqual(expect.arrayContaining(['design', 'meeting']));
  });

  it('getTagIdByName returns id when present, null when absent', () => {
    const a = repo.create({ rawText: 'a' }).id;
    repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['hello'], provider: 'p' });
    const id = repo.getTagIdByName('hello');
    expect(typeof id).toBe('number');
    expect(id).toBeGreaterThan(0);
    // case-insensitive
    expect(repo.getTagIdByName('HELLO')).toBe(id);
    // absent
    expect(repo.getTagIdByName('nothere')).toBeNull();
  });
});

describe('NoteRepository — setStatus + listByStatus', () => {
  let db: Database.Database;
  let repo: NoteRepository;

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

  it('setStatus updates status + reason + status_changed_at + updated_at', () => {
    const { id } = repo.create({ rawText: 'test' });
    repo.setStatus(id, 'completed', '결재 끝', new Date('2026-05-10T00:00:00.000Z'));
    const note = repo.findById(id)!;
    expect(note.status).toBe('completed');
    expect(note.moveReason).toBe('결재 끝');
    expect(note.statusChangedAt).toBe('2026-05-10T00:00:00.000Z');
    expect(note.updatedAt).toBe('2026-05-10T00:00:00.000Z');
  });

  it('setStatus accepts null reason', () => {
    const { id } = repo.create({ rawText: 'test' });
    repo.setStatus(id, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
    const note = repo.findById(id)!;
    expect(note.status).toBe('archived');
    expect(note.moveReason).toBeNull();
  });

  it('setStatus default now uses Date.now()', () => {
    const { id } = repo.create({ rawText: 'test' });
    const before = Date.now();
    repo.setStatus(id, 'completed', null);
    const after = Date.now();
    const note = repo.findById(id)!;
    const ts = new Date(note.statusChangedAt!).getTime();
    expect(ts).toBeGreaterThanOrEqual(before);
    expect(ts).toBeLessThanOrEqual(after);
  });

  it('listByStatus filters correctly', () => {
    const idA = repo.create({ rawText: 'a' }).id;
    const idB = repo.create({ rawText: 'b' }).id;
    repo.setStatus(idB, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));

    const active = repo.listByStatus('active', { limit: 10 });
    const archived = repo.listByStatus('archived', { limit: 10 });
    expect(active.map((n) => n.id)).toContain(idA);
    expect(active.map((n) => n.id)).not.toContain(idB);
    expect(archived.map((n) => n.id)).toContain(idB);
    expect(archived.map((n) => n.id)).not.toContain(idA);
  });

  it('listByStatus orders by status_changed_at DESC (NULL falls back to created_at)', () => {
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    const c = repo.create({ rawText: 'c' }).id;
    repo.setStatus(a, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
    repo.setStatus(b, 'completed', null, new Date('2026-05-12T00:00:00.000Z'));
    repo.setStatus(c, 'completed', null, new Date('2026-05-11T00:00:00.000Z'));
    const r = repo.listByStatus('completed', { limit: 10 });
    expect(r.map((n) => n.id)).toEqual([b, c, a]);
  });

  it('listByStatus respects limit (cap 200)', () => {
    for (let i = 0; i < 5; i++) {
      const id = repo.create({ rawText: `n${i}` }).id;
      repo.setStatus(id, 'archived', null, new Date(`2026-05-${10 + i}T00:00:00.000Z`));
    }
    expect(repo.listByStatus('archived', { limit: 3 })).toHaveLength(3);
    expect(repo.listByStatus('archived', { limit: 100 })).toHaveLength(5);
  });

  it('listByStatus default limit 200', () => {
    repo.create({ rawText: 'a' });
    expect(repo.listByStatus('active')).toHaveLength(1);
  });

  it('setStatus("trashed") syncs deleted_at (backward compat)', () => {
    const { id } = repo.create({ rawText: 't' });
    repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
    const row = db.prepare(`SELECT deleted_at FROM notes WHERE id=?`).get(id) as {
      deleted_at: string;
    };
    expect(row.deleted_at).toBe('2026-05-15T00:00:00.000Z');
    expect(repo.findById(id)!.deletedAt).toBe('2026-05-15T00:00:00.000Z');
  });

  it('setStatus("active") clears deleted_at (restore from trash)', () => {
    const { id } = repo.create({ rawText: 'r' });
    repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
    repo.setStatus(id, 'active', null, new Date('2026-05-16T00:00:00.000Z'));
    const row = db.prepare(`SELECT deleted_at FROM notes WHERE id=?`).get(id) as {
      deleted_at: string | null;
    };
    expect(row.deleted_at).toBeNull();
    expect(repo.findById(id)!.deletedAt).toBeNull();
  });

  it('setStatus("completed"/"archived") also clears deleted_at', () => {
    const { id } = repo.create({ rawText: 'r' });
    repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z'));
    repo.setStatus(id, 'completed', null, new Date('2026-05-16T00:00:00.000Z'));
    expect(repo.findById(id)!.deletedAt).toBeNull();
  });

  it('newly created note hydrates as status=active', () => {
    const { id } = repo.create({ rawText: 'fresh' });
    const note = repo.findById(id)!;
    expect(note.status).toBe('active');
    expect(note.statusChangedAt).toBeNull();
    expect(note.moveReason).toBeNull();
  });

  it('countByStatus returns accurate count per status', () => {
    const a = repo.create({ rawText: 'a' }).id;  // active
    repo.create({ rawText: 'b' });               // active
    const c = repo.create({ rawText: 'c' }).id;
    repo.setStatus(c, 'completed', null, new Date('2026-05-10T00:00:00.000Z'));
    const d = repo.create({ rawText: 'd' }).id;
    repo.setStatus(d, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
    const e = repo.create({ rawText: 'e' }).id;
    repo.setStatus(e, 'trashed', null, new Date('2026-05-10T00:00:00.000Z'));

    expect(repo.countByStatus('active')).toBe(2);
    expect(repo.countByStatus('completed')).toBe(1);
    expect(repo.countByStatus('archived')).toBe(1);
    expect(repo.countByStatus('trashed')).toBe(1);
    // sanity — a 가 여전히 active.
    expect(repo.findById(a)!.status).toBe('active');
  });

  it('restoreNote sets status=active + clears moveReason', () => {
    const { id } = repo.create({ rawText: 'r' });
    repo.setStatus(id, 'trashed', '실수', new Date('2026-05-15T00:00:00.000Z'));
    expect(repo.findById(id)!.status).toBe('trashed');
    expect(repo.findById(id)!.moveReason).toBe('실수');

    repo.restoreNote(id);

    const after = repo.findById(id)!;
    expect(after.status).toBe('active');
    expect(after.moveReason).toBeNull();
    expect(after.deletedAt).toBeNull();
  });
});

// v0.2.9 Cut B Task 16 — settings.ai_enabled OFF→ON 전환 시 disabled 메모 일괄 재투입.
describe('NoteRepository.requeueDisabled', () => {
  let db: Database.Database;
  let repo: NoteRepository;

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

  it('changes ai_status="disabled" → "pending" + INSERT pending_jobs', () => {
    const { id } = repo.create({ rawText: 't', aiStatus: 'disabled' });
    const count = repo.requeueDisabled(new Date('2026-05-09T00:00:00Z'));
    expect(count).toBe(1);
    const note = repo.findById(id);
    expect(note?.aiStatus).toBe('pending');
    const job = db.prepare(`SELECT * FROM pending_jobs WHERE note_id=?`).get(id);
    expect(job).toBeDefined();
  });

  it('does not affect non-disabled notes', () => {
    const idP = repo.create({ rawText: 'p', aiStatus: 'pending' }).id;
    const idC = repo.create({ rawText: 'c' }).id;
    repo.updateAiResult(idC, { title: 't', summary: 'a\nb\nc', tags: [], provider: 'p' });
    repo.requeueDisabled(new Date());
    expect(repo.findById(idP)?.aiStatus).toBe('pending');
    expect(repo.findById(idC)?.aiStatus).toBe('done');
  });

  it('returns 0 when no disabled notes', () => {
    const count = repo.requeueDisabled(new Date());
    expect(count).toBe(0);
  });
});

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

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

  it('returns count per ai_status', () => {
    repo.create({ rawText: 'a', aiStatus: 'disabled' });
    repo.create({ rawText: 'b', aiStatus: 'disabled' });
    repo.create({ rawText: 'c', aiStatus: 'pending' });
    expect(repo.countByAiStatus('disabled')).toBe(2);
    expect(repo.countByAiStatus('pending')).toBe(1);
    expect(repo.countByAiStatus('done')).toBe(0);
  });
});

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

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

  it('create() 가 첫 revision (edited_by=capture) 을 INSERT 한다', () => {
    const { id } = repo.create({ rawText: 'hello' });
    const rows = db
      .prepare(`SELECT raw_text, edited_by FROM note_revisions WHERE note_id=?`)
      .all(id) as Array<{ raw_text: string; edited_by: string }>;
    expect(rows).toHaveLength(1);
    expect(rows[0]).toEqual({ raw_text: 'hello', edited_by: 'capture' });
  });
});

describe('NoteRepository — notes_fts tags sync (v0.2.11 Cut D)', () => {
  let db: Database.Database;

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

  it('updateAiResult 후 notes_fts.tags 가 csv 로 sync', () => {
    const repo = new NoteRepository(db);
    const { id } = repo.create({ rawText: '회의 본문' });
    repo.updateAiResult(id, { title: '제목', summary: '요약', tags: ['기획', '회의'], provider: 'p' });
    const row = db
      .prepare(`SELECT tags FROM notes_fts WHERE note_id=?`)
      .get(id) as { tags: string };
    expect(row.tags.split(' ').sort()).toEqual(['기획', '회의']);
  });

  it('updateUserAiFields tags 갱신 후 notes_fts.tags 동기', () => {
    const repo = new NoteRepository(db);
    const { id } = repo.create({ rawText: '본문' });
    repo.updateAiResult(id, { title: 't', summary: 's', tags: ['old'], provider: 'p' });
    repo.updateUserAiFields(id, { tags: ['new1', 'new2'] });
    const row = db
      .prepare(`SELECT tags FROM notes_fts WHERE note_id=?`)
      .get(id) as { tags: string };
    expect(row.tags.split(' ').sort()).toEqual(['new1', 'new2']);
  });

  it('importNote insert path: notes_fts.tags 가 csv 로 sync (final review fix)', () => {
    const repo = new NoteRepository(db);
    const r = repo.importNote({
      id: '00000000-0000-0000-0000-000000000010',
      rawText: 'imported with tags',
      createdAt: '2026-04-01T00:00:00Z',
      updatedAt: '2026-04-01T00:00:00Z',
      aiTitle: 'imported title',
      aiSummary: 'imported summary',
      titleEditedByUser: false,
      summaryEditedByUser: false,
      aiProvider: 'p',
      aiGeneratedAt: '2026-04-01T00:00:00Z',
      userIntent: null,
      intentPromptedAt: null,
      tags: [
        { name: '기획', source: 'ai' },
        { name: '회의', source: 'user' }
      ]
    });
    expect(r.status).toBe('inserted');
    const row = db
      .prepare(`SELECT tags FROM notes_fts WHERE note_id=?`)
      .get(r.id) as { tags: string };
    expect(row.tags.split(' ').sort()).toEqual(['기획', '회의']);
  });

  it('importNote fork path: forked 노트의 notes_fts.tags 동기 (final review fix)', () => {
    const repo = new NoteRepository(db);
    const existing = repo.create({ rawText: 'v1' });
    const r = repo.importNote({
      id: existing.id,
      rawText: 'imported v2 with tags',
      createdAt: '2026-04-01T00:00:00Z',
      updatedAt: '2026-04-01T00:00:00Z',
      aiTitle: null,
      aiSummary: null,
      titleEditedByUser: false,
      summaryEditedByUser: false,
      aiProvider: null,
      aiGeneratedAt: null,
      userIntent: null,
      intentPromptedAt: null,
      tags: [{ name: '결재', source: 'user' }]
    });
    expect(r.status).toBe('forked');
    expect(r.id).not.toBe(existing.id);
    const row = db
      .prepare(`SELECT tags FROM notes_fts WHERE note_id=?`)
      .get(r.id) as { tags: string };
    expect(row.tags).toBe('결재');
  });
});
