/**
 * Broker: syncCalendar event upsert + all-day handling
 *
 * Tests syncCalendar in src/broker/sync.ts.
 * Key behaviors verified (D-13, T-03-05):
 *   - All-day events: dtstart_date (DATE) set, dtstart_utc NULL, allDay=true
 *   - Timed events: dtstart_utc (TIMESTAMP UTC) set, dtstart_date NULL, allDay=false
 *   - UID used as idempotency key: second sync of same UID is an upsert, not duplicate
 *   - Raw VEVENT blob stored verbatim
 *   - Calendar ctag/syncToken updated after sync
 */

import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
  SAMPLE_VEVENT_TIMED,
  SAMPLE_VEVENT_ALLDAY,
  SAMPLE_VEVENT_RECURRING_TIMED,
  SAMPLE_VEVENT_RECURRING_ALLDAY,
} from '../helpers/db.js';

// Track calls for assertions
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const mockValues = vi.fn().mockReturnValue({ onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate });
const mockInsert = vi.fn().mockReturnValue({ values: mockValues });
const mockLimit = vi.fn().mockResolvedValue([{ id: 42 }]);
const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit });
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere });
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom });
// Prune chain: db.delete(calendarEvents).where(...)
const mockDeleteWhere = vi.fn().mockResolvedValue([]);
const mockDelete = vi.fn().mockReturnValue({ where: mockDeleteWhere });

// Mock the db singleton at module level (Vitest hoisting)
vi.mock('../../src/db/client.js', () => ({
  db: {
    insert: mockInsert,
    select: mockSelect,
    delete: mockDelete,
  },
}));

describe('syncCalendar', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    // Reset mock implementations after clearAllMocks
    mockOnDuplicateKeyUpdate.mockResolvedValue([{ insertId: 1 }]);
    mockValues.mockReturnValue({ onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate });
    mockInsert.mockReturnValue({ values: mockValues });
    mockLimit.mockResolvedValue([{ id: 42 }]);
    mockWhere.mockReturnValue({ limit: mockLimit });
    mockFrom.mockReturnValue({ where: mockWhere });
    mockSelect.mockReturnValue({ from: mockFrom });
    mockDeleteWhere.mockResolvedValue([]);
    mockDelete.mockReturnValue({ where: mockDeleteWhere });
  });

  it('stores all-day events with dtstart_date (DATE) and dtstart_utc=NULL', async () => {
    const { syncCalendar } = await import('../../src/broker/sync.js');

    const mockClient = {
      fetchCalendarObjects: vi
        .fn()
        .mockResolvedValue([
          { data: SAMPLE_VEVENT_ALLDAY, etag: '"etag-allday"', url: '/cal/allday.ics' },
        ]),
    };
    const mockDavCal = {
      url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      displayName: 'Test Calendar',
      ctag: 'ctag-v1',
      syncToken: null,
    };

    await syncCalendar(mockClient as never, mockDavCal as never, 1);

    // insert called twice: calendars + calendarEvents
    expect(mockInsert).toHaveBeenCalledTimes(2);
    // Event insert: second call's values arg
    const eventValuesArg = mockValues.mock.calls[1][0];
    expect(eventValuesArg.allDay).toBe(true);
    expect(eventValuesArg.dtstartDate).toBeTruthy();
    expect(eventValuesArg.dtstartUtc).toBeNull();
  });

  it('stores timed events with dtstart_utc (TIMESTAMP) and dtstart_date=NULL', async () => {
    const { syncCalendar } = await import('../../src/broker/sync.js');

    const mockClient = {
      fetchCalendarObjects: vi
        .fn()
        .mockResolvedValue([
          { data: SAMPLE_VEVENT_TIMED, etag: '"etag-timed"', url: '/cal/timed.ics' },
        ]),
    };
    const mockDavCal = {
      url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      displayName: 'Test Calendar',
      ctag: 'ctag-v1',
      syncToken: null,
    };

    await syncCalendar(mockClient as never, mockDavCal as never, 1);

    expect(mockInsert).toHaveBeenCalledTimes(2);
    const eventValuesArg = mockValues.mock.calls[1][0];
    expect(eventValuesArg.allDay).toBe(false);
    expect(eventValuesArg.dtstartUtc).toBeInstanceOf(Date);
    expect(eventValuesArg.dtstartDate).toBeNull();
  });

  it('sets allDay=true for all-day events, allDay=false for timed', async () => {
    const { syncCalendar } = await import('../../src/broker/sync.js');

    const mockClient = {
      fetchCalendarObjects: vi
        .fn()
        .mockResolvedValue([{ data: SAMPLE_VEVENT_ALLDAY, etag: '"allday"', url: '/allday.ics' }]),
    };
    const mockDavCal = {
      url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Test/',
      displayName: 'Test',
      ctag: null,
      syncToken: null,
    };

    await syncCalendar(mockClient as never, mockDavCal as never, 1);
    const eventArg = mockValues.mock.calls[1][0];
    expect(eventArg.allDay).toBe(true);
  });

  it('upserts on duplicate UID within the same calendar (onDuplicateKeyUpdate called for events)', async () => {
    const { syncCalendar } = await import('../../src/broker/sync.js');

    const mockClient = {
      fetchCalendarObjects: vi
        .fn()
        .mockResolvedValue([{ data: SAMPLE_VEVENT_TIMED, etag: '"etag1"', url: '/timed.ics' }]),
    };
    const mockDavCal = {
      url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      displayName: 'Test',
      ctag: 'v2',
      syncToken: null,
    };

    await syncCalendar(mockClient as never, mockDavCal as never, 1);

    // onDuplicateKeyUpdate must be called for both the calendar upsert and the event upsert
    expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(2);
  });

  it('stores the raw VEVENT blob in rawVevent column', async () => {
    const { syncCalendar } = await import('../../src/broker/sync.js');

    const mockClient = {
      fetchCalendarObjects: vi
        .fn()
        .mockResolvedValue([{ data: SAMPLE_VEVENT_TIMED, etag: '"etag"', url: '/timed.ics' }]),
    };
    const mockDavCal = {
      url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      displayName: 'Test',
      ctag: 'v1',
      syncToken: null,
    };

    await syncCalendar(mockClient as never, mockDavCal as never, 1);
    const eventArg = mockValues.mock.calls[1][0];
    expect(eventArg.rawVevent).toBe(SAMPLE_VEVENT_TIMED);
  });

  it('sets hasRrule=true for a VEVENT with RRULE (timed recurring)', async () => {
    const { syncCalendar } = await import('../../src/broker/sync.js');

    const mockClient = {
      fetchCalendarObjects: vi
        .fn()
        .mockResolvedValue([
          { data: SAMPLE_VEVENT_RECURRING_TIMED, etag: '"etag-rrule"', url: '/rrule.ics' },
        ]),
    };
    const mockDavCal = {
      url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      displayName: 'Test Calendar',
      ctag: 'ctag-v1',
      syncToken: null,
    };

    await syncCalendar(mockClient as never, mockDavCal as never, 1);

    expect(mockInsert).toHaveBeenCalledTimes(2);
    const eventValuesArg = mockValues.mock.calls[1][0];
    expect(eventValuesArg.hasRrule).toBe(true);
  });

  it('sets hasRrule=true for an all-day VEVENT with RRULE (all-day recurring)', async () => {
    const { syncCalendar } = await import('../../src/broker/sync.js');

    const mockClient = {
      fetchCalendarObjects: vi.fn().mockResolvedValue([
        {
          data: SAMPLE_VEVENT_RECURRING_ALLDAY,
          etag: '"etag-rrule-allday"',
          url: '/rrule-allday.ics',
        },
      ]),
    };
    const mockDavCal = {
      url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      displayName: 'Test Calendar',
      ctag: 'ctag-v1',
      syncToken: null,
    };

    await syncCalendar(mockClient as never, mockDavCal as never, 1);

    expect(mockInsert).toHaveBeenCalledTimes(2);
    const eventValuesArg = mockValues.mock.calls[1][0];
    expect(eventValuesArg.hasRrule).toBe(true);
  });

  it('sets hasRrule=false for a non-recurring timed VEVENT', async () => {
    const { syncCalendar } = await import('../../src/broker/sync.js');

    const mockClient = {
      fetchCalendarObjects: vi
        .fn()
        .mockResolvedValue([
          { data: SAMPLE_VEVENT_TIMED, etag: '"etag-oneoff"', url: '/oneoff.ics' },
        ]),
    };
    const mockDavCal = {
      url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      displayName: 'Test Calendar',
      ctag: 'ctag-v1',
      syncToken: null,
    };

    await syncCalendar(mockClient as never, mockDavCal as never, 1);

    expect(mockInsert).toHaveBeenCalledTimes(2);
    const eventValuesArg = mockValues.mock.calls[1][0];
    expect(eventValuesArg.hasRrule).toBe(false);
  });

  it('includes hasRrule in onDuplicateKeyUpdate set so re-syncs self-heal the flag', async () => {
    const { syncCalendar } = await import('../../src/broker/sync.js');

    const mockClient = {
      fetchCalendarObjects: vi
        .fn()
        .mockResolvedValue([
          { data: SAMPLE_VEVENT_RECURRING_TIMED, etag: '"etag-v2"', url: '/rrule.ics' },
        ]),
    };
    const mockDavCal = {
      url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      displayName: 'Test Calendar',
      ctag: 'ctag-v2',
      syncToken: null,
    };

    await syncCalendar(mockClient as never, mockDavCal as never, 1);

    // The second onDuplicateKeyUpdate call is for the event upsert
    const eventUpdateArg = mockOnDuplicateKeyUpdate.mock.calls[1][0];
    expect(eventUpdateArg.set).toHaveProperty('hasRrule', true);
  });

  it('BUG B: scopes the calendar-row select to (userId, url), not url alone', async () => {
    // Capture the predicate passed to db.select().from(calendars).where(...).limit(1).
    const capturedWhere: unknown[] = [];
    mockWhere.mockImplementation((pred: unknown) => {
      capturedWhere.push(pred);
      return { limit: mockLimit };
    });

    const { syncCalendar } = await import('../../src/broker/sync.js');
    const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]) };
    const mockDavCal = {
      url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      displayName: 'Test',
      ctag: 'v1',
      syncToken: null,
    };

    await syncCalendar(mockClient as never, mockDavCal as never, 99);

    expect(capturedWhere.length).toBeGreaterThan(0);
    const serialized = JSON.stringify(capturedWhere[0], (_k, v) =>
      typeof v === 'object' && v !== null && 'name' in (v as Record<string, unknown>)
        ? (v as { name?: unknown }).name
        : v,
    );
    expect(serialized).toContain('user_id');
    expect(serialized).toContain('url');
  });

  it('BUG B: calendar upsert is idempotent — onDuplicateKeyUpdate fires for the calendar row', async () => {
    const { syncCalendar } = await import('../../src/broker/sync.js');
    const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]) };
    const mockDavCal = {
      url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      displayName: 'Test',
      ctag: 'v1',
      syncToken: null,
    };
    await syncCalendar(mockClient as never, mockDavCal as never, 1);
    // The calendar insert (call 0) must use onDuplicateKeyUpdate so the (userId,url)
    // unique key makes re-polls update-in-place instead of inserting duplicate rows.
    expect(mockOnDuplicateKeyUpdate).toHaveBeenCalled();
    const calUpdateArg = mockOnDuplicateKeyUpdate.mock.calls[0][0];
    expect(calUpdateArg.set).toHaveProperty('ctag');
  });

  it('updates the calendar ctag/syncToken after a successful sync', async () => {
    const { syncCalendar } = await import('../../src/broker/sync.js');

    const mockClient = {
      fetchCalendarObjects: vi.fn().mockResolvedValue([]),
    };
    const mockDavCal = {
      url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      displayName: 'Test Calendar',
      ctag: 'new-ctag-123',
      syncToken: 'sync-token-abc',
    };

    await syncCalendar(mockClient as never, mockDavCal as never, 1);

    // Calendar insert values must include the new ctag and syncToken
    const calValuesArg = mockValues.mock.calls[0][0];
    expect(calValuesArg.ctag).toBe('new-ctag-123');
    expect(calValuesArg.syncToken).toBe('sync-token-abc');
  });

  // Regression: deletes must be reconciled out of the cache. Before this fix,
  // syncCalendar only upserted present events, so a deleted event lingered in
  // calendar_events forever and the UI showed a ghost that "wouldn't delete".
  it('prunes cached events whose uid is absent from the server (delete reconciliation)', async () => {
    const { syncCalendar } = await import('../../src/broker/sync.js');

    // Server returns ONE timed event; any other cached uid for this calendar must be pruned.
    const mockClient = {
      fetchCalendarObjects: vi
        .fn()
        .mockResolvedValue([
          { data: SAMPLE_VEVENT_TIMED, etag: '"etag-timed"', url: '/cal/timed.ics' },
        ]),
    };
    const mockDavCal = {
      url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      displayName: 'Test Calendar',
      ctag: 'ctag-v2',
      syncToken: null,
    };

    await syncCalendar(mockClient as never, mockDavCal as never, 1);

    // A prune DELETE must run, scoped by calendarId AND excluding the seen uid(s).
    expect(mockDelete).toHaveBeenCalledTimes(1);
    expect(mockDeleteWhere).toHaveBeenCalledTimes(1);
  });

  it('prunes the entire calendar cache when the server returns zero events', async () => {
    const { syncCalendar } = await import('../../src/broker/sync.js');

    const mockClient = {
      fetchCalendarObjects: vi.fn().mockResolvedValue([]),
    };
    const mockDavCal = {
      url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      displayName: 'Test Calendar',
      ctag: 'ctag-empty',
      syncToken: null,
    };

    await syncCalendar(mockClient as never, mockDavCal as never, 1);

    // Empty server result → prune-all DELETE (scoped to this calendar id only).
    expect(mockDelete).toHaveBeenCalledTimes(1);
    expect(mockDeleteWhere).toHaveBeenCalledTimes(1);
  });

  // NEW-WR-01: When the server returns zero events (whole-cache clear), the
  // onChanges callback must still receive a 'delete' change for each cached row
  // that is being pruned. Before this fix, the pendingDeleteRows pre-capture only
  // ran inside the seenUids.length > 0 branch, so bulk/clear deletions silently
  // dropped all delete change events.
  it('NEW-WR-01: emits delete change events for each cached row when server returns zero events', async () => {
    const { syncCalendar } = await import('../../src/broker/sync.js');

    // First mockWhere call: calendar row lookup (ends in .limit(1)) → { limit: mockLimit }
    // Second mockWhere call: pending-delete pre-capture (direct await, no .limit()) →
    //   must return an iterable array of cached rows. Use mockReturnValueOnce for the
    //   second call so it returns a resolved Promise<array> instead of { limit }.
    //
    // Call order when seenUids.length === 0 and onChanges is provided:
    //   1. db.select().from(calendars).where(...).limit(1) — calendar row lookup
    //   2. db.select({uid,title}).from(calendarEvents).where(...) — pending-delete capture (awaited directly)
    mockWhere
      .mockReturnValueOnce({ limit: mockLimit }) // call 1: calendar row lookup
      .mockResolvedValueOnce([
        // call 2: pending-delete capture
        { uid: 'uid-to-delete-1', title: 'Event A' },
        { uid: 'uid-to-delete-2', title: 'Event B' },
      ]);

    const mockClient = {
      fetchCalendarObjects: vi.fn().mockResolvedValue([]),
    };
    const mockDavCal = {
      url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      displayName: 'Test Calendar',
      ctag: 'ctag-empty',
      syncToken: null,
    };

    const collectedChanges: import('../../src/lib/eventChangeDispatcher.js').EventChange[] = [];
    const onChanges = (changes: import('../../src/lib/eventChangeDispatcher.js').EventChange[]) => {
      collectedChanges.push(...changes);
    };

    await syncCalendar(mockClient as never, mockDavCal as never, 1, onChanges);

    // The callback must have received one delete change per cached row.
    expect(collectedChanges).toHaveLength(2);
    expect(collectedChanges[0]).toMatchObject({ uid: 'uid-to-delete-1', operation: 'delete' });
    expect(collectedChanges[1]).toMatchObject({ uid: 'uid-to-delete-2', operation: 'delete' });
  });
});
