import type Database from 'better-sqlite3';
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
import type { AiStatus, Note, NoteMedia, NoteRevision, NoteStatus, NoteTag } from '@shared/types';
import { kstTodayIso, KST_OFFSET_MS } from '../../shared/util/kstDate.js';
import { sanitizeFtsQuery, computeCutoff, type ReviewPeriod } from './ftsHelpers.js';

export interface CreateNoteInput {
  rawText: string;
  /**
   * v0.2.9 Cut B — settings.ai_enabled=false 일 때 'disabled' 로 insert + pending_jobs skip.
   * 미지정 시 기존 'pending' default + pending_jobs enqueue (backward compat).
   */
  aiStatus?: AiStatus;
}

export interface NewMediaRow {
  noteId: string;
  kind: 'image';
  relPath: string;
  mime: string;
  bytes: number;
}

export interface ImportNoteInput {
  /** Proposed id from the export file. May be replaced if it collides with
   *  an existing row whose `raw_text` differs — fork-on-conflict so a single
   *  id never resolves to two distinct historical baselines (v0.2.10 Cut C
   *  changed `raw_text 불변` policy → `raw_text 가변` + revision history; the
   *  baseline distinction is now preserved per-id, edit history per-note). */
  id: string;
  rawText: string;
  createdAt: string;
  updatedAt: string;
  aiTitle: string | null;
  aiSummary: string | null;
  titleEditedByUser: boolean;
  summaryEditedByUser: boolean;
  aiProvider: string | null;
  aiGeneratedAt: string | null;
  userIntent: string | null;
  intentPromptedAt: string | null;
  tags: { name: string; source: 'ai' | 'user' }[];
  deletedAt?: string | null;
}

export type ImportNoteStatus = 'inserted' | 'skipped' | 'forked';

export interface ImportNoteResult {
  /** Final id used for the row (== input.id for inserted/skipped, fresh uuidv7 for forked). */
  id: string;
  status: ImportNoteStatus;
}

export interface UpsertFromSyncInput {
  id: string;
  rawText: string;
  createdAt: string;
  updatedAt: string;
  aiTitle: string | null;
  aiSummary: string | null;
  titleEditedByUser: boolean;
  summaryEditedByUser: boolean;
  aiProvider: string | null;
  aiGeneratedAt: string | null;
  userIntent: string | null;
  intentPromptedAt: string | null;
  tags: { name: string; source: 'ai' | 'user' }[];
  status: NoteStatus;
  statusChangedAt: string | null;
  moveReason: string | null;
  dueDate: string | null;
  dueDateEditedByUser: boolean;
}

export type UpsertFromSyncStatus = 'inserted' | 'updated' | 'skipped';

const KEBAB_CASE_RE = /^[a-z0-9-]+$/;

export class NoteRepository {
  constructor(private db: Database.Database) {}

  create(input: CreateNoteInput, now: Date = new Date()): { id: string } {
    const id = uuidv7();
    const ts = now.toISOString();
    const aiStatus: AiStatus = input.aiStatus ?? 'pending';
    const tx = this.db.transaction(() => {
      this.db
        .prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
                  VALUES (?, ?, ?, ?, ?)`)
        .run(id, input.rawText, aiStatus, ts, ts);
      this.db
        .prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
                  VALUES (?, ?, ?, 'capture')`)
        .run(id, input.rawText, ts);
      // pending_jobs 는 'pending' 일 때만 생성 — 'disabled' 노트는 worker 가 처리 안 함.
      if (aiStatus === 'pending') {
        this.db
          .prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
                    VALUES (?, 0, ?)`)
          .run(id, ts);
      }
    });
    tx();
    return { id };
  }

  insertMedia(rows: NewMediaRow[]): void {
    if (rows.length === 0) return;
    const now = new Date().toISOString();
    const stmt = this.db.prepare(
      `INSERT INTO media (id, note_id, kind, rel_path, mime, bytes, created_at)
       VALUES (?, ?, ?, ?, ?, ?, ?)`
    );
    const tx = this.db.transaction(() => {
      for (const r of rows) {
        stmt.run(uuidv4(), r.noteId, r.kind, r.relPath, r.mime, r.bytes, now);
      }
    });
    tx();
  }

  findById(id: string): Note | null {
    const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as Record<string, unknown>;
    if (!row) return null;
    return this.hydrate(row);
  }

  list(opts: { limit: number; cursor?: string }): Note[] {
    const limit = Math.max(1, Math.min(200, opts.limit));
    const rows = opts.cursor
      ? (this.db
          .prepare(
            `SELECT * FROM notes
              WHERE deleted_at IS NULL AND created_at < ?
              ORDER BY created_at DESC, id DESC LIMIT ?`
          )
          .all(opts.cursor, limit) as Record<string, unknown>[])
      : (this.db
          .prepare(
            `SELECT * FROM notes
              WHERE deleted_at IS NULL
              ORDER BY created_at DESC, id DESC LIMIT ?`
          )
          .all(limit) as Record<string, unknown>[]);
    return rows.map((r) => this.hydrate(r));
  }

  listAll(): Note[] {
    const rows = this.db
      .prepare(`SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC, id ASC`)
      .all() as Record<string, unknown>[];
    return rows.map((r) => this.hydrate(r));
  }

  updateAiResult(
    id: string,
    result: { title: string; summary: string; tags: string[]; provider: string; dueDate?: string | null }
  ): void {
    const now = new Date().toISOString();
    const dueDate = result.dueDate ?? null;
    const tx = this.db.transaction(() => {
      this.db
        .prepare(
          `UPDATE notes
              SET ai_title = CASE WHEN title_edited_by_user = 1 THEN ai_title ELSE ? END,
                  ai_summary = CASE WHEN summary_edited_by_user = 1 THEN ai_summary ELSE ? END,
                  due_date = CASE WHEN due_date_edited_by_user = 1 THEN due_date ELSE ? END,
                  ai_status = 'done',
                  ai_provider = ?,
                  ai_generated_at = ?,
                  ai_error = NULL,
                  updated_at = ?
            WHERE id = ?`
        )
        .run(result.title, result.summary, dueDate, result.provider, now, now, id);
      this.db.prepare(`DELETE FROM note_tags WHERE note_id=? AND source='ai'`).run(id);
      const getOrInsertTag = this.db.prepare(
        `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
      );
      const linkTag = this.db.prepare(
        `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
      );
      for (const t of result.tags) {
        const tagRow = getOrInsertTag.get(t) as { id: number };
        linkTag.run(id, tagRow.id);
      }
      this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
      this.rebuildFtsTagsForNote(id);
    });
    tx();
  }

  markAiFailed(id: string, error: string): void {
    const now = new Date().toISOString();
    const tx = this.db.transaction(() => {
      this.db
        .prepare(`UPDATE notes SET ai_status='failed', ai_error=?, updated_at=? WHERE id=?`)
        .run(error.slice(0, 500), now, id);
      this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
    });
    tx();
  }

  findFailedIds(): string[] {
    const rows = this.db
      .prepare(
        `SELECT id FROM notes WHERE ai_status='failed' AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC`
      )
      .all() as Array<{ id: string }>;
    return rows.map((r) => r.id);
  }

  countFailed(): number {
    const row = this.db
      .prepare(
        `SELECT COUNT(*) AS c FROM notes WHERE ai_status='failed' AND deleted_at IS NULL`
      )
      .get() as { c: number };
    return row.c;
  }

  /**
   * 모든 ai_status='failed' (active) 노트를 'pending' 으로 reset 하고 pending_jobs 재투입.
   * 단일 transaction. v0.2.3 #2 retryAllFailed.
   *
   * INSERT OR IGNORE 로 race 안전 (이미 pending_jobs row 존재 시 skip).
   */
  retryAllFailed(now: string): { ids: string[] } {
    const ids: string[] = [];
    const tx = this.db.transaction(() => {
      const rows = this.db
        .prepare(`SELECT id FROM notes WHERE ai_status='failed' AND deleted_at IS NULL`)
        .all() as Array<{ id: string }>;
      if (rows.length === 0) return;
      const reset = this.db.prepare(
        `UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`
      );
      const insert = this.db.prepare(
        `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
      );
      for (const r of rows) {
        reset.run(now, r.id);
        insert.run(r.id, now);
        ids.push(r.id);
      }
    });
    tx();
    return { ids };
  }

  /**
   * v0.2.9 Cut B Task 16 — 모든 ai_status='disabled' 노트를 'pending' 으로 reset 하고
   * pending_jobs 재투입. 사용자가 settings.ai_enabled OFF→ON 전환 후 "지금 모두 처리"
   * 버튼을 누른 path. 단일 transaction. 호출자가 `now` 주입 가능 (테스트성).
   *
   * INSERT OR IGNORE — race 안전 (이미 pending_jobs row 존재 시 skip).
   * 반환값 = 처리된 노트 수 (UI 가 "N건 처리됨" 토스트 등 표시용).
   */
  requeueDisabled(now: Date = new Date()): number {
    const tx = this.db.transaction(() => {
      const ts = now.toISOString();
      const targets = this.db
        .prepare(`SELECT id FROM notes WHERE ai_status='disabled'`)
        .all() as Array<{ id: string }>;
      for (const { id } of targets) {
        this.db
          .prepare(`UPDATE notes SET ai_status='pending', updated_at=? WHERE id=?`)
          .run(ts, id);
        this.db
          .prepare(
            `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
          )
          .run(id, ts);
      }
      return targets.length;
    });
    return tx();
  }

  /**
   * v0.2.9 Cut B Task 16 — ai_status 별 row count.
   * 설정 페이지의 "원문만 저장된 메모 N건" 표기용 (status='disabled' 카운트).
   * deleted_at 필터 없음 — disabled 메모도 trash 갈 수 있는데 사용자 의도는
   * "AI 처리할 게 얼마나 남았나?" 라 trashed 까지 포함되면 안 됨. → deleted_at IS NULL 추가.
   */
  countByAiStatus(status: AiStatus): number {
    const row = this.db
      .prepare(
        `SELECT COUNT(*) AS c FROM notes WHERE ai_status=? AND deleted_at IS NULL`
      )
      .get(status) as { c: number };
    return row.c;
  }

  /**
   * pending_jobs 의 next_run_at + last_error 만 갱신, attempts 변경 없음.
   * v0.2.3 #2 — unreachable/timeout 무한 retry 시 사용 (incrementJobAttempt 와 별도 경로).
   */
  setNextRunAt(noteId: string, nextRunAt: string, lastError: string): void {
    this.db
      .prepare(
        `UPDATE pending_jobs SET next_run_at=?, last_error=? WHERE note_id=?`
      )
      .run(nextRunAt, lastError.slice(0, 500), noteId);
  }

  /**
   * v0.2.3 #6 — 회상 후보 1건. 가장 오래된 후보 (created_at ASC) 우선.
   * - 7일 이상 안 본 노트 (last_recalled_at NULL 또는 7일 전 이전)
   * - 30일 이상 dismiss 만료 또는 dismiss 안 된 노트
   * - ai_status='done' + deleted_at IS NULL + due_date 임박 X (≥ today)
   * KST 보정: SQLite date('now') 는 UTC 라 +9 hours 항상 추가.
   */
  findRecallCandidate(): Note | null {
    const row = this.db
      .prepare(
        `SELECT * FROM notes
          WHERE (last_recalled_at IS NULL OR last_recalled_at < date('now','+9 hours','-7 day'))
            AND (recall_dismissed_at IS NULL OR recall_dismissed_at < date('now','+9 hours','-30 day'))
            AND ai_status = 'done'
            AND deleted_at IS NULL
            AND (due_date IS NULL OR due_date >= date('now','+9 hours'))
          ORDER BY created_at ASC
          LIMIT 1`
      )
      .get() as Record<string, unknown> | undefined;
    return row ? this.hydrate(row) : null;
  }

  /** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at = now. */
  markRecallOpened(id: string, now: string): void {
    this.db
      .prepare(`UPDATE notes SET last_recalled_at = ?, updated_at = ? WHERE id = ?`)
      .run(now, now, id);
  }

  /** v0.2.3 #6 — 회상 "더 이상" 시 recall_dismissed_at = now. 30일 후 재추천. */
  dismissRecall(id: string, now: string): void {
    this.db
      .prepare(`UPDATE notes SET recall_dismissed_at = ?, updated_at = ? WHERE id = ?`)
      .run(now, now, id);
  }

  /**
   * v0.2.3 #3 — AI prompt 의 vocabulary 후보. 사용 빈도 높은 태그 top-N.
   * source 무시 (AI+user 통합), kebab-case 통과한 것만 (한글/공백/대문자 제외).
   * deleted_at IS NULL 만 (휴지통 노트 태그 제외).
   *
   * Note: LIMIT 가 SQL 단계에서 먼저 적용된 후 regex 필터링이 후처리 됨.
   * 따라서 반환 배열 length 가 limit 보다 작을 수 있음 (top-N 안에 비-kebab-case
   * 태그가 섞여 있을 때). v0.2.3 dogfood 규모에서는 실용적 영향 없음.
   */
  getTopUsedTags(limit = 20): string[] {
    const rows = this.db
      .prepare(
        `SELECT t.name, COUNT(*) AS c
           FROM tags t
           JOIN note_tags nt ON nt.tag_id = t.id
           JOIN notes n ON n.id = nt.note_id
          WHERE n.deleted_at IS NULL
          GROUP BY t.id
          ORDER BY c DESC, t.id ASC
          LIMIT ?`
      )
      .all(limit) as Array<{ name: string; c: number }>;
    return rows
      .map((r) => r.name)
      .filter((n) => KEBAB_CASE_RE.test(n));
  }

  /**
   * v0.2.3 #3 — vocab hit telemetry 의 tagId 확보용. updateAiResult 후 호출 보장.
   * tags.name COLLATE NOCASE 라 case-insensitive lookup.
   */
  getTagIdByName(name: string): number | null {
    const row = this.db
      .prepare(`SELECT id FROM tags WHERE name = ? COLLATE NOCASE LIMIT 1`)
      .get(name) as { id: number } | undefined;
    return row ? row.id : null;
  }

  updateUserAiFields(
    id: string,
    fields: { title?: string; summary?: string; tags?: string[] }
  ): void {
    const now = new Date().toISOString();
    const tx = this.db.transaction(() => {
      const updates: string[] = [];
      const params: unknown[] = [];
      if (fields.title !== undefined) {
        updates.push('ai_title=?');
        updates.push('title_edited_by_user=1');
        params.push(fields.title);
      }
      if (fields.summary !== undefined) {
        updates.push('ai_summary=?');
        updates.push('summary_edited_by_user=1');
        params.push(fields.summary);
      }
      if (updates.length > 0) {
        updates.push('updated_at=?');
        params.push(now);
        params.push(id);
        this.db.prepare(`UPDATE notes SET ${updates.join(', ')} WHERE id=?`).run(...params);
      }
      if (fields.tags !== undefined) {
        this.db.prepare(`DELETE FROM note_tags WHERE note_id=?`).run(id);
        const getOrInsert = this.db.prepare(
          `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
        );
        const link = this.db.prepare(
          `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
        );
        for (const t of fields.tags) {
          const row = getOrInsert.get(t) as { id: number };
          link.run(id, row.id);
        }
        this.rebuildFtsTagsForNote(id);
      }
    });
    tx();
  }

  setIntent(id: string, text: string): void {
    const now = new Date().toISOString();
    this.db
      .prepare(
        `UPDATE notes
            SET user_intent = ?,
                intent_prompted_at = COALESCE(intent_prompted_at, ?),
                updated_at = ?
          WHERE id = ?`
      )
      .run(text.slice(0, 200), now, now, id);
  }

  dismissIntent(id: string): void {
    const now = new Date().toISOString();
    this.db
      .prepare(
        `UPDATE notes
            SET intent_prompted_at = COALESCE(intent_prompted_at, ?),
                updated_at = ?
          WHERE id = ?`
      )
      .run(now, now, id);
  }

  setDueDate(id: string, date: string | null): void {
    const now = new Date().toISOString();
    this.db
      .prepare(
        `UPDATE notes SET due_date = ?, due_date_edited_by_user = 1, updated_at = ? WHERE id = ?`
      )
      .run(date, now, id);
  }

  trash(id: string, deletedAt: string): void {
    const tx = this.db.transaction(() => {
      this.db
        .prepare(`UPDATE notes SET deleted_at = ?, updated_at = ? WHERE id = ?`)
        .run(deletedAt, deletedAt, id);
      this.db.prepare(`DELETE FROM pending_jobs WHERE note_id = ?`).run(id);
    });
    tx();
  }

  /**
   * Atomically transition a batch of notes from active → trash.
   * Returns the number of notes that actually transitioned (i.e. were active
   * before the call). Already-trashed and unknown ids are silent skips —
   * counting them would inflate `expired_batch_trash` telemetry.
   *
   * Reuses `trash(id, deletedAt)` per row to inherit pending_jobs cleanup
   * invariant (§9.2 of #4 spec).
   */
  trashBatch(ids: string[], deletedAt: string): { trashedCount: number } {
    if (ids.length === 0) return { trashedCount: 0 };
    let trashedCount = 0;
    const tx = this.db.transaction((batch: string[]) => {
      for (const id of batch) {
        const row = this.db
          .prepare(`SELECT deleted_at FROM notes WHERE id = ?`)
          .get(id) as { deleted_at: string | null } | undefined;
        if (!row || row.deleted_at !== null) continue;
        this.trash(id, deletedAt);
        trashedCount += 1;
      }
    });
    tx(ids);
    return { trashedCount };
  }

  restore(id: string): void {
    const now = new Date().toISOString();
    this.db
      .prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`)
      .run(now, id);
  }

  /**
   * v0.2.10 Cut C — 사용자가 raw_text 정정. notes.raw_text 갱신 + note_revisions 에
   * edited_by='user' 새 row INSERT. 단일 transaction. 호출자 `now` 주입 가능 (테스트성).
   *
   * 옛 raw_text 는 backfill (m006) 으로 capture revision 에 이미 보존됨.
   */
  updateRawText(id: string, newText: string, now: Date = new Date()): void {
    const ts = now.toISOString();
    const tx = this.db.transaction(() => {
      this.db
        .prepare(`UPDATE notes SET raw_text=?, updated_at=? WHERE id=?`)
        .run(newText, ts, id);
      this.db
        .prepare(
          `INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
           VALUES (?, ?, ?, 'user')`
        )
        .run(id, newText, ts);
    });
    tx();
  }

  /**
   * v0.2.10 Cut C — 노트의 모든 revision (capture + user) 을 최신순 반환.
   * NoteCard 의 "이력" modal 에서 사용. edited_at DESC + rev_id DESC tiebreak.
   */
  listRevisions(id: string): NoteRevision[] {
    const rows = this.db
      .prepare(
        `SELECT rev_id, note_id, raw_text, edited_at, edited_by
           FROM note_revisions
          WHERE note_id = ?
          ORDER BY edited_at DESC, rev_id DESC`
      )
      .all(id) as Array<{
        rev_id: number;
        note_id: string;
        raw_text: string;
        edited_at: string;
        edited_by: 'user' | 'capture';
      }>;
    return rows.map((r) => ({
      revId: r.rev_id,
      noteId: r.note_id,
      rawText: r.raw_text,
      editedAt: r.edited_at,
      editedBy: r.edited_by
    }));
  }

  /**
   * v0.2.10 Cut C — 옛 revision 의 raw_text 를 latest 로 복원. chain 끊지 않고
   * 새 user revision 으로 INSERT (linear history 유지). revId 가 해당 note 의 것이
   * 아니면 throw — restore 대상 잘못 매칭 방지.
   */
  restoreRevision(id: string, revId: number, now: Date = new Date()): void {
    const rev = this.db
      .prepare(`SELECT raw_text FROM note_revisions WHERE rev_id=? AND note_id=?`)
      .get(revId, id) as { raw_text: string } | undefined;
    if (!rev) throw new Error(`revision ${revId} not found for note ${id}`);
    this.updateRawText(id, rev.raw_text, now);
  }

  /**
   * v0.2.9 Cut B — 노트 status 4분기 전이 (active/completed/archived/trashed).
   * status + status_changed_at + move_reason + updated_at 갱신 + deleted_at
   * backward-compat 동기화 (status='trashed' → deleted_at=ts, 그 외 → NULL).
   *
   * 단일 transaction. 호출자가 `now` 주입 가능 (테스트성).
   */
  setStatus(
    id: string,
    status: NoteStatus,
    reason: string | null,
    now: Date = new Date()
  ): void {
    const tx = this.db.transaction(() => {
      const ts = now.toISOString();
      this.db
        .prepare(
          `UPDATE notes
              SET status = ?,
                  move_reason = ?,
                  status_changed_at = ?,
                  updated_at = ?
            WHERE id = ?`
        )
        .run(status, reason, ts, ts, id);
      // backward compat: deleted_at 컬럼은 m004 이후로도 status='trashed' 와 동기화.
      if (status === 'trashed') {
        this.db.prepare(`UPDATE notes SET deleted_at = ? WHERE id = ?`).run(ts, id);
      } else {
        this.db.prepare(`UPDATE notes SET deleted_at = NULL WHERE id = ?`).run(id);
      }
    });
    tx();
  }

  /**
   * v0.2.9 Cut B Task 4 — status 별 row count. 4탭 헤더 badge 용.
   * tags/media hydrate 없음 (cheap path, listByStatus 와 별도).
   */
  countByStatus(status: NoteStatus): number {
    const row = this.db
      .prepare(`SELECT COUNT(*) AS c FROM notes WHERE status = ?`)
      .get(status) as { c: number };
    return row.c;
  }

  /**
   * v0.2.9 Cut B — status 별 노트 목록. status_changed_at DESC (최근 전이 우선),
   * NULL 은 created_at fallback. limit cap 200 (list/listTrashed 와 동일).
   */
  listByStatus(status: NoteStatus, opts: { limit?: number } = {}): Note[] {
    const limit = Math.max(1, Math.min(200, opts.limit ?? 200));
    const rows = this.db
      .prepare(
        `SELECT * FROM notes
          WHERE status = ?
          ORDER BY COALESCE(status_changed_at, created_at) DESC, id DESC
          LIMIT ?`
      )
      .all(status, limit) as Record<string, unknown>[];
    return rows.map((r) => this.hydrate(r));
  }

  /**
   * v0.2.11 Cut D — FTS5 검색. notes_fts MATCH + rank 정렬 + 기본 trashed 제외.
   * 빈/공백 query → []. multi-token 은 implicit AND. FTS5 special chars 는 sanitize.
   */
  search(query: string, opts: { limit?: number; status?: NoteStatus } = {}): Note[] {
    const sanitized = sanitizeFtsQuery(query);
    if (sanitized.length === 0) return [];
    const limit = Math.max(1, Math.min(200, opts.limit ?? 50));
    const statusClause = opts.status ? `AND n.status = ?` : `AND n.status != 'trashed'`;
    const sql = `
      SELECT n.* FROM notes n
      JOIN notes_fts f ON n.id = f.note_id
      WHERE notes_fts MATCH ? ${statusClause}
      ORDER BY rank
      LIMIT ?
    `;
    const args: unknown[] = opts.status ? [sanitized, opts.status, limit] : [sanitized, limit];
    const rows = this.db.prepare(sql).all(...args) as Record<string, unknown>[];
    return rows.map((r) => this.hydrate(r));
  }

  /**
   * v0.2.11 Cut D — 회고 view aggregate. period 별 KST 자정 cutoff 이후 노트
   * (status != 'trashed') 의 totalCount / recentNotes(50) / tagCounts(DESC) /
   * dueProgress(passed/pending KST today 기준).
   */
  reviewAggregate(period: ReviewPeriod, now: Date = new Date()): {
    totalCount: number;
    recentNotes: Note[];
    tagCounts: Array<{ tag: string; count: number }>;
    dueProgress: { total: number; passed: number; pending: number };
  } {
    const cutoff = computeCutoff(period, now);
    const todayIso = kstTodayIso(now);

    const totalCount = (this.db
      .prepare(`SELECT COUNT(*) AS c FROM notes WHERE created_at >= ? AND status != 'trashed'`)
      .get(cutoff) as { c: number }).c;

    const recentRows = this.db
      .prepare(
        `SELECT * FROM notes
          WHERE created_at >= ? AND status != 'trashed'
          ORDER BY created_at DESC, id DESC LIMIT 50`
      )
      .all(cutoff) as Record<string, unknown>[];
    const recentNotes = recentRows.map((r) => this.hydrate(r));

    const tagCounts = this.db
      .prepare(
        `SELECT t.name AS tag, COUNT(*) AS count
           FROM note_tags nt
           JOIN notes n ON n.id = nt.note_id
           JOIN tags t ON t.id = nt.tag_id
          WHERE n.created_at >= ? AND n.status != 'trashed'
          GROUP BY t.id
          ORDER BY count DESC, t.name ASC`
      )
      .all(cutoff) as Array<{ tag: string; count: number }>;

    const dueRow = this.db
      .prepare(
        `SELECT
           COUNT(*) AS total,
           SUM(CASE WHEN due_date < ? THEN 1 ELSE 0 END) AS passed,
           SUM(CASE WHEN due_date >= ? THEN 1 ELSE 0 END) AS pending
         FROM notes
         WHERE created_at >= ?
           AND status != 'trashed'
           AND due_date IS NOT NULL`
      )
      .get(todayIso, todayIso, cutoff) as { total: number; passed: number | null; pending: number | null };
    const dueProgress = {
      total: dueRow.total,
      passed: dueRow.passed ?? 0,
      pending: dueRow.pending ?? 0
    };

    return { totalCount, recentNotes, tagCounts, dueProgress };
  }

  /**
   * 휴지통에서 active 로 복원. setStatus('active') 로 status + deleted_at 동기화 +
   * v0.2.6 #10 round 1 fix 보존 (ai_status='failed' / 'pending' 시 pending_jobs 재투입).
   */
  restoreNote(id: string): void {
    const tx = this.db.transaction(() => {
      const before = this.db
        .prepare(`SELECT ai_status FROM notes WHERE id = ?`)
        .get(id) as { ai_status: string } | undefined;
      // setStatus('active', null) — reason clear + deleted_at NULL + updated_at 갱신.
      this.setStatus(id, 'active', null);
      const now = new Date().toISOString();

      // v0.2.6 #10 — failed 노트 restore 시 pending 으로 reset + pending_jobs 재생성
      if (before?.ai_status === 'failed') {
        this.db
          .prepare(
            `UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`
          )
          .run(now, id);
        this.db
          .prepare(
            `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
          )
          .run(id, now);
      } else if (before?.ai_status === 'pending') {
        // pending 인 채로 trash 됐다면 pending_jobs 도 미정상 상태일 수 있음 — 재생성 (idempotent)
        this.db
          .prepare(
            `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
          )
          .run(id, now);
      }
      // done 노트는 재처리 안 함 (이미 결과 있음)
    });
    tx();
  }

  permanentDelete(id: string): void {
    this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
  }

  emptyTrash(): { noteIds: string[] } {
    // Single DELETE ... RETURNING is atomic by itself (no explicit transaction needed)
    // and avoids per-row prepare overhead. RETURNING is house-style elsewhere
    // (updateAiResult/updateUserAiFields/getAllPendingJobs).
    const rows = this.db
      .prepare('DELETE FROM notes WHERE deleted_at IS NOT NULL RETURNING id')
      .all() as Array<{ id: string }>;
    return { noteIds: rows.map((r) => r.id) };
  }

  listTrashed(opts: { limit: number }): Note[] {
    const limit = Math.max(1, Math.min(200, opts.limit));
    const rows = this.db
      .prepare(`SELECT * FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT ?`)
      .all(limit) as Record<string, unknown>[];
    return rows.map((r) => this.hydrate(r));
  }

  /**
   * Cheap COUNT for trash UI badge / bulk-empty dialog. Does not hydrate
   * tags/media — used in hot paths (loadInitial / refreshMeta / upsertNote
   * follow-ups) where listTrashed() is wasteful.
   */
  countTrashed(): number {
    const row = this.db
      .prepare(`SELECT COUNT(*) AS c FROM notes WHERE deleted_at IS NOT NULL`)
      .get() as { c: number };
    return row.c;
  }

  /** @deprecated v0.2.3 #4 부터 hard delete 는 permanentDelete() 사용. soft delete 는 trash(). 본 메서드는 v0.2.4 에서 제거 예정. */
  delete(id: string): void {
    this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
  }

  findRawTextById(id: string): string | null {
    const row = this.db.prepare('SELECT raw_text FROM notes WHERE id=?').get(id) as
      | { raw_text: string }
      | undefined;
    return row?.raw_text ?? null;
  }

  /**
   * Import a note from an external source (F5 export tree).
   * Conflict policy (fork-on-id-collision):
   *  - id missing in DB → INSERT (status: 'inserted')
   *  - id present + raw_text identical → no-op (status: 'skipped')
   *  - id present + raw_text differs → INSERT under fresh uuidv7 so the same id
   *    never points at two different baselines (status: 'forked'). v0.2.10 Cut C
   *    relaxed the `raw_text 불변` policy → `raw_text 가변 + note_revisions 보존`,
   *    but per-id baseline distinction is still required for sync determinism.
   *
   * v0.2.10 Cut C — INSERT/fork 시 동일 transaction 안에서 note_revisions 에
   * 'capture' 첫 revision INSERT (createdAt = edited_at). 미수행 시 first user
   * edit 직후 import 시점 본문이 history 에서 사라지는 회귀 (final review 발견).
   *
   * v0.2.11 Cut D — INSERT/fork 시 tags 추가 후 rebuildFtsTagsForNote(finalId)
   * 호출 — m007 trigger 가 빈 tags='' 로 FTS row 만들고, note_tags INSERT 만으로는
   * notes_fts.tags 갱신 안 됨. 미수행 시 import 한 노트가 tag keyword 검색에서
   * 매칭 안 되는 회귀 (final review 발견).
   *
   * deletedAt merge (v0.2.3 #4, spec §8.2): source/dest 중 IS NOT NULL 우선
   * (삭제 보존). skip 케이스에서 source NN + dest NULL 일 때만 dest 갱신.
   * insert/fork 는 source 의 deletedAt 그대로 보존.
   */
  importNote(input: ImportNoteInput): ImportNoteResult {
    const existing = this.findRawTextById(input.id);
    let finalId = input.id;
    let status: ImportNoteStatus = 'inserted';
    if (existing !== null) {
      if (existing === input.rawText) {
        // skip — source 가 deletedAt IS NOT NULL 이고 dest 가 NULL 이면 dest 갱신 (삭제 보존).
        // trash() 를 재사용해 pending_jobs cleanup invariant (§9.2) 도 동시에 만족.
        if (input.deletedAt != null) {
          const destRow = this.db
            .prepare('SELECT deleted_at FROM notes WHERE id=?')
            .get(input.id) as { deleted_at: string | null } | undefined;
          if (destRow && destRow.deleted_at === null) {
            this.trash(input.id, input.deletedAt);
          }
        }
        return { id: input.id, status: 'skipped' };
      }
      finalId = uuidv7();
      status = 'forked';
    }
    const tx = this.db.transaction(() => {
      this.db
        .prepare(
          `INSERT INTO notes
             (id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at,
              title_edited_by_user, summary_edited_by_user,
              user_intent, intent_prompted_at, deleted_at, created_at, updated_at)
           VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?)`
        )
        .run(
          finalId,
          input.rawText,
          input.aiTitle,
          input.aiSummary,
          input.aiProvider,
          input.aiGeneratedAt,
          input.titleEditedByUser ? 1 : 0,
          input.summaryEditedByUser ? 1 : 0,
          input.userIntent,
          input.intentPromptedAt,
          input.deletedAt ?? null,
          input.createdAt,
          input.updatedAt
        );
      this.db
        .prepare(
          `INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
           VALUES (?, ?, ?, 'capture')`
        )
        .run(finalId, input.rawText, input.createdAt);
      if (input.tags.length > 0) {
        const getOrInsertTag = this.db.prepare(
          `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
        );
        const linkAi = this.db.prepare(
          `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
        );
        const linkUser = this.db.prepare(
          `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
        );
        for (const t of input.tags) {
          const row = getOrInsertTag.get(t.name) as { id: number };
          if (t.source === 'ai') linkAi.run(finalId, row.id);
          else linkUser.run(finalId, row.id);
        }
        // v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 동기화 (single write path).
        this.rebuildFtsTagsForNote(finalId);
      }
    });
    tx();
    return { id: finalId, status };
  }

  /**
   * v0.3.0 Cut E — sync 전용 upsert. 기존 importNote 의 fork-on-id-collision 정책은
   * sync 에 부적합 (양 기기 raw_text 가 다를 때마다 fork → 노트 갯수 무한 증가).
   *
   * 3 분기:
   * - id 없음 → INSERT (capture revision + tags FTS sync)
   * - id 있음 + raw_text 동일 → source.updatedAt 가 더 최신일 때만 metadata 갱신
   * - id 있음 + raw_text 다름 → source 가 더 최신이면 updateRawText (new user revision),
   *   local 이 더 최신이면 skip
   *
   * tags 변경 시 rebuildFtsTagsForNote 호출 — Cut D single write path 재사용.
   * raw_text 변경 시 updateRawText 호출 — Cut C single write path 재사용.
   */
  upsertFromSync(input: UpsertFromSyncInput): { id: string; status: UpsertFromSyncStatus } {
    const existing = this.db
      .prepare(`SELECT raw_text, updated_at, status FROM notes WHERE id=?`)
      .get(input.id) as { raw_text: string; updated_at: string; status: NoteStatus } | undefined;

    if (!existing) {
      // INSERT path
      const tx = this.db.transaction(() => {
        this.db
          .prepare(
            `INSERT INTO notes
               (id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at,
                title_edited_by_user, summary_edited_by_user,
                user_intent, intent_prompted_at,
                created_at, updated_at,
                due_date, due_date_edited_by_user,
                status, status_changed_at, move_reason)
             VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
          )
          .run(
            input.id,
            input.rawText,
            input.aiTitle,
            input.aiSummary,
            input.aiProvider,
            input.aiGeneratedAt,
            input.titleEditedByUser ? 1 : 0,
            input.summaryEditedByUser ? 1 : 0,
            input.userIntent,
            input.intentPromptedAt,
            input.createdAt,
            input.updatedAt,
            input.dueDate,
            input.dueDateEditedByUser ? 1 : 0,
            input.status,
            input.statusChangedAt,
            input.moveReason
          );
        this.db
          .prepare(
            `INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
             VALUES (?, ?, ?, 'capture')`
          )
          .run(input.id, input.rawText, input.createdAt);
        if (input.tags.length > 0) {
          const getOrInsertTag = this.db.prepare(
            `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
          );
          const linkAi = this.db.prepare(
            `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
          );
          const linkUser = this.db.prepare(
            `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
          );
          for (const t of input.tags) {
            const row = getOrInsertTag.get(t.name) as { id: number };
            if (t.source === 'ai') linkAi.run(input.id, row.id);
            else linkUser.run(input.id, row.id);
          }
          this.rebuildFtsTagsForNote(input.id);
        }
      });
      tx();
      return { id: input.id, status: 'inserted' };
    }

    if (input.updatedAt <= existing.updated_at) {
      return { id: input.id, status: 'skipped' };
    }

    if (existing.raw_text !== input.rawText) {
      this.updateRawText(input.id, input.rawText, new Date(input.updatedAt));
    }

    const tx = this.db.transaction(() => {
      this.db
        .prepare(
          `UPDATE notes
              SET ai_title = CASE WHEN title_edited_by_user = 1 THEN ai_title ELSE ? END,
                  ai_summary = CASE WHEN summary_edited_by_user = 1 THEN ai_summary ELSE ? END,
                  ai_provider = ?,
                  ai_generated_at = ?,
                  due_date = CASE WHEN due_date_edited_by_user = 1 THEN due_date ELSE ? END,
                  status = ?,
                  status_changed_at = ?,
                  move_reason = ?,
                  updated_at = ?
            WHERE id = ?`
        )
        .run(
          input.aiTitle,
          input.aiSummary,
          input.aiProvider,
          input.aiGeneratedAt,
          input.dueDate,
          input.status,
          input.statusChangedAt,
          input.moveReason,
          input.updatedAt,
          input.id
        );
      this.db.prepare(`DELETE FROM note_tags WHERE note_id=?`).run(input.id);
      if (input.tags.length > 0) {
        const getOrInsertTag = this.db.prepare(
          `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
        );
        const linkAi = this.db.prepare(
          `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
        );
        const linkUser = this.db.prepare(
          `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
        );
        for (const t of input.tags) {
          const row = getOrInsertTag.get(t.name) as { id: number };
          if (t.source === 'ai') linkAi.run(input.id, row.id);
          else linkUser.run(input.id, row.id);
        }
      }
      this.rebuildFtsTagsForNote(input.id);
    });
    tx();
    return { id: input.id, status: 'updated' };
  }

  getPendingCount(): number {
    const row = this.db
      .prepare(
        `SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending' AND deleted_at IS NULL`
      )
      .get() as { c: number };
    return row.c;
  }

  /**
   * Count notes whose `created_at` falls on the KST calendar date of `now`.
   * KST = UTC+9. We compute the UTC half-open interval
   *   [KST-midnight today, KST-midnight tomorrow)
   * and count rows whose UTC ISO `created_at` lies inside.
   */
  countToday(now: Date = new Date()): number {
    const kstNow = new Date(now.getTime() + KST_OFFSET_MS);
    const kstYear = kstNow.getUTCFullYear();
    const kstMonth = kstNow.getUTCMonth();
    const kstDate = kstNow.getUTCDate();
    const kstMidnightUtc = Date.UTC(kstYear, kstMonth, kstDate) - KST_OFFSET_MS;
    const nextKstMidnightUtc = kstMidnightUtc + 24 * 60 * 60 * 1000;
    const startIso = new Date(kstMidnightUtc).toISOString();
    const endIso = new Date(nextKstMidnightUtc).toISOString();
    const row = this.db
      .prepare(
        `SELECT COUNT(*) AS c FROM notes
          WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?`
      )
      .get(startIso, endIso) as { c: number };
    return row.c;
  }

  /**
   * Notes whose due_date is strictly before today (KST calendar) and that are
   * still active (not trashed) and AI-processed. Includes both AI-extracted and
   * user-edited due_date (v0.2.3 #5 spec §1 Q1=B).
   *
   * Caller may inject `now` for testability; defaults to `new Date()`.
   */
  findExpiredCandidates(now: Date = new Date()): Note[] {
    const today = kstTodayIso(now);
    const rows = this.db
      .prepare(
        `SELECT * FROM notes
          WHERE due_date IS NOT NULL
            AND due_date < ?
            AND deleted_at IS NULL
            AND ai_status = 'done'
          ORDER BY created_at DESC, id DESC`
      )
      .all(today) as Record<string, unknown>[];
    return rows.map((r) => this.hydrate(r));
  }

  getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> {
    const rows = this.db
      .prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`)
      .all() as Record<string, unknown>[];
    return rows.map((r) => ({
      noteId: r.note_id as string,
      attempts: r.attempts as number,
      nextRunAt: r.next_run_at as string
    }));
  }

  incrementJobAttempt(noteId: string, nextRunAt: string, lastError: string): void {
    this.db
      .prepare(
        `UPDATE pending_jobs
            SET attempts = attempts + 1,
                next_run_at = ?,
                last_error = ?
          WHERE note_id = ?`
      )
      .run(nextRunAt, lastError.slice(0, 500), noteId);
  }

  /**
   * v0.2.11 Cut D — note_tags 변경 후 notes_fts.tags 컬럼 (csv) 재구성.
   * 단일 write path 패턴: tags 변경하는 모든 메서드가 같은 transaction 끝에서 호출.
   */
  private rebuildFtsTagsForNote(noteId: string): void {
    const row = this.db
      .prepare(
        `SELECT COALESCE(GROUP_CONCAT(t.name, ' '), '') AS csv
           FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
          WHERE nt.note_id = ?`
      )
      .get(noteId) as { csv: string };
    this.db
      .prepare(`UPDATE notes_fts SET tags = ? WHERE note_id = ?`)
      .run(row.csv, noteId);
  }

  private hydrate(row: Record<string, unknown>): Note {
    const tags = this.db
      .prepare(
        `SELECT t.name, nt.source
           FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
          WHERE nt.note_id = ? ORDER BY t.name`
      )
      .all(row.id as string) as Array<{ name: string; source: 'ai' | 'user' }>;
    const media = this.db
      .prepare(
        `SELECT id, kind, rel_path as relPath, mime, bytes FROM media WHERE note_id=?`
      )
      .all(row.id as string) as NoteMedia[];
    return {
      id: row.id as string,
      rawText: row.raw_text as string,
      aiTitle: row.ai_title as string | null,
      aiSummary: row.ai_summary as string | null,
      aiStatus: row.ai_status as AiStatus,
      aiError: row.ai_error as string | null,
      aiProvider: row.ai_provider as string | null,
      aiGeneratedAt: row.ai_generated_at as string | null,
      titleEditedByUser: (row.title_edited_by_user as number) === 1,
      summaryEditedByUser: (row.summary_edited_by_user as number) === 1,
      userIntent: row.user_intent as string | null,
      intentPromptedAt: row.intent_prompted_at as string | null,
      dueDate: (row.due_date as string | null) ?? null,
      dueDateEditedByUser: (row.due_date_edited_by_user as number) === 1,
      deletedAt: (row.deleted_at as string | null) ?? null,
      lastRecalledAt: (row.last_recalled_at as string | null) ?? null,
      recallDismissedAt: (row.recall_dismissed_at as string | null) ?? null,
      status: ((row.status as NoteStatus | undefined) ?? 'active'),
      statusChangedAt: (row.status_changed_at as string | null) ?? null,
      moveReason: (row.move_reason as string | null) ?? null,
      createdAt: row.created_at as string,
      updatedAt: row.updated_at as string,
      tags: tags as NoteTag[],
      media
    };
  }
}
