// ============================================================================
// extensions/imessage-triage/imessage-bridge.ts — iMessage Bridge
//
// Reads iMessages directly from ~/Library/Messages/chat.db via better-sqlite3
// and sends messages via AppleScript. Wraps raw DB rows into TriageItem shape.
//
// Requirements:
//   - macOS Full Disk Access granted to the process/terminal
//   - Messages.app must be installed (standard on macOS)
//   - better-sqlite3 must be installed
// ============================================================================

import { createRequire } from 'module';
import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { appendFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import type {
  TriageItem,
  TriagePlatform,
  MessageDirection,
  TriagePriority,
  TriageCategory,
  SuggestedAction,
} from '../../lib/triage-core/types.ts';
import {
  fetchAttachmentsForMessages,
  buildAttachmentSummary,
  isAttachmentOnlyBody,
  type AttachmentInfo,
} from '../../lib/triage-core/mental-model/attachment-handler.ts';
import {
  ALLOW_SEND_ENV,
  imessageSendGate,
  isGlobalSendAllowed,
} from './gates/send-gate.ts';

const require = createRequire(import.meta.url);

// ============================================================================
// Error types
// ============================================================================

export class IMessageBridgeError extends Error {
  public readonly code:
    | 'FDA_DENIED'
    | 'DB_INACCESSIBLE'
    | 'QUERY_FAILED'
    | 'SEND_FAILED'
    | 'RATE_LIMITED'
    | 'VALIDATION_ERROR'
    | 'NOT_INITIALIZED';
  public readonly cause?: unknown;

  constructor(
    message: string,
    code:
      | 'FDA_DENIED'
      | 'DB_INACCESSIBLE'
      | 'QUERY_FAILED'
      | 'SEND_FAILED'
      | 'RATE_LIMITED'
      | 'VALIDATION_ERROR'
      | 'NOT_INITIALIZED',
    cause?: unknown,
  ) {
    super(message);
    this.code = code;
    this.cause = cause;
    this.name = 'IMessageBridgeError';
  }
}

// ============================================================================
// Internal types
// ============================================================================

interface MessageRow {
  ROWID: number;
  text: string | null;
  date: number;
  is_from_me: number;
  handle_id: number;
  handle: string;
  cache_roomnames: string | null;
  attributedBody: Buffer | null;
  /** Populated after fetching attachment metadata — not from the DB row directly */
  _attachments?: AttachmentInfo[];
}

interface HealthStatus {
  fdaGranted: boolean;
  messagesRunning: boolean;
  dbAccessible: boolean;
  lastPollAt: string | null;
}

export interface DeleteResult {
  deleted: string[];
  failed: string[];
  syncEntriesAdded: number;
  backupPath: string;
}

// ============================================================================
// Mac epoch helpers
// ============================================================================

/**
 * Mac epoch: nanoseconds since 2001-01-01 00:00:00 UTC.
 * Unix epoch offset = 978307200 seconds.
 */
export function macDateToJs(macDate: number): Date {
  return new Date(macDate / 1e9 * 1000 + 978307200000);
}

/**
 * JS Date → Mac epoch (nanoseconds since 2001-01-01)
 */
export function jsDateToMac(d: Date): number {
  return (d.getTime() - 978307200000) * 1e6; // ms → ns
}

// ============================================================================
// Text validation helper
// ============================================================================

/**
 * Validate that extracted text looks like natural human message text,
 * not HTML, JSON, URLs, or metadata.
 */
export function looksLikeNaturalText(s: string): boolean {
  if (!s || s.length < 2) return false;
  // Reject if it starts with { or < or [ (JSON/HTML/array)
  if (/^[\[{<]/.test(s.trim())) return false;
  // Reject if it's a URL
  if (/^https?:\/\//i.test(s.trim())) return false;
  // Reject if >60% non-letter characters (likely metadata)
  const letters = (s.match(/[a-zA-Z\s]/g) || []).length;
  if (letters / s.length < 0.4) return false;
  return true;
}

// ============================================================================
// NSAttributedString plain-text extractor
// ============================================================================

/**
 * Extract plain text from an NSAttributedString blob stored in attributedBody.
 *
 * The plist binary format for NSAttributedString has the plain text as a UTF-8
 * string embedded starting after the "NSString" class reference. We use a
 * heuristic scan: look for the 0x01 byte (NSObject root marker), then read
 * the null-terminated or length-prefixed string that follows the string class
 * name in the plist. A robust fallback is to decode any contiguous run of
 * printable ASCII/UTF-8 bytes after the plist header.
 *
 * This handles the common case without requiring a full plist parser.
 */
export function extractTextFromAttributedBody(blob: Buffer): string | null {
  if (!blob || blob.length === 0) return null;

  // Strategy 1: Use @parseaple/typedstream parser (robust for emoji, reactions, Unicode)
  try {
    // eslint-disable-next-line @typescript-eslint/no-var-requires
    const { Unarchiver, NSAttributedString } = require('@parseaple/typedstream');
    const buffer = Buffer.isBuffer(blob) ? blob : Buffer.from(blob);
    const root = Unarchiver.open(buffer, Unarchiver.BinaryDecoding.decodable).decodeSingleRoot();
    if (root instanceof NSAttributedString && root.string) {
      return root.string;
    }
  } catch {
    // SDK parser failed — fall through to heuristic
  }

  // Strategy 2 (fallback): Heuristic UTF-8 scan for longest printable run
  try {
    const NS_BLOCKLIST = ['NS.objects', 'NSString', 'NSMutableString', 'NSDictionary', 'NSArray', 'NSAttributedString', 'NSMutableAttributedString', 'NS.keys', 'NS.bytes', '$class', '$classname'];

    const str = blob.toString('utf8');
    const runs = str.split(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]+/)
      .map(s => s.replace(/[^\x09\x0a\x20-\x7e\u00a0-\ufffd]/g, ''))
      .filter(s => s.trim().length >= 4);

    if (runs.length === 0) return null;

    const sorted = [...runs].sort((a, b) => b.length - a.length);
    const best = sorted[0]!.trim();
    if (!NS_BLOCKLIST.includes(best)) {
      if (!looksLikeNaturalText(best)) return null;
      return best || null;
    }

    const second = sorted[1]?.trim();
    if (second && !NS_BLOCKLIST.includes(second)) {
      if (!looksLikeNaturalText(second)) return null;
      return second || null;
    }

    return '';
  } catch {
    return null;
  }
}

// ============================================================================
// AppleScript escaping
// ============================================================================

// Escape text for use inside a double-quoted AppleScript string.
// Single quotes (') are safe inside double-quoted AS strings — no escaping needed.
// Shell injection is prevented upstream by execFileSync with an args array.
export function escapeForAppleScript(text: string): string {
  return text
    .replace(/\\/g, '\\\\')    // backslash first
    .replace(/"/g, '\\"')      // double-quotes (string delimiters)
    .replace(/\t/g, '\\t')     // tabs
    .replace(/\r?\n/g, '\\n'); // newlines
}

// ============================================================================
// Rate limiter (5 sends/minute sliding window)
// ============================================================================

class SendRateLimiter {
  private readonly maxPerMinute: number;
  private readonly windowMs: number;
  private timestamps: number[] = [];

  constructor(maxPerMinute = 5) {
    this.maxPerMinute = maxPerMinute;
    this.windowMs = 60_000;
  }

  check(): void {
    const now = Date.now();
    this.timestamps = this.timestamps.filter(t => now - t < this.windowMs);
    if (this.timestamps.length >= this.maxPerMinute) {
      const oldestInWindow = this.timestamps[0]!;
      const retryAfterMs = this.windowMs - (now - oldestInWindow);
      const retryAfterSec = Math.ceil(retryAfterMs / 1000);
      throw new IMessageBridgeError(
        `Rate limit exceeded. Please try again in ${retryAfterSec}s`,
        'RATE_LIMITED',
      );
    }
  }

  record(): void {
    this.timestamps.push(Date.now());
  }
}

// ============================================================================
// IMessageBridge
// ============================================================================

const DB_PATH = `${process.env.HOME}/Library/Messages/chat.db`;

const UNREAD_QUERY = `
  SELECT
    m.ROWID,
    m.text,
    m.date,
    m.is_from_me,
    m.handle_id,
    h.id AS handle,
    m.cache_roomnames,
    m.attributedBody
  FROM message m
  JOIN handle h ON m.handle_id = h.ROWID
  WHERE m.date > ?
    AND m.is_from_me = 0
  ORDER BY m.date DESC
  LIMIT ?
`;

const HEALTH_TEST_QUERY = 'SELECT 1 AS ok FROM message LIMIT 1';

export class IMessageBridge {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private db: any | null = null;
  private lastPollAt: string | null = null;
  private readonly rateLimiter = new SendRateLimiter(5);
  private disposed = false;

  // -------------------------------------------------------------------------
  // Lifecycle
  // -------------------------------------------------------------------------

  /** Open the chat.db connection. Must be called before any data methods. */
  async init(): Promise<void> {
    if (this.disposed) {
      throw new IMessageBridgeError('Bridge has been disposed', 'NOT_INITIALIZED');
    }
    if (this.db) return; // idempotent

    const isBun = typeof (globalThis as any).Bun !== 'undefined';

    if (isBun) {
      // Use bun:sqlite (native, no npm dependency needed)
      try {
        const { Database } = await import('bun:sqlite' as string);
        this.db = new Database(DB_PATH, { readonly: true });
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        if (msg.includes('SQLITE_CANTOPEN') || msg.includes('no such file') || msg.includes('unable to open')) {
          throw new IMessageBridgeError(
            'Cannot open ~/Library/Messages/chat.db — ensure Full Disk Access is granted to your terminal in System Settings → Privacy & Security → Full Disk Access.',
            'FDA_DENIED',
            err,
          );
        }
        throw new IMessageBridgeError(
          `Failed to open chat.db: ${msg}`,
          'DB_INACCESSIBLE',
          err,
        );
      }
    } else {
      // Node.js path: use better-sqlite3
      let Database: new (
        filename: string,
        options: { readonly: boolean; fileMustExist: boolean },
      ) => unknown;
      try {
        Database = require('better-sqlite3') as typeof Database;
      } catch (err) {
        throw new IMessageBridgeError(
          'better-sqlite3 not installed — run: npm install better-sqlite3',
          'DB_INACCESSIBLE',
          err,
        );
      }

      try {
        // readonly: true prevents accidental writes to Messages DB
        this.db = new Database(DB_PATH, { readonly: true, fileMustExist: true });
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        if (msg.includes('SQLITE_CANTOPEN') || msg.includes('no such file')) {
          throw new IMessageBridgeError(
            'Cannot open ~/Library/Messages/chat.db — ensure Full Disk Access is granted to your terminal in System Settings → Privacy & Security → Full Disk Access.',
            'FDA_DENIED',
            err,
          );
        }
        throw new IMessageBridgeError(
          `Failed to open chat.db: ${msg}`,
          'DB_INACCESSIBLE',
          err,
        );
      }
    }
  }

  /** Check system prerequisites without throwing. */
  async healthCheck(): Promise<HealthStatus> {
    let fdaGranted = false;
    let messagesRunning = false;
    let dbAccessible = false;

    // 1. FDA: attempt to open the DB file readonly
    const isBun = typeof (globalThis as any).Bun !== 'undefined';
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    let testDb: any | null = null;

    if (isBun) {
      try {
        const { Database } = await import('bun:sqlite' as string);
        testDb = new Database(DB_PATH, { readonly: true });
        fdaGranted = true;
      } catch {
        fdaGranted = false;
      }
    } else {
      let Database: ((...args: unknown[]) => unknown) | null = null;
      try {
        Database = require('better-sqlite3');
      } catch {
        // better-sqlite3 not available — all checks fail
        return { fdaGranted, messagesRunning, dbAccessible, lastPollAt: this.lastPollAt };
      }

      try {
        // eslint-disable-next-line @typescript-eslint/no-unsafe-call
        testDb = new (Database as unknown as new (path: string, opts: Record<string, unknown>) => typeof testDb)(
          DB_PATH,
          { readonly: true, fileMustExist: true },
        );
        fdaGranted = true;
      } catch {
        fdaGranted = false;
      }
    }

    // 2. Messages.app running: pgrep -x Messages returns exit 0 when running
    try {
      execFileSync('pgrep', ['-x', 'Messages'], { timeout: 5000, stdio: 'ignore' });
      messagesRunning = true;
    } catch {
      messagesRunning = false;
    }

    // 3. DB accessible: run a simple SELECT
    if (testDb) {
      try {
        testDb.prepare(HEALTH_TEST_QUERY).get();
        dbAccessible = true;
      } catch {
        dbAccessible = false;
      } finally {
        try { testDb.close(); } catch { /* ignore */ }
      }
    }

    return { fdaGranted, messagesRunning, dbAccessible, lastPollAt: this.lastPollAt };
  }

  // -------------------------------------------------------------------------
  // Read messages
  // -------------------------------------------------------------------------

  /**
   * Return iMessages received after `since`, up to `limit` items.
   * Mapped to TriageItem shape (classification fields left at defaults).
   */
  async getUnread(since: Date, limit = 50): Promise<TriageItem[]> {
    this.assertInitialized();
    this.validateGetUnreadArgs(since, limit);

    const macSince = jsDateToMac(since);
    let rows: MessageRow[];

    try {
      rows = this.db.prepare(UNREAD_QUERY).all(macSince, limit) as MessageRow[];
    } catch (err) {
      throw new IMessageBridgeError(
        `Failed to query chat.db: ${err instanceof Error ? err.message : String(err)}`,
        'QUERY_FAILED',
        err,
      );
    }

    this.lastPollAt = new Date().toISOString();

    // Batch-fetch attachment metadata for all returned messages.
    // This enriches attachment-only messages (no text) with a descriptive body.
    const messageIds = rows.map(r => r.ROWID);
    let attachmentMap = new Map<number, AttachmentInfo[]>();
    try {
      attachmentMap = fetchAttachmentsForMessages(this.db, messageIds);
    } catch {
      // Non-fatal: attachment enrichment fails gracefully — messages still returned
      process.stderr.write('[imessage-bridge] warn: attachment fetch failed, continuing without attachment metadata\n');
    }

    // Attach metadata to rows
    for (const row of rows) {
      row._attachments = attachmentMap.get(row.ROWID) ?? [];
    }

    return rows.map(row => this.rowToTriageItem(row));
  }

  // -------------------------------------------------------------------------
  // Send message
  // -------------------------------------------------------------------------

  /**
   * Send a message to `handle` with HITL governance gate.
   * Blocks until the user approves or timeout is reached (2 minutes).
   * Review is bypassed by HELIOS_SEND_GATE=off only when the global
   * HELIOS_ALLOW_SEND=1 delivery permission is also present.
   *
   * @returns true if the message was sent; false if blocked by the gate
   */
  async sendWithGate(handle: string, text: string): Promise<boolean> {
    this.validateSendArgs(handle, text);

    const gate = await imessageSendGate(handle, text);
    if (!gate.approved) {
      process.stderr.write(`[imessage-bridge] Send blocked by gate: ${gate.reason}\n`);
      return false;
    }

    return this.send(handle, text);
  }

  /**
   * Send a message to `handle` (phone number or Apple ID email).
   * Uses AppleScript via osascript. Retries 3× with exponential backoff.
   * Rate limited to 5 sends/minute.
   *
   * Prefer {@link sendWithGate} for production sends. This method bypasses
   * HITL review but never bypasses the global delivery gate.
   */
  send(handle: string, text: string): boolean {
    this.assertInitialized();
    this.validateSendArgs(handle, text);
    if (!isGlobalSendAllowed()) {
      process.stderr.write(
        `[imessage-bridge] Send blocked (${ALLOW_SEND_ENV} must be exactly "1")\n`,
      );
      return false;
    }
    this.rateLimiter.check();

    const escapedText = escapeForAppleScript(text);
    const escapedHandle = escapeForAppleScript(handle);
    const script = `tell application "Messages" to send "${escapedText}" to buddy "${escapedHandle}"`;

    const delays = [1000, 2000, 4000];
    let lastError: Error | null = null;

    for (let attempt = 0; attempt < 3; attempt++) {
      try {
        execFileSync('osascript', ['-e', script], { timeout: 10000 });
        this.rateLimiter.record();
        return true;
      } catch (err) {
        lastError = err instanceof Error ? err : new Error(String(err));
        process.stderr.write(
          `[imessage-bridge] send attempt ${attempt + 1}/3 failed: ${lastError.message}\n`,
        );
        if (attempt < delays.length) {
          sleepSync(delays[attempt]!);
        }
      }
    }

    throw new IMessageBridgeError(
      `Failed to send iMessage to ${handle} after 3 attempts: ${lastError?.message ?? 'unknown error'}`,
      'SEND_FAILED',
      lastError,
    );
  }

  // -------------------------------------------------------------------------
  // Delete & Block
  // -------------------------------------------------------------------------

  /**
   * Delete conversations from chat.db with iCloud sync support.
   * 
   * This performs headless, iCloud-aware conversation deletion by:
   * 1. Dropping the problematic plugin trigger that references non-existent C functions
   * 2. Deleting the conversations (cascading triggers populate sync_deleted_messages)
   * 3. Restoring the plugin trigger
   * 
   * The sync_deleted_messages table tells iCloud the deletion was intentional,
   * preventing ghost conversations from reappearing after sync.
   * 
   * Safety:
   * - Backs up chat.db before deletion
   * - Validates inputs (max 500 conversations per batch)
   * - Logs all deletions to ~/helios-agent/.planning/imessage-deletion-log.jsonl
   * - Kills and relaunches Messages.app to prevent corruption
   * 
   * @param chatIdentifiers - Array of chat_identifier strings from the chat table
   * @returns DeleteResult with deleted/failed counts and backup path
   */
  async deleteConversations(chatIdentifiers: string[]): Promise<DeleteResult> {
    // Safety: validate inputs
    if (!chatIdentifiers.length) {
      return { deleted: [], failed: [], syncEntriesAdded: 0, backupPath: '' };
    }
    if (chatIdentifiers.length > 500) {
      throw new IMessageBridgeError(
        'Max 500 conversations per batch',
        'VALIDATION_ERROR',
      );
    }

    const backupPath = `${process.env.HOME}/Library/Messages/chat.db.backup-${Date.now()}`;

    // 1. Close our DB connection
    if (this.db) {
      try { this.db.close(); } catch { /* ignore */ }
      this.db = null;
    }

    // 2. Backup
    execFileSync('cp', [DB_PATH, backupPath]);

    // 3. Kill Messages.app
    try {
      execFileSync('killall', ['Messages']);
    } catch {
      // Messages.app not running — that's fine
    }
    sleepSync(1000);

    // 4. Open fresh connection for deletion (read-write mode)
    const Database = require('better-sqlite3');
    // eslint-disable-next-line @typescript-eslint/no-unsafe-call
    const db = new Database(DB_PATH);

    try {
      // 5. Save and drop the problematic plugin trigger
      const triggerRow = db.prepare(
        "SELECT sql FROM sqlite_master WHERE name='after_delete_on_message_plugin'"
      ).get();
      const triggerSql = triggerRow?.sql;
      if (triggerSql) {
        db.exec('DROP TRIGGER IF EXISTS after_delete_on_message_plugin');
      }

      // 6. Get pre-state sync count
      const preSyncCount = db.prepare('SELECT COUNT(*) as cnt FROM sync_deleted_messages').get().cnt;

      // 7. Validate which identifiers actually exist
      const placeholders = chatIdentifiers.map(() => '?').join(',');
      const existing = db.prepare(
        `SELECT chat_identifier FROM chat WHERE chat_identifier IN (${placeholders})`
      ).all(...chatIdentifiers).map((r: { chat_identifier: string }) => r.chat_identifier);

      const failed = chatIdentifiers.filter(id => !existing.includes(id));

      if (existing.length > 0) {
        const existPlaceholders = existing.map(() => '?').join(',');

        // 8. Delete chat_handle_join (not auto-cascaded)
        db.prepare(
          `DELETE FROM chat_handle_join WHERE chat_id IN (SELECT ROWID FROM chat WHERE chat_identifier IN (${existPlaceholders}))`
        ).run(...existing);

        // 9. Delete chats (triggers cascade: chat → chat_message_join → message → sync_deleted_messages)
        db.prepare(
          `DELETE FROM chat WHERE chat_identifier IN (${existPlaceholders})`
        ).run(...existing);
      }

      // 10. Get post-state sync count
      const postSyncCount = db.prepare('SELECT COUNT(*) as cnt FROM sync_deleted_messages').get().cnt;

      // 11. Restore trigger
      if (triggerSql) {
        db.exec(triggerSql);
      }

      // 12. Log deletion
      const logEntry = {
        timestamp: new Date().toISOString(),
        deleted: existing,
        failed,
        syncEntriesAdded: postSyncCount - preSyncCount,
        backupPath,
      };
      const logPath = `${process.env.HOME}/helios-agent/.planning/imessage-deletion-log.jsonl`;
      try {
        mkdirSync(dirname(logPath), { recursive: true });
      } catch {
        // Directory already exists
      }
      appendFileSync(logPath, JSON.stringify(logEntry) + '\n');

      return {
        deleted: existing,
        failed,
        syncEntriesAdded: postSyncCount - preSyncCount,
        backupPath,
      };
    } finally {
      db.close();
      // 13. Relaunch Messages.app in background
      try {
        execFileSync('open', ['-g', '-a', 'Messages']);
      } catch {
        // Failed to relaunch — non-fatal
      }
      // 14. Reopen our connection (readonly mode)
      await this.init();
    }
  }

  /**
   * Block phone numbers via macOS Contacts blocklist.
   * 
   * Uses the `defaults` command to add numbers to the BlockedPhoneNumbers array
   * in com.apple.cmfsyncagent preferences. This is the same mechanism used by
   * the Contacts app and Messages app.
   * 
   * @param numbers - Array of phone numbers to block (any format)
   * @returns Object with blocked and failed arrays
   */
  async blockNumbers(numbers: string[]): Promise<{ blocked: string[]; failed: string[] }> {
    const blocked: string[] = [];
    const failed: string[] = [];

    for (const number of numbers) {
      try {
        // Use defaults to add to the blocked contacts plist
        execFileSync('/usr/bin/defaults', [
          'write',
          'com.apple.cmfsyncagent',
          'BlockedPhoneNumbers',
          '-array-add',
          number,
        ]);
        blocked.push(number);
      } catch {
        failed.push(number);
      }
    }

    return { blocked, failed };
  }

  // -------------------------------------------------------------------------
  // Cleanup
  // -------------------------------------------------------------------------

  /** Close the DB connection and mark bridge as disposed. */
  dispose(): void {
    if (this.db) {
      try { this.db.close(); } catch { /* ignore */ }
      this.db = null;
    }
    this.disposed = true;
  }

  // -------------------------------------------------------------------------
  // Private helpers
  // -------------------------------------------------------------------------

  private assertInitialized(): void {
    if (this.disposed) {
      throw new IMessageBridgeError('Bridge has been disposed — create a new instance', 'NOT_INITIALIZED');
    }
    if (!this.db) {
      throw new IMessageBridgeError('Bridge not initialized — call init() first', 'NOT_INITIALIZED');
    }
  }

  private validateGetUnreadArgs(since: Date, limit: number): void {
    if (!(since instanceof Date) || isNaN(since.getTime())) {
      throw new IMessageBridgeError(
        'Please check your input: since must be a valid Date',
        'VALIDATION_ERROR',
      );
    }
    if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
      throw new IMessageBridgeError(
        'Please check your input: limit must be an integer between 1 and 500',
        'VALIDATION_ERROR',
      );
    }
  }

  private validateSendArgs(handle: string, text: string): void {
    if (typeof handle !== 'string' || handle.trim().length === 0) {
      throw new IMessageBridgeError(
        'Please check your input: handle must be a non-empty string (phone number or Apple ID)',
        'VALIDATION_ERROR',
      );
    }
    if (typeof text !== 'string' || text.trim().length === 0) {
      throw new IMessageBridgeError(
        'Please check your input: text must be a non-empty string',
        'VALIDATION_ERROR',
      );
    }
    if (text.length > 65536) {
      throw new IMessageBridgeError(
        'Please check your input: text must be 65,536 characters or fewer',
        'VALIDATION_ERROR',
      );
    }
  }

  private rowToTriageItem(row: MessageRow): TriageItem {
    // Resolve body text: prefer text column, fall back to attributedBody extraction
    // Guard against null/undefined text before calling .trim()
    let body =
      (row.text != null && row.text.trim().length > 0)
        ? row.text
        : (row.attributedBody ? extractTextFromAttributedBody(row.attributedBody) ?? '' : '');

    // For attachment-only messages (empty/ORC-only body), build a descriptive
    // summary from attachment metadata so they appear in triage with context.
    const attachments = row._attachments ?? [];
    if (isAttachmentOnlyBody(body) && attachments.length > 0) {
      body = buildAttachmentSummary(attachments);
    }

    // Final safety guard: ensure body is ALWAYS a string, never undefined/null
    body = body ?? '';

    const receivedAt = macDateToJs(row.date).toISOString();
    const direction: MessageDirection = row.is_from_me === 1 ? 'outbound' : 'inbound';
    const platform: TriagePlatform = 'imessage';

    // Thread identity: group chats have cache_roomnames, DMs use handle
    const threadId = row.cache_roomnames ?? row.handle;
    const isGroup = Boolean(row.cache_roomnames && row.cache_roomnames.trim().length > 0);

    // Derive a subject from the first line of the body (≤ 80 chars)
    const firstLine = body.split('\n')[0] ?? '';
    const subject = firstLine.length > 80 ? firstLine.slice(0, 77) + '…' : firstLine;
    const snippet = body.length > 200 ? body.slice(0, 197) + '…' : body;

    // Defaults for classifier fields — filled downstream by the triage classifier
    const defaultPriority: TriagePriority = 'P2';
    const defaultCategory: TriageCategory = 'review';
    const defaultAction: SuggestedAction = 'archive';

    return {
      id: randomUUID(),
      rawId: String(row.ROWID),
      threadId,
      platform,
      senderHandle: row.handle,
      senderName: row.handle, // enriched later by graph lookup
      subject,
      body,
      snippet,
      receivedAt,
      direction,
      isGroup,

      // Classifier defaults (not yet classified)
      priority: defaultPriority,
      category: defaultCategory,
      suggestedAction: defaultAction,
      confidence: 0,
      compositeScore: 0,
      signals: [],

      // Mental model (filled by triage pipeline)
      senderModel: null,
      extraction: null,

      classifiedAt: '',
      classifierVersion: '',
    };
  }
}

// ============================================================================
// Utility
// ============================================================================

function sleep(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function sleepSync(ms: number): void {
  const buf = new SharedArrayBuffer(4);
  const arr = new Int32Array(buf);
  Atomics.wait(arr, 0, 0, ms);
}

// ─── PROD-GAP-03: Exported standalone validation helpers ─────────────────────
// Mirrors the private class methods as module-level exports so tests can import
// and exercise the validation logic independently, without needing a live DB.

/**
 * Validate getUnread arguments outside a class instance.
 * Returns an error message string if invalid, or null if valid.
 */
export function validateGetUnreadArgs(since?: Date, limit?: number): string | null {
  if (!(since instanceof Date) || isNaN(since.getTime())) {
    return 'since must be a valid Date';
  }
  if (!Number.isInteger(limit) || (limit as number) < 1 || (limit as number) > 500) {
    return 'limit must be an integer between 1 and 500';
  }
  return null;
}

/**
 * Validate send arguments outside a class instance.
 * Returns an error message string if invalid, or null if valid.
 */
export function validateSendArgs(handle: string, text: string): string | null {
  if (typeof handle !== 'string' || handle.trim().length === 0) {
    return 'handle must be a non-empty string (phone number or Apple ID)';
  }
  if (typeof text !== 'string' || text.trim().length === 0) {
    return 'text must be a non-empty string';
  }
  if (text.length > 65536) {
    return 'text must be 65,536 characters or fewer';
  }
  return null;
}
