/**
 * broker/outboxWorker.ts — outbox state machine (D-04, D-07, D-08)
 *
 * Behaviors under test:
 *   1. runOutboxDrain transitions pending→done on mock 204 response
 *   2. runOutboxDrain transitions pending→failed on mock 412 (conflict, no retry), triggers re-sync
 *   3. runOutboxDrain transitions pending→backoff (nextAttemptAt advanced, attemptCount++)
 *      on mock 500 (transient error)
 *   4. runOutboxDrain transitions pending→dead when attemptCount reaches MAX_ATTEMPTS
 *   5. Edit-as-move (D-04): create row processed BEFORE the linked delete row (groupId)
 *   6. scheduleOutboxDrain trigger wiring (D-09): signal → prompt drain, trailing re-drain collapse
 */

import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest';

import {
  runOutboxDrain,
  assembleRruleString,
  scheduleOutboxDrain,
  initOutboxTrigger,
  stopOutboxTrigger,
  __resetDrainState,
} from '../../src/broker/outboxWorker.js';
import { signalOutboxDrain } from '../../src/lib/outboxTrigger.js';

// ── Drizzle DB mock ────────────────────────────────────────────────────────
// Follows the pattern from PATTERNS.md §Drizzle DB mock in tests.
//
// vi.hoisted() is required for variables referenced inside vi.mock() factories.
// vi.mock() is hoisted to the top of the file by vitest's transform; without
// vi.hoisted(), variables declared with const/let are in the TDZ when the factory
// runs (static imports trigger module loading before declarations are evaluated).

const {
  mockUpdateSet,
  mockUpdate,
  mockWherePending,
  mockWhereCalEvents,
  mockFromFn,
  mockSelectFn,
  mockDecryptPassword,
} = vi.hoisted(() => {
  const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) });
  const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet });
  // mockWherePending: terminal node for calendarOutbox selects (pending-rows + sibling-status)
  //   db.select().from(calendarOutbox).where(...) — resolves to the row array
  const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]));
  // mockWhereCalEvents: terminal node for calendarEvents selects (etag re-read for WR-02)
  //   db.select({etag}).from(calendarEvents).where(...) — resolves to the etag array
  const mockWhereCalEvents = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]));
  const mockFromFn = vi.fn().mockReturnValue({ where: mockWherePending });
  const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn });
  // By default returns a dummy password so loadClientForUser succeeds
  const mockDecryptPassword = vi.fn().mockReturnValue('app-password');
  return {
    mockUpdateSet,
    mockUpdate,
    mockWherePending,
    mockWhereCalEvents,
    mockFromFn,
    mockSelectFn,
    mockDecryptPassword,
  };
});

let mockPendingRows: unknown[] = [];

// Fake credential row returned by loadClientForUser's db.select().from(memberCredentials).where()
const FAKE_CRED_ROW = {
  userId: 42,
  fastmailEmail: 'test@fastmail.com',
  encryptedPassword: '{"iv":"aa","authTag":"bb","ciphertext":"cc"}',
};

vi.mock('../../src/db/client.js', () => ({
  db: {
    select: mockSelectFn,
    update: mockUpdate,
  },
}));

// Mock write functions — these are called by outboxWorker for the actual CalDAV ops
vi.mock('../../src/broker/write.js', () => ({
  createCalendarEvent: vi.fn(),
  updateCalendarEvent: vi.fn(),
  deleteCalendarEvent: vi.fn(),
}));

// Mock sync — called after successful write (D-06)
vi.mock('../../src/broker/sync.js', () => ({
  syncCalendar: vi.fn().mockResolvedValue(undefined),
}));

// Mock client creation — the worker needs a DAVClient to call sync
vi.mock('../../src/broker/client.js', () => ({
  createFastmailClient: vi.fn().mockResolvedValue({
    fetchCalendars: vi.fn().mockResolvedValue([]),
  }),
}));

// Mock crypto — controls whether loadClientForUser succeeds or throws (CR-03 tests)
vi.mock('../../src/broker/crypto.js', () => ({
  decryptPassword: mockDecryptPassword,
}));

// Default payload is form JSON (the worker must build ICS from this — not pass raw JSON to CalDAV)
const DEFAULT_FORM_PAYLOAD = JSON.stringify({
  title: 'Lunch',
  allDay: false,
  start: '2026-06-10T12:00:00',
  end: '2026-06-10T13:00:00',
  recurrence: 'none',
});

const makeRow = (overrides: Record<string, unknown> = {}) => ({
  id: 1,
  userId: 42,
  operation: 'create' as const,
  status: 'pending' as const,
  uid: 'test-uid@familysync',
  calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
  calendarObjectUrl: null,
  etag: null,
  payload: DEFAULT_FORM_PAYLOAD,
  attemptCount: 0,
  nextAttemptAt: new Date(Date.now() - 5000), // already due
  lastError: null,
  groupId: null,
  createdAt: new Date(),
  updatedAt: new Date(),
  ...overrides,
});

const makeResponse = (status: number): Response =>
  ({ status, ok: status >= 200 && status < 300, headers: new Headers() }) as unknown as Response;

// Helper: wire db mock so outbox queries return mockPendingRows and credential queries return FAKE_CRED_ROW
// This is called in each beforeEach after vi.clearAllMocks() to restore the mock chain.
function wireMockChain() {
  mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) });
  mockUpdate.mockReturnValue({ set: mockUpdateSet });
  // mockFromFn differentiates by table argument using Symbol.for('drizzle:Name'):
  // - memberCredentials → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default)
  // - calendarEvents → returns mockWhereCalEvents (etag re-read for WR-02)
  // - calendarOutbox (and anything else) → returns mockWherePending (pending-rows + sibling-status)
  // JSON.stringify throws on circular Drizzle table structures; use Symbol identity instead.
  mockFromFn.mockImplementation((table: unknown) => {
    const tableName = (table as Record<symbol, string>)[Symbol.for('drizzle:Name')] ?? '';
    if (tableName === 'member_credentials') {
      return { where: vi.fn().mockResolvedValue([FAKE_CRED_ROW]) };
    }
    if (tableName === 'calendar_events') {
      // CR-02: the freshest-etag re-read now scopes to the writing member's calendar:
      //   .from(calendarEvents).innerJoin(calendars, ...).where(...).limit(1)
      // mockWhereCalEvents stays the awaited terminal (returned by .limit) so existing
      // mockWhereCalEvents.mockResolvedValue([{ etag }]) overrides still drive the etag.
      return {
        innerJoin: vi.fn().mockReturnValue({
          where: vi.fn().mockReturnValue({ limit: mockWhereCalEvents }),
        }),
      };
    }
    return { where: mockWherePending };
  });
  mockWhereCalEvents.mockImplementation(() => Promise.resolve([]));
  mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows));
  mockSelectFn.mockReturnValue({ from: mockFromFn });
  // Default: decryptPassword succeeds
  mockDecryptPassword.mockReturnValue('app-password');
}

describe('runOutboxDrain — state transitions', () => {
  beforeEach(() => {
    vi.resetAllMocks();
    mockPendingRows = [];
    wireMockChain();
  });

  it('transitions pending→done on 204 response and triggers re-sync (D-06)', async () => {
    const { createCalendarEvent } = await import('../../src/broker/write.js');
    vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(204));
    mockPendingRows = [makeRow()];

    await runOutboxDrain();

    // Must update status to 'done'
    expect(mockUpdate).toHaveBeenCalled();
    const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string };
    expect(setArg?.status).toBe('done');
  });

  it('transitions pending→failed on 412 (conflict — no retry), marks failed (D-08)', async () => {
    const { createCalendarEvent } = await import('../../src/broker/write.js');
    vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(412));
    mockPendingRows = [makeRow()];

    await runOutboxDrain();

    // 412 = hard fail (conflict) — must NOT retry, must mark failed
    const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string; lastError?: string };
    expect(setArg?.status).toBe('failed');
    expect(setArg?.lastError).toBeTruthy();
  });

  it('transitions pending→backoff (attemptCount++, nextAttemptAt advanced) on 500 (transient)', async () => {
    const { createCalendarEvent } = await import('../../src/broker/write.js');
    vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500));
    const row = makeRow({ attemptCount: 0 });
    mockPendingRows = [row];

    const beforeDrain = Date.now();
    await runOutboxDrain();

    // Must NOT transition to done or failed — backoff
    const setArg = mockUpdateSet.mock.calls[0]?.[0] as {
      status?: string;
      attemptCount?: number;
      nextAttemptAt?: Date;
    };
    expect(setArg?.status).not.toBe('done');
    expect(setArg?.status).not.toBe('failed');
    expect(setArg?.attemptCount).toBe(1);
    // nextAttemptAt must be in the future
    expect(setArg?.nextAttemptAt?.getTime()).toBeGreaterThan(beforeDrain);
  });

  it('transitions pending→dead when attemptCount reaches MAX_ATTEMPTS on transient error', async () => {
    const { createCalendarEvent } = await import('../../src/broker/write.js');
    vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500));
    // MAX_ATTEMPTS is 5 per RESEARCH.md Pattern 4 — at attempt 4 (0-indexed) → dead
    const row = makeRow({ attemptCount: 4 });
    mockPendingRows = [row];

    await runOutboxDrain();

    const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string };
    expect(setArg?.status).toBe('dead');
  });

  it('does not crash when pending rows list is empty', async () => {
    mockPendingRows = [];
    await expect(runOutboxDrain()).resolves.not.toThrow();
  });
});

describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
  beforeEach(() => {
    vi.resetAllMocks();
    mockPendingRows = [];
    wireMockChain();
  });

  it('create row: icsString passed to createCalendarEvent starts with BEGIN:VCALENDAR and contains SUMMARY', async () => {
    const { createCalendarEvent } = await import('../../src/broker/write.js');
    let capturedIcsString: unknown = null;
    vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => {
      capturedIcsString = icsString;
      return makeResponse(201);
    });
    mockPendingRows = [makeRow()];

    await runOutboxDrain();

    expect(typeof capturedIcsString).toBe('string');
    expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true);
    expect(capturedIcsString).toContain('SUMMARY:Lunch');
  });

  it('update row: icsString passed to updateCalendarEvent starts with BEGIN:VCALENDAR', async () => {
    const { updateCalendarEvent } = await import('../../src/broker/write.js');
    let capturedIcsString: unknown = null;
    vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, icsString, _etag) => {
      capturedIcsString = icsString;
      return makeResponse(204);
    });
    mockPendingRows = [
      makeRow({
        operation: 'update',
        calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
      }),
    ];

    await runOutboxDrain();

    expect(typeof capturedIcsString).toBe('string');
    expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true);
  });

  it('create row with unparseable payload marks the row failed (hard fail, no retry)', async () => {
    mockPendingRows = [makeRow({ payload: 'NOT_VALID_JSON{{{' })];

    await runOutboxDrain();

    const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string };
    expect(setArg?.status).toBe('failed');
  });

  // IN-03 (iteration 2): a JSON-parseable but schema-INVALID payload (e.g. missing the
  // required title) can never produce a valid VEVENT, so the row is hard-failed (no
  // retry) rather than dispatched with SUMMARY:undefined.
  it('IN-03: create row with schema-invalid payload (missing title) is hard-failed, never dispatched', async () => {
    const { createCalendarEvent } = await import('../../src/broker/write.js');
    vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201));
    // Valid JSON, but title is missing → fails outboxPayloadSchema
    const badPayload = JSON.stringify({
      allDay: false,
      start: '2026-06-10T12:00:00',
      end: '2026-06-10T13:00:00',
    });
    mockPendingRows = [makeRow({ payload: badPayload })];

    await runOutboxDrain();

    // Must NOT have dispatched a CalDAV write with an invalid VEVENT
    expect(createCalendarEvent).not.toHaveBeenCalled();
    const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string; lastError?: string };
    expect(setArg?.status).toBe('failed');
    expect(setArg?.lastError).toMatch(/validation/i);
  });

  // CR-01 (iteration 2): the edit-as-move create branch must re-apply the RRULE the
  // route stashed on the payload as `_preservedRrule`, so a moved recurring series keeps
  // its RRULE instead of collapsing into a single occurrence.
  it('CR-01: create row re-applies _preservedRrule → emitted ICS contains RRULE:', async () => {
    const { createCalendarEvent } = await import('../../src/broker/write.js');
    let capturedIcsString: unknown = null;
    vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => {
      capturedIcsString = icsString;
      return makeResponse(201);
    });
    // Move payload: no explicit `recurrence`, but the route stashed the source RRULE.
    const movePayload = JSON.stringify({
      title: 'Moved weekly standup',
      allDay: false,
      start: '2026-06-10T12:00:00',
      end: '2026-06-10T13:00:00',
      _preservedRrule: 'FREQ=WEEKLY;BYDAY=MO',
    });
    mockPendingRows = [
      makeRow({ operation: 'create', payload: movePayload, groupId: 'move-grp-1' }),
    ];

    await runOutboxDrain();

    expect(typeof capturedIcsString).toBe('string');
    expect(capturedIcsString as string).toContain('RRULE:');
    expect(capturedIcsString as string).toContain('FREQ=WEEKLY');
  });

  // CR-01 corollary: an explicit `recurrence` on a create still wins over any preserved
  // RRULE (deliberate user choice); recurrence:'none' must emit no RRULE.
  it("CR-01: explicit recurrence:'none' wins → emitted ICS has no RRULE even if _preservedRrule present", async () => {
    const { createCalendarEvent } = await import('../../src/broker/write.js');
    let capturedIcsString: unknown = null;
    vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => {
      capturedIcsString = icsString;
      return makeResponse(201);
    });
    const payload = JSON.stringify({
      title: 'One-off',
      allDay: false,
      start: '2026-06-10T12:00:00',
      end: '2026-06-10T13:00:00',
      recurrence: 'none',
      _preservedRrule: 'FREQ=WEEKLY;BYDAY=MO',
    });
    mockPendingRows = [makeRow({ operation: 'create', payload })];

    await runOutboxDrain();

    expect(typeof capturedIcsString).toBe('string');
    expect(capturedIcsString as string).not.toContain('RRULE:');
  });
});

describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
  beforeEach(() => {
    vi.resetAllMocks();
    mockPendingRows = [];
    wireMockChain();
  });

  it('processes the create row BEFORE the delete row when both share a groupId', async () => {
    const { createCalendarEvent, deleteCalendarEvent } = await import('../../src/broker/write.js');
    const createResponse = makeResponse(201);
    const deleteResponse = makeResponse(204);
    vi.mocked(createCalendarEvent).mockResolvedValue(createResponse);
    vi.mocked(deleteCalendarEvent).mockResolvedValue(deleteResponse);

    const groupId = 'edit-move-group-001';
    // delete row listed first (to verify ordering is enforced regardless of order in the array)
    const deleteRow = makeRow({
      id: 2,
      operation: 'delete',
      calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/Old/uid.ics',
      etag: '"etag-old"',
      payload: null,
      groupId,
    });
    const createRow = makeRow({
      id: 3,
      operation: 'create',
      calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/New/',
      groupId,
    });

    // Both rows in the pending list
    mockPendingRows = [deleteRow, createRow];

    // The durable sibling-status check (CR-04) runs for the delete row with groupId.
    // It queries calendarOutbox for the sibling create's status. By the time the delete
    // is processed (create was sorted and dispatched first), we simulate the sibling as 'done'.
    // The base mockWherePending returns mockPendingRows for all calendarOutbox selects; we
    // override just the sibling-status call with mockImplementationOnce queued after the
    // pending-rows select call.
    mockWherePending
      .mockImplementationOnce(() => Promise.resolve([deleteRow, createRow])) // pending-rows select
      .mockImplementationOnce(() => Promise.resolve([{ status: 'done' }])); // sibling-status select

    await runOutboxDrain();

    // CREATE must be called before DELETE
    const createCall = vi.mocked(createCalendarEvent).mock.invocationCallOrder[0];
    const deleteCall = vi.mocked(deleteCalendarEvent).mock.invocationCallOrder[0];

    // If either was never called, the test will fail naturally.
    // If create order index > delete order index, create ran AFTER delete — fail.
    expect(createCall).toBeLessThan(deleteCall);
  });
});

describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency guard (CR-05)', () => {
  beforeEach(() => {
    vi.resetAllMocks();
    mockPendingRows = [];
    wireMockChain();
  });

  it('CR-04 cross-batch: drain 1 (sibling create still pending) leaves the delete pending and never calls deleteCalendarEvent', async () => {
    const { deleteCalendarEvent } = await import('../../src/broker/write.js');
    vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204));

    const groupId = 'edit-move-group-001';
    const deleteRow = makeRow({
      id: 2,
      operation: 'delete',
      calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/Old/uid.ics',
      etag: '"etag-old"',
      payload: null,
      groupId,
    });

    // Drain 1: only the delete row is returned as pending (the create hasn't been fetched yet)
    // First mockWherePending call → pending-rows select (only the delete row)
    // Second mockWherePending call → sibling-status select (create is still 'pending')
    mockWherePending
      .mockImplementationOnce(() => Promise.resolve([deleteRow]))
      .mockImplementationOnce(() => Promise.resolve([{ status: 'pending' }]));

    await runOutboxDrain();

    // The delete must NOT have been dispatched — sibling create is not yet done
    expect(deleteCalendarEvent).not.toHaveBeenCalled();

    // The delete row's status must NOT have been updated to done or failed
    const statusCalls = mockUpdateSet.mock.calls.filter((call) => {
      const arg = call[0] as { status?: string };
      return arg?.status === 'done' || arg?.status === 'failed';
    });
    expect(statusCalls.length).toBe(0);
  });

  it('CR-04 cross-batch: drain 2 (sibling create now done) dispatches the delete exactly once', async () => {
    const { deleteCalendarEvent } = await import('../../src/broker/write.js');
    vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204));

    const groupId = 'edit-move-group-001';
    const deleteRow = makeRow({
      id: 2,
      operation: 'delete',
      calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/Old/uid.ics',
      etag: '"etag-old"',
      payload: null,
      groupId,
    });

    // Drain 2: delete row is pending again, sibling create is now 'done'
    mockWherePending
      .mockImplementationOnce(() => Promise.resolve([deleteRow]))
      .mockImplementationOnce(() => Promise.resolve([{ status: 'done' }]));

    await runOutboxDrain();

    expect(deleteCalendarEvent).toHaveBeenCalledTimes(1);
  });

  it('CR-04 paired-create-failed: if sibling create is failed, delete is marked failed and never dispatched (D-04 preserved)', async () => {
    const { deleteCalendarEvent } = await import('../../src/broker/write.js');
    vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204));

    const groupId = 'edit-move-group-001';
    const deleteRow = makeRow({
      id: 2,
      operation: 'delete',
      calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/Old/uid.ics',
      etag: '"etag-old"',
      payload: null,
      groupId,
    });

    // Sibling create is 'failed' — the delete must be permanently skipped
    mockWherePending
      .mockImplementationOnce(() => Promise.resolve([deleteRow]))
      .mockImplementationOnce(() => Promise.resolve([{ status: 'failed' }]));

    await runOutboxDrain();

    // The original event must be preserved — delete must NOT be dispatched
    expect(deleteCalendarEvent).not.toHaveBeenCalled();

    // The delete row must be marked failed (permanently, not just skipped this cycle)
    const failedCall = mockUpdateSet.mock.calls.find((call) => {
      const arg = call[0] as { status?: string; lastError?: string };
      return arg?.status === 'failed' && typeof arg?.lastError === 'string';
    });
    expect(failedCall).toBeDefined();
    const failArg = failedCall![0] as { lastError: string };
    expect(failArg.lastError).toMatch(/paired create/);
  });

  it('CR-05: two overlapping runOutboxDrain calls invoke createCalendarEvent exactly once', async () => {
    const { createCalendarEvent } = await import('../../src/broker/write.js');
    // Simulate a slow create so the second drain starts while first is still running
    vi.mocked(createCalendarEvent).mockImplementation(
      () => new Promise((resolve) => setTimeout(() => resolve(makeResponse(201)), 20)),
    );

    mockPendingRows = [makeRow({ id: 1 })];

    // Start both drains concurrently WITHOUT awaiting the first
    const drain1 = runOutboxDrain();
    const drain2 = runOutboxDrain();
    await Promise.all([drain1, drain2]);

    // Only one dispatch must have happened — the second drain must have been a no-op
    expect(createCalendarEvent).toHaveBeenCalledTimes(1);
  });
});

describe('runOutboxDrain — fresh etag re-read before PUT (WR-02)', () => {
  beforeEach(() => {
    // Use resetAllMocks here (not clearAllMocks) so that unconsumed mockImplementationOnce
    // queues from prior tests do not bleed into subsequent tests via the shared mockWherePending.
    vi.resetAllMocks();
    mockPendingRows = [];
    wireMockChain();
  });

  it('WR-02 fresh etag: update PUT uses freshest calendarEvents.etag, not stale enqueue-time etag', async () => {
    const { updateCalendarEvent } = await import('../../src/broker/write.js');
    let capturedEtag: string | null = null;
    vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, _ics, etag) => {
      capturedEtag = etag;
      return makeResponse(204);
    });

    const updateRow = makeRow({
      operation: 'update',
      calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
      etag: 'old-etag', // stale enqueue-time etag
    });
    mockPendingRows = [updateRow];

    // Mock the calendarEvents etag lookup to return a fresher etag.
    // In RED (no fresh-etag code yet), mockWhereCalEvents is never called, so
    // the PUT uses row.etag = 'old-etag'. The assertion expects 'new-etag' → fails RED.
    mockWhereCalEvents.mockResolvedValue([{ etag: 'new-etag' }]);

    await runOutboxDrain();

    // The PUT must use the freshest etag from calendarEvents, not the stale row.etag
    expect(capturedEtag).toBe('new-etag');
    expect(capturedEtag).not.toBe('old-etag');
  });

  it('WR-02 etag fallback: update PUT falls back to row.etag when calendarEvents has no matching row', async () => {
    const { updateCalendarEvent } = await import('../../src/broker/write.js');
    let capturedEtag: string | null = null;
    vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, _ics, etag) => {
      capturedEtag = etag;
      return makeResponse(204);
    });

    const updateRow = makeRow({
      operation: 'update',
      calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
      etag: 'fallback-etag',
    });
    mockPendingRows = [updateRow];

    // mockWhereCalEvents is already configured to return [] by default in wireMockChain.
    // No row for the uid → worker falls back to row.etag.
    // In RED, mockWhereCalEvents is never called so the test passes (row.etag used directly).
    // In GREEN, mockWhereCalEvents returns [] so the fallback is exercised.

    await runOutboxDrain();

    // When calendarEvents has no row for the uid, fall back to row.etag
    expect(capturedEtag).toBe('fallback-etag');
  });
});

describe('runOutboxDrain — fail closed on bad credentials (CR-03) + backoff index fix (WR-01)', () => {
  beforeEach(() => {
    vi.resetAllMocks();
    mockPendingRows = [];
    wireMockChain();
  });

  it('CR-03: credential-load failure leaves row pending and never calls createFastmailClient with empty credentials', async () => {
    // Make decryptPassword throw so loadClientForUser throws
    mockDecryptPassword.mockImplementation(() => {
      throw new Error('bad credentials');
    });

    const { createFastmailClient } = await import('../../src/broker/client.js');
    mockPendingRows = [makeRow()];

    await runOutboxDrain();

    // The row must NOT be updated to done/failed/dead — it stays pending (outer catch handles it)
    const updateCalls = mockUpdateSet.mock.calls;
    const anyStatusChange = updateCalls.some((call) => {
      const arg = call[0] as { status?: string };
      return arg?.status !== undefined;
    });
    expect(anyStatusChange).toBe(false);

    // createFastmailClient must NEVER be called with empty-string credentials
    const emptyCalls = vi
      .mocked(createFastmailClient)
      .mock.calls.filter(([email, password]) => email === '' || password === '');
    expect(emptyCalls.length).toBe(0);
  });

  it('WR-01: first transient failure (attemptCount=0) sets backoff to ~15s (BACKOFF_SECONDS[0])', async () => {
    const { createCalendarEvent } = await import('../../src/broker/write.js');
    vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500));
    const row = makeRow({ attemptCount: 0 });
    mockPendingRows = [row];

    const beforeDrain = Date.now();
    await runOutboxDrain();
    const afterDrain = Date.now();

    const setArg = mockUpdateSet.mock.calls[0]?.[0] as {
      nextAttemptAt?: Date;
      attemptCount?: number;
    };
    expect(setArg?.attemptCount).toBe(1);

    // WR-01: nextAttemptAt must be ~15s in the future (BACKOFF_SECONDS[0] = 15)
    // Allow ±2s for execution overhead
    const expectedMinMs = beforeDrain + 14_000;
    const expectedMaxMs = afterDrain + 16_000;
    expect(setArg?.nextAttemptAt?.getTime()).toBeGreaterThanOrEqual(expectedMinMs);
    expect(setArg?.nextAttemptAt?.getTime()).toBeLessThanOrEqual(expectedMaxMs);
  });
});

// ─── D-06: assembleRruleString unit tests ─────────────────────────────────────
// Exercise the exported `assembleRruleString` helper.

describe('assembleRruleString (D-06)', () => {
  it('returns base preset unchanged when no bound given', () => {
    expect(assembleRruleString('FREQ=DAILY')).toBe('FREQ=DAILY');
  });

  it('appends COUNT when count is given (count wins over until)', () => {
    expect(assembleRruleString('FREQ=DAILY', undefined, 5, false)).toBe('FREQ=DAILY;COUNT=5');
  });

  it('COUNT wins when both until and count are provided (mutual exclusion, RFC 5545 §3.3.10)', () => {
    expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', 5, false)).toBe('FREQ=WEEKLY;COUNT=5');
  });

  it('appends UNTIL as DATE form (YYYYMMDD) for all-day events', () => {
    expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, true)).toBe(
      'FREQ=WEEKLY;UNTIL=20260630',
    );
  });

  it('appends UNTIL as DATETIME UTC form (YYYYMMDDTHHMMSSZ) for timed events', () => {
    expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, false)).toBe(
      'FREQ=WEEKLY;UNTIL=20260630T235959Z',
    );
  });

  it('COUNT=5 appended to FREQ=DAILY (matches plan behavior assertion)', () => {
    expect(assembleRruleString('FREQ=DAILY', undefined, 5, false)).toBe('FREQ=DAILY;COUNT=5');
  });
});

// ─── D-07: FREQ-persistence regression lock ────────────────────────────────────
// The FREQ-persistence assertion catches the D-07 regression scenario
// (recurrenceUntil/recurrenceCount wiring must not drop the FREQ).

describe('FREQ persistence (D-07 regression)', () => {
  beforeEach(() => {
    vi.resetAllMocks();
    mockPendingRows = [];
    wireMockChain();
  });

  it('D-07: daily-recurrence payload assembles to FREQ=DAILY (not weekly or none) in emitted ICS', async () => {
    const { createCalendarEvent } = await import('../../src/broker/write.js');
    let capturedIcsString: unknown = null;
    vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => {
      capturedIcsString = icsString;
      return makeResponse(201);
    });
    const dailyPayload = JSON.stringify({
      title: 'Daily standup',
      allDay: false,
      start: '2026-06-10T09:00:00',
      end: '2026-06-10T09:30:00',
      recurrence: 'daily',
    });
    mockPendingRows = [makeRow({ payload: dailyPayload })];

    await runOutboxDrain();

    expect(typeof capturedIcsString).toBe('string');
    // D-07: FREQ must be DAILY — not WEEKLY or absent
    expect(capturedIcsString as string).toContain('RRULE:FREQ=DAILY');
    expect(capturedIcsString as string).not.toContain('FREQ=WEEKLY');
  });
});

// ─── scheduleOutboxDrain trigger-wiring tests (D-09) ─────────────────────────
// These tests verify the trigger-wiring behavior introduced in Plan 09-01:
//   SC-1: signalOutboxDrain() → drain fires promptly (no 15s wait)
//   D-05: mid-drain signal collapses to exactly one trailing re-drain
//   D-07: concurrent scheduleOutboxDrain() calls dispatch each row exactly once

describe('scheduleOutboxDrain — trigger wiring (D-09)', () => {
  // Wire the EventEmitter signal → scheduleOutboxDrain once for this describe block.
  // initOutboxTrigger registers the 'drain' listener that connects signalOutboxDrain()
  // to scheduleOutboxDrain(). Called once (not per-test) to avoid listener accumulation.
  beforeAll(() => {
    initOutboxTrigger();
  });

  // WR-03: remove the leaked 'drain' listener so it cannot fire against the mocked
  // DB in subsequent describe blocks (fileParallelism:false shares one module instance).
  afterAll(() => {
    stopOutboxTrigger();
  });

  beforeEach(() => {
    vi.resetAllMocks();
    // WR-03 / IN-03: module-level isDraining/drainRequested are not reset by
    // vi.resetAllMocks(); reset them explicitly so each test starts quiescent
    // instead of relying on the previous test having drained cleanly.
    __resetDrainState();
    mockPendingRows = [];
    wireMockChain();
  });

  // Test A (SC-1): signalOutboxDrain() triggers a drain immediately — no 15s wait.
  // With one pending row and createCalendarEvent mocked to 201, calling signalOutboxDrain()
  // then flushing microtasks results in createCalendarEvent called exactly once.
  // No vi.useFakeTimers() — drain fires via the EventEmitter signal, not the interval.
  it('SC-1: signalOutboxDrain() triggers drain promptly — createCalendarEvent called once without advancing timers', async () => {
    const { createCalendarEvent } = await import('../../src/broker/write.js');
    vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201));
    mockPendingRows = [makeRow()];

    signalOutboxDrain();

    // Flush microtasks so the async drain has a chance to run
    await new Promise<void>((resolve) => setImmediate(resolve));
    // One more flush to let the drain's internal async steps complete
    await new Promise<void>((resolve) => setImmediate(resolve));

    expect(createCalendarEvent).toHaveBeenCalledTimes(1);
  });

  // Test B (SC-4 / D-05): a signal arriving during an in-flight drain triggers exactly
  // one trailing re-drain — not two, not zero.
  it('D-05: two signals mid-drain collapse to exactly one trailing re-drain', async () => {
    const { createCalendarEvent } = await import('../../src/broker/write.js');

    // Capture the resolve function so we can hold the first drain in flight
    let resolveFirst!: () => void;
    const firstDone = new Promise<void>((resolve) => {
      resolveFirst = resolve;
    });

    // First call: held until we release it; subsequent calls resolve immediately
    vi.mocked(createCalendarEvent)
      .mockImplementationOnce(async () => {
        await firstDone;
        return makeResponse(201);
      })
      .mockResolvedValue(makeResponse(201));

    // Two pending rows: drain 1 picks up row 1, trailing drain picks up row 2
    const row1 = makeRow({ id: 1, uid: 'uid-1@familysync' });
    const row2 = makeRow({ id: 2, uid: 'uid-2@familysync' });
    mockPendingRows = [row1];

    // Start drain 1 (via signal) — it blocks on firstDone
    signalOutboxDrain();
    await new Promise<void>((resolve) => setImmediate(resolve));

    // While drain 1 is in flight, send two more signals — both should collapse to one trailing drain
    mockPendingRows = [row2];
    signalOutboxDrain();
    signalOutboxDrain();

    // Release drain 1 — this lets it finish, then the trailing re-drain fires
    resolveFirst();

    // Flush: drain 1 finally-block + trailing scheduleOutboxDrain() + trailing drain steps
    await new Promise<void>((resolve) => setImmediate(resolve));
    await new Promise<void>((resolve) => setImmediate(resolve));
    await new Promise<void>((resolve) => setImmediate(resolve));

    // Drain 1 called createCalendarEvent once (row1); trailing drain called it once (row2).
    // Total = 2 — never a third pass.
    expect(createCalendarEvent).toHaveBeenCalledTimes(2);
  });

  // Test C (SC-4 / D-07): two concurrent scheduleOutboxDrain() calls dispatch each row
  // exactly once — the second call is a no-op via the isDraining guard.
  // The trailing re-drain (triggered by drainRequested) finds 0 pending rows so
  // createCalendarEvent is called exactly once total.
  it('D-07: two concurrent scheduleOutboxDrain() calls invoke createCalendarEvent exactly once', async () => {
    vi.useFakeTimers();
    try {
      const { createCalendarEvent } = await import('../../src/broker/write.js');
      // Slow create so the second scheduleOutboxDrain starts while first is still running
      vi.mocked(createCalendarEvent).mockImplementation(
        () => new Promise((resolve) => setTimeout(() => resolve(makeResponse(201)), 20)),
      );
      const row = makeRow({ id: 1 });
      // First pending-rows query returns the row; subsequent queries return empty
      // (simulates: drain 1 processes and removes the row; trailing drain finds nothing)
      mockWherePending
        .mockImplementationOnce(() => Promise.resolve([row]))
        .mockImplementation(() => Promise.resolve([]));

      // Both calls are synchronous — second sees isDraining=true and sets drainRequested=true
      scheduleOutboxDrain();
      scheduleOutboxDrain();

      await vi.runAllTimersAsync();

      // Drain 1 dispatched the row once; trailing drain found 0 rows → createCalendarEvent once total
      expect(createCalendarEvent).toHaveBeenCalledTimes(1);
    } finally {
      vi.useRealTimers();
    }
  });
});
