/**
 * reminderScheduler tests.
 *
 * Asserts that the reminder scan:
 *   - Selects ONLY shared (isShared=true) AND timed (allDay=false) events
 *     in the catch-up window (now, now+16min] (D-05/D-07)
 *   - Does NOT dispatch reminders for all-day events (D-07)
 *   - Does NOT dispatch reminders for non-shared (personal) events (D-05)
 *   - Does NOT dispatch for an event whose dtstart <= now (already started)
 *   - Fires EXACTLY ONCE across consecutive ticks while the event sits in the window
 *     (per-uid exactly-once dedup; cross-tick double-fire prevention)
 *   - Recovers from a missed/late cron tick: fires at a later scan when event is
 *     still in (now, now+16min] even if the ideal 15-min tick was skipped
 *   - WR-01: sentReminders.set(uid) is called AFTER dispatch (at-least-once delivery)
 *   - CR-01: sentReminders Map is pruned of started events after each scan
 *   - D-16: empty push_subscriptions -> zero sends / no crash
 *   - T-05-19: per-subscription error isolation
 *
 * Run: pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts
 */

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

// Mock the DB so we can control what events are returned
vi.mock('../../src/db/client.js', () => ({
  db: {
    select: vi.fn(),
  },
}));

// Mock pushDispatcher so no real push occurs
vi.mock('../../src/lib/pushDispatcher.js', () => ({
  dispatchPush: vi.fn().mockResolvedValue(undefined),
}));

// ── Helpers ───────────────────────────────────────────────────────────────────

function makeSelectMock(rows: unknown[]) {
  return {
    from: vi.fn().mockReturnValue({
      innerJoin: vi.fn().mockReturnValue({
        innerJoin: vi.fn().mockReturnValue({
          where: vi.fn().mockResolvedValue(rows),
        }),
      }),
    }),
  } as never;
}

function makeEventRow(overrides: {
  uid?: string;
  title?: string;
  dtstartUtc: Date;
  subId?: number | null;
  subUserId?: number | null;
  subEndpoint?: string;
  subP256dh?: string;
  subAuth?: string;
}) {
  return {
    uid: overrides.uid ?? 'test-uid-1',
    title: overrides.title ?? 'Test Event',
    dtstartUtc: overrides.dtstartUtc,
    allDay: false,
    isShared: true,
    subId: overrides.subId ?? 1,
    subUserId: overrides.subUserId ?? 1,
    subEndpoint: overrides.subEndpoint ?? 'https://push.example.com/1',
    subP256dh: overrides.subP256dh ?? 'p256dh-key',
    subAuth: overrides.subAuth ?? 'auth-secret',
  };
}

// ── Filtering tests (D-05 / D-07) ────────────────────────────────────────────

describe('reminderScheduler — shared+timed event filtering (D-05/D-07)', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.resetModules();
  });

  afterEach(() => {
    vi.useRealTimers();
    vi.clearAllMocks();
  });

  it('does not dispatch reminders for all-day events (D-07)', async () => {
    const now = new Date('2026-06-15T10:00:00Z');
    vi.setSystemTime(now);

    const { db } = await import('../../src/db/client.js');
    const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
    const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');

    // allDay=true events are excluded by the SQL WHERE; simulate by returning empty rows
    vi.mocked(db.select).mockReturnValue(makeSelectMock([]));

    await runReminderCheck();

    expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
  });

  it('does not dispatch reminders for non-shared (personal) calendar events (D-05)', async () => {
    const now = new Date('2026-06-15T10:00:00Z');
    vi.setSystemTime(now);

    const { db } = await import('../../src/db/client.js');
    const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
    const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');

    // isShared=false events are excluded by the SQL WHERE; simulate by returning empty rows
    vi.mocked(db.select).mockReturnValue(makeSelectMock([]));

    await runReminderCheck();

    expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
  });

  it('does not dispatch for an event whose start has already passed (dtstart <= now)', async () => {
    const now = new Date('2026-06-15T10:00:00Z');
    vi.setSystemTime(now);

    const { db } = await import('../../src/db/client.js');
    const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
    const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');

    // gt(dtstartUtc, now) excludes already-started events; simulate by returning empty rows
    vi.mocked(db.select).mockReturnValue(makeSelectMock([]));

    await runReminderCheck(now);

    expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
  });
});

// ── D-16: empty subscriptions — zero sends / no crash ────────────────────────

describe('reminderScheduler — D-16: empty push_subscriptions', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.resetModules();
  });

  afterEach(() => {
    vi.useRealTimers();
    vi.clearAllMocks();
  });

  it('produces zero sends and does not crash when push_subscriptions is empty (D-16)', async () => {
    const now = new Date('2026-06-15T10:00:00Z');
    vi.setSystemTime(now);

    const { db } = await import('../../src/db/client.js');
    const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
    const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');

    // Cross-join with empty push_subscriptions returns no rows
    vi.mocked(db.select).mockReturnValue(makeSelectMock([]));

    await expect(runReminderCheck(now)).resolves.toBeUndefined();
    expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
  });
});

// ── Catch-up / missed-tick recovery ──────────────────────────────────────────

describe('reminderScheduler — catch-up window and missed-tick recovery', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.resetModules();
  });

  afterEach(() => {
    vi.useRealTimers();
    vi.clearAllMocks();
  });

  it('SINGLE-FIRE: dispatches exactly once across three consecutive ticks while event is in window', async () => {
    const { db } = await import('../../src/db/client.js');
    const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
    const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');

    const t0 = new Date('2026-06-15T10:00:00Z');
    // Event is 15 min out from t0; still in (now, now+16min] at t0+1min (14 min out) and t0+2min (13 min out)
    const eventDtstart = new Date('2026-06-15T10:15:00Z');

    const sub = {
      id: 1,
      userId: 1,
      endpoint: 'https://push.example.com/1',
      p256dh: 'k',
      auth: 'a',
    };

    function rowForNow() {
      return makeEventRow({
        uid: 'single-fire-uid',
        dtstartUtc: eventDtstart,
        ...sub,
        subId: sub.id,
        subUserId: sub.userId,
        subEndpoint: sub.endpoint,
        subP256dh: sub.p256dh,
        subAuth: sub.auth,
      });
    }

    // Tick at t0 (event 15 min out)
    vi.setSystemTime(t0);
    vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]));
    await runReminderCheck(t0);

    // Tick at t0+1min (event 14 min out — still in window, same uid)
    const t1 = new Date(t0.getTime() + 60 * 1000);
    vi.setSystemTime(t1);
    vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]));
    await runReminderCheck(t1);

    // Tick at t0+2min (event 13 min out — still in window, same uid)
    const t2 = new Date(t0.getTime() + 2 * 60 * 1000);
    vi.setSystemTime(t2);
    vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]));
    await runReminderCheck(t2);

    // Exactly one dispatch total: uid dedup prevents re-fire on ticks 2 and 3
    expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1);
  });

  it('MISSED-TICK-RECOVERY: fires when scan runs 8 min before event after ideal tick was skipped', async () => {
    const { db } = await import('../../src/db/client.js');
    const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
    const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');

    // Event at 10:15:00Z. The ideal 15-min scan (10:00:00Z) was missed.
    // Call at 10:07:00Z — event is 8 min out, still > now and <= now+16min.
    const eventDtstart = new Date('2026-06-15T10:15:00Z');
    const recoveryNow = new Date('2026-06-15T10:07:00Z');

    vi.setSystemTime(recoveryNow);

    const sub = {
      id: 1,
      userId: 1,
      endpoint: 'https://push.example.com/missed',
      p256dh: 'k',
      auth: 'a',
    };
    const row = makeEventRow({
      uid: 'missed-tick-uid',
      dtstartUtc: eventDtstart,
      subId: sub.id,
      subUserId: sub.userId,
      subEndpoint: sub.endpoint,
      subP256dh: sub.p256dh,
      subAuth: sub.auth,
    });

    vi.mocked(db.select).mockReturnValue(makeSelectMock([row]));
    await runReminderCheck(recoveryNow);

    // Reminder must fire even though the ideal-mark tick was skipped
    expect(vi.mocked(dispatchPush).mock.calls.length).toBeGreaterThanOrEqual(1);
  });

  it('dispatches to all subscribers for a single event (fan-out)', async () => {
    const now = new Date('2026-06-15T10:00:00Z');
    vi.setSystemTime(now);

    const { db } = await import('../../src/db/client.js');
    const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
    const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');

    const dtstartUtc = new Date('2026-06-15T10:15:00Z');

    const rows = [
      makeEventRow({
        uid: 'fanout-uid',
        dtstartUtc,
        subId: 1,
        subUserId: 1,
        subEndpoint: 'https://push.example.com/1',
        subP256dh: 'k1',
        subAuth: 'a1',
      }),
      makeEventRow({
        uid: 'fanout-uid',
        dtstartUtc,
        subId: 2,
        subUserId: 2,
        subEndpoint: 'https://push.example.com/2',
        subP256dh: 'k2',
        subAuth: 'a2',
      }),
    ];

    vi.mocked(db.select).mockReturnValue(makeSelectMock(rows));
    await runReminderCheck(now);

    // One dispatch per subscriber
    expect(vi.mocked(dispatchPush).mock.calls.length).toBe(2);
  });
});

// ── WR-01: mark-sent after dispatch ──────────────────────────────────────────

describe('reminderScheduler — WR-01: mark-sent after dispatch', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.resetModules();
  });

  afterEach(() => {
    vi.useRealTimers();
    vi.clearAllMocks();
  });

  it('does not re-dispatch the same uid after a successful dispatch in the same module instance', async () => {
    // WR-01: sentReminders.set(uid) is called AFTER the fan-out loop completes.
    // After a successful dispatch the uid is recorded; a second run with the same
    // module instance must not re-fire for the same uid.
    const { db } = await import('../../src/db/client.js');
    const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
    const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');

    const now = new Date('2026-06-15T10:00:00Z');
    vi.setSystemTime(now);

    const sub = {
      id: 1,
      userId: 1,
      endpoint: 'https://push.example.com/1',
      p256dh: 'k',
      auth: 'a',
    };
    const eventRow = makeEventRow({
      uid: 'wr01-dedup-uid',
      title: 'WR-01 dedup event',
      dtstartUtc: new Date('2026-06-15T10:15:00Z'),
      subId: sub.id,
      subUserId: sub.userId,
      subEndpoint: sub.endpoint,
      subP256dh: sub.p256dh,
      subAuth: sub.auth,
    });

    vi.mocked(dispatchPush).mockResolvedValue(undefined);

    // First run — dispatch succeeds; uid is recorded after the loop
    vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]));
    await runReminderCheck(now);
    expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // dispatched once

    // Second run with same now — uid is in sentReminders; should NOT re-dispatch
    vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]));
    await runReminderCheck(now);
    expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // still 1 — deduped
  });
});

// ── CR-01: started-event pruning ─────────────────────────────────────────────

describe('reminderScheduler — CR-01: sentReminders Map pruning', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.resetModules();
  });

  afterEach(() => {
    vi.useRealTimers();
    vi.clearAllMocks();
  });

  it('prunes started-event entries so a different future event with the same uid can fire again', async () => {
    const { db } = await import('../../src/db/client.js');
    const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
    const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');

    const sub = {
      id: 1,
      userId: 1,
      endpoint: 'https://push.example.com/1',
      p256dh: 'k',
      auth: 'a',
    };
    const eventDtstart = new Date('2026-06-15T10:15:00Z');
    const uid = 'prune-test-uid';

    const eventRow = makeEventRow({
      uid,
      title: 'Prune test event',
      dtstartUtc: eventDtstart,
      subId: sub.id,
      subUserId: sub.userId,
      subEndpoint: sub.endpoint,
      subP256dh: sub.p256dh,
      subAuth: sub.auth,
    });

    // Tick at t0 (now=10:00): event 15 min out — fires
    const t0 = new Date('2026-06-15T10:00:00Z');
    vi.setSystemTime(t0);
    vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]));
    await runReminderCheck(t0);
    expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // fired

    // Same t0 — uid still in Map — must NOT re-fire
    vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]));
    await runReminderCheck(t0);
    expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // still 1

    // Advance past dtstart (now=10:20): event has started; CR-01 prunes the uid entry.
    // The SQL WHERE gt(dtstartUtc, now) would return no rows, so simulate empty.
    const tPast = new Date('2026-06-15T10:20:00Z');
    vi.setSystemTime(tPast);
    vi.mocked(db.select).mockReturnValue(makeSelectMock([]));
    await runReminderCheck(tPast);
    // Pruning fires; dispatch count stays at 1
    expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1);
  });
});

// ── T-05-19: per-subscription error isolation ────────────────────────────────

describe('reminderScheduler — T-05-19: per-subscription error isolation', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.resetModules();
  });

  afterEach(() => {
    vi.useRealTimers();
    vi.clearAllMocks();
  });

  it('continues dispatching to remaining subscribers when one subscription throws', async () => {
    const now = new Date('2026-06-15T10:00:00Z');
    vi.setSystemTime(now);

    const { db } = await import('../../src/db/client.js');
    const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
    const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');

    const dtstartUtc = new Date('2026-06-15T10:15:00Z');
    const rows = [
      makeEventRow({
        uid: 'iso-uid',
        dtstartUtc,
        subId: 1,
        subUserId: 1,
        subEndpoint: 'https://push.example.com/1',
        subP256dh: 'k1',
        subAuth: 'a1',
      }),
      makeEventRow({
        uid: 'iso-uid',
        dtstartUtc,
        subId: 2,
        subUserId: 2,
        subEndpoint: 'https://push.example.com/2',
        subP256dh: 'k2',
        subAuth: 'a2',
      }),
    ];

    vi.mocked(db.select).mockReturnValue(makeSelectMock(rows));
    // First subscriber throws; second should still be attempted
    vi.mocked(dispatchPush)
      .mockRejectedValueOnce(new Error('network error'))
      .mockResolvedValueOnce(undefined);

    await expect(runReminderCheck(now)).resolves.toBeUndefined();
    expect(vi.mocked(dispatchPush).mock.calls.length).toBe(2); // both attempted
  });
});
