/**
 * Tests for GET /api/events — windowed endpoint.
 *
 * Contracts:
 *  1. Missing/malformed start or end params → 400 (input validation guard)
 *  2. Window wider than 90 days → 400 (DoS cap)
 *  3. Valid window returns { occurrences: [] } when DB is empty
 *  4. Recurring timed master (dtstartUtc years before window, hasRrule=1) yields occurrences
 *     inside the requested window via expandOccurrences.
 *  5. Recurring all-day master (dtstartUtc NULL, dtstartDate years before window, hasRrule=1)
 *     is NOT filtered out — its occurrence in the window is returned.
 *
 * Write endpoint contracts (added Plan 03-03):
 *  6. POST /create validates input, enqueues outbox row, returns 202.
 *  7. POST /create with non-owned calendarUrl returns 403 (D-03 / V4).
 *  8. PATCH /:uid/edit enqueues update outbox row, returns 202.
 *  9. DELETE /:uid enqueues delete outbox row, returns 202.
 * 10. GET /sync-status?uid= returns outbox status for the member's UID.
 * 11. GET /writable-calendars returns member's own + shared calendars, never other member's personal.
 */

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

// ---------------------------------------------------------------------------
// Configurable getAuth and devBypass so individual tests can control the auth
// path without reloading the module. Uses vi.hoisted() to avoid TDZ issues
// (D-03-04-hoisting: vi.hoisted() required when test file has static imports of
// modules that vi.mock() references in their factory callbacks).
// ---------------------------------------------------------------------------

// getAuthImpl: default returns null (unauthenticated); CR-06 OIDC tests override it.
const { getAuthImpl, devBypassInjectUser } = vi.hoisted(() => ({
  getAuthImpl: { fn: null as (() => unknown) | null },
  devBypassInjectUser: { active: true }, // true = inject dev user; false = passthrough
}));

// Mock @hono/oidc-auth so tests do not need a live Authelia instance.
// The mock makes oidcAuthMiddleware a no-op passthrough.
// getAuth delegates to getAuthImpl.fn so per-test overrides work at call time.
vi.mock('@hono/oidc-auth', () => ({
  oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
  processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
  getAuth: () => (getAuthImpl.fn ? getAuthImpl.fn() : null),
}));

// Mock upsertUser for the OIDC path — CR-06 tests override mockUpsertUser.fn.
const mockUpsertUserFn = vi.fn();
vi.mock('../../src/auth/user.js', async (importActual) => {
  // Keep deriveDisplayName (a pure helper) real so the displayName the handler
  // passes to upsertUser reflects production claim-preference logic; stub only
  // upsertUser (the DB-touching function).
  const actual = await importActual<typeof import('../../src/auth/user.js')>();
  return {
    ...actual,
    upsertUser: (...args: unknown[]) => mockUpsertUserFn(...args),
  };
});

// ---------------------------------------------------------------------------
// Configurable mock for the DB.
// ALL db methods used by the events router are mocked here so vi.mock can
// reference them. Vitest hoists vi.mock to the top of the file — the factory
// must refer to variables that are mutable (reassigned in beforeEach) via
// wrapper fns rather than direct references to lets.
// ---------------------------------------------------------------------------

// --- select chain ---
let mockDbRows: unknown[] = [];
const mockWhereFn = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows));
const mockInnerJoin2Fn = vi.fn().mockReturnValue({ where: mockWhereFn });
const mockInnerJoin1Fn = vi.fn().mockReturnValue({ innerJoin: mockInnerJoin2Fn });
const mockFromFn = vi.fn().mockReturnValue({ innerJoin: mockInnerJoin1Fn });
const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn });

// --- Simple select chain used by /sync-status and /writable-calendars (no joins) ---
// We intercept via mockSelectFn dispatcher — each test controls which path fires
// by configuring mockFromFn to return either join chain or where-only chain.

// --- insert chain ---
let mockInsertValuesFn = vi.fn().mockResolvedValue([{ insertId: 1 }]);
const mockInsertFn = vi.fn().mockImplementation(() => ({ values: mockInsertValuesFn }));

// --- transaction ---
// Drizzle transaction receives a callback (tx => ...). The mock invokes the callback
// with a fake tx object that has insert: mockInsertFn so transactional inserts are counted.
const mockTransactionFn = vi
  .fn()
  .mockImplementation(async (cb: (tx: { insert: typeof mockInsertFn }) => Promise<void>) => {
    await cb({ insert: mockInsertFn });
  });

vi.mock('../../src/db/client.js', () => ({
  db: {
    select: mockSelectFn,
    insert: mockInsertFn,
    transaction: mockTransactionFn,
  },
}));

// ---------------------------------------------------------------------------
// Shared state reset
// ---------------------------------------------------------------------------
beforeEach(() => {
  mockDbRows = [];
  vi.clearAllMocks();

  // Reset auth stubs to safe defaults for each test
  getAuthImpl.fn = null; // getAuth returns null (unauthenticated)
  devBypassInjectUser.active = true; // inject dev user (most tests use dev bypass)
  mockUpsertUserFn.mockResolvedValue({
    id: 42,
    oidcIss: 'https://auth.example.com',
    oidcSub: 'sub-abc',
    displayName: 'OIDC User',
    color: '#E8734A',
  });

  // Restore select chain (vi.clearAllMocks wipes mockImplementation)
  mockWhereFn.mockImplementation(() => Promise.resolve(mockDbRows));
  mockInnerJoin2Fn.mockReturnValue({ where: mockWhereFn });
  mockInnerJoin1Fn.mockReturnValue({ innerJoin: mockInnerJoin2Fn });
  mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin1Fn });
  mockSelectFn.mockReturnValue({ from: mockFromFn });

  // Restore insert chain
  mockInsertValuesFn = vi.fn().mockResolvedValue([{ insertId: 1 }]);
  mockInsertFn.mockImplementation(() => ({ values: mockInsertValuesFn }));

  // Restore transaction
  mockTransactionFn.mockImplementation(
    async (cb: (tx: { insert: typeof mockInsertFn }) => Promise<void>) => {
      await cb({ insert: mockInsertFn });
    },
  );
});

// ---------------------------------------------------------------------------
// GET /api/events — windowed read
// ---------------------------------------------------------------------------
describe('GET /api/events', () => {
  beforeEach(() => {
    // The windowed GET uses the 3-join select chain; ensure it is wired.
    mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin1Fn });
    mockSelectFn.mockReturnValue({ from: mockFromFn });
  });

  it('returns 400 when start param is missing', async () => {
    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events?end=2026-07-01');
    expect(res.status).toBe(400);
  });

  it('returns 400 when end param is missing', async () => {
    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events?start=2026-06-01');
    expect(res.status).toBe(400);
  });

  it('returns 400 when start param is malformed (not YYYY-MM-DD)', async () => {
    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events?start=not-a-date&end=2026-07-01');
    expect(res.status).toBe(400);
  });

  it('returns occurrences with color, isShared, and ownerName fields for valid window', async () => {
    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events?start=2026-06-01&end=2026-07-01');
    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      occurrences: Array<{ color: string; isShared: boolean; ownerName: string | null }>;
    };
    expect(body).toHaveProperty('occurrences');
    expect(Array.isArray(body.occurrences)).toBe(true);
    // Each occurrence must carry color + isShared + ownerName (may be empty array if DB is mocked empty)
    for (const occ of body.occurrences) {
      expect(occ).toHaveProperty('color');
      expect(typeof occ.color).toBe('string');
      expect(occ).toHaveProperty('isShared');
      expect(typeof occ.isShared).toBe('boolean');
      expect(occ).toHaveProperty('ownerName');
      // ownerName is string | null — both are valid
      expect(occ.ownerName === null || typeof occ.ownerName === 'string').toBe(true);
    }
  });

  it('returns occurrences for a timed recurring master whose dtstartUtc is years before the window', async () => {
    // Simulates a weekly meeting created in 2024 (dtstartUtc=2024-01-01) being queried for
    // a 2026 window. The SQL pre-filter must select it (hasRrule=1 AND dtstartUtc < windowEnd)
    // and expandOccurrences must return the in-window occurrences.
    // SAMPLE_VEVENT_RECURRING_TIMED has DTSTART:20240101T100000Z RRULE:FREQ=WEEKLY;BYDAY=MO.
    // In the 2026-06-01..2026-07-01 window there are 4 Mondays.
    mockDbRows = [
      {
        rawVevent: SAMPLE_VEVENT_RECURRING_TIMED,
        calendarId: 1,
        calendarName: 'Work',
        isShared: false,
        userId: 1,
        userColor: '#4A90D9',
        ownerName: 'Alice',
      },
    ];

    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events?start=2026-06-01&end=2026-07-01');
    expect(res.status).toBe(200);

    const body = (await res.json()) as {
      occurrences: Array<{ uid: string; start: string; allDay: boolean; ownerName: string | null }>;
    };
    expect(body.occurrences.length).toBeGreaterThan(0);

    // All returned occurrences must be inside [2026-06-01, 2026-07-01)
    for (const occ of body.occurrences) {
      expect(occ.uid).toBe('test-recurring-timed-001@familysync');
      expect(occ.allDay).toBe(false);
      // start must be within the window
      const startDate = occ.start.slice(0, 10); // 'YYYY-MM-DD'
      expect(startDate >= '2026-06-01').toBe(true);
      expect(startDate < '2026-07-01').toBe(true);
      // ownerName from the mock row must be threaded through
      expect(occ.ownerName).toBe('Alice');
    }
  });

  it('returns occurrence for an all-day recurring master whose dtstartDate is years before the window (dtstartUtc NULL)', async () => {
    // Simulates an annual birthday created in 2024 (DTSTART;VALUE=DATE:20240615, no dtstartUtc).
    // The SQL pre-filter MUST NOT exclude it on dtstartUtc IS NULL — the fix adds
    //   OR dtstartDate < end
    // so this row is selected and expandOccurrences returns the 2026-06-15 occurrence.
    // SAMPLE_VEVENT_RECURRING_ALLDAY has DTSTART;VALUE=DATE:20240615 RRULE:FREQ=YEARLY.
    mockDbRows = [
      {
        rawVevent: SAMPLE_VEVENT_RECURRING_ALLDAY,
        calendarId: 2,
        calendarName: 'Personal',
        isShared: false,
        userId: 1,
        userColor: '#50C878',
        ownerName: 'Bob',
      },
    ];

    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events?start=2026-06-01&end=2026-07-01');
    expect(res.status).toBe(200);

    const body = (await res.json()) as {
      occurrences: Array<{ uid: string; start: string; allDay: boolean }>;
    };
    // There must be exactly one annual occurrence (June 15) in the window
    expect(body.occurrences.length).toBe(1);

    const occ = body.occurrences[0];
    expect(occ.uid).toBe('test-recurring-allday-001@familysync');
    expect(occ.allDay).toBe(true);
    expect(occ.start).toBe('2026-06-15');
  });
});

// ---------------------------------------------------------------------------
// Write endpoints + sync-status + writable-calendars (Plan 03-03)
//
// CAL-04/05/06, D-03, D-05, D-08, D-09
//
// These tests extend the existing GET /api/events suite.
// They were RED in Plan 01 and turn GREEN in Plan 03-03.
//
// Auth: the top-level vi.mock for @hono/oidc-auth sets getAuth → null.
// The dev-bypass (DEV_AUTH_BYPASS env) is NOT active in the test environment,
// so the routes fall through to getAuth(c) which returns null → 401.
//
// HOWEVER: for write routes in test, we need an authenticated user. The test
// environment sets DEV_AUTH_BYPASS=true via the vitest.config or the route's
// dev-bypass reads c.get('user'). But since DEV_AUTH_BYPASS env is not set in
// the test runner, c.get('user') will be undefined and getAuth(c) returns null.
//
// Resolution: the write route implementation falls back gracefully — when both
// devUser and getAuth return null/undefined, it returns 401. But the write tests
// do NOT check for 401; they expect 202/400/403. Therefore:
//   - We need a way to inject a user. The cleanest approach is to mock the
//     devBypass module so that c.get('user') returns DEV_USER in tests.
//   - We mock '../auth/devBypass.js' to inject a fixed user into context.
// ---------------------------------------------------------------------------

// Mock devAuthBypass: delegates to devBypassInjectUser.active flag so CR-06 tests
// can simulate the OIDC path by setting devBypassInjectUser.active = false.
vi.mock('../../src/auth/devBypass.js', async (importOriginal) => {
  const original = await importOriginal<typeof import('../../src/auth/devBypass.js')>();
  return {
    ...original,
    devAuthBypass:
      () => async (c: { set: (k: string, v: unknown) => void }, next: () => Promise<void>) => {
        if (devBypassInjectUser.active) {
          c.set('user', {
            id: 1,
            oidcIss: 'dev',
            oidcSub: 'dev-user',
            displayName: 'Dev User',
            color: '#4A90D9',
          });
        }
        await next();
      },
  };
});

// ---------------------------------------------------------------------------
// POST /api/events/create
// ---------------------------------------------------------------------------
describe('POST /api/events/create', () => {
  beforeEach(() => {
    // create: select chain used to look up calendar ownership.
    // Default: mockDbRows=[] means no owned calendar found → 403 for calendarUrl given.
    // To get 202, we need to either supply no calendarUrl (uses default) or supply a
    // calendarUrl that matches a calendar row. For simplicity: mock a calendar row as
    // owned by user 1 matching the test calendarUrl.
    mockDbRows = [
      {
        id: 1,
        url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
        displayName: 'Default',
        color: '#4A90D9',
        userId: 1,
        isShared: false,
      },
    ];
    // Rewire select chain for simple where-only queries (no joins needed for calendar lookup).
    const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows));
    mockFromFn.mockReturnValue({ where: mockSimpleWhere });
    mockSelectFn.mockReturnValue({ from: mockFromFn });
  });

  it('returns 202 and inserts a pending outbox row for a valid create request', async () => {
    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/create', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: 'New event',
        allDay: false,
        start: '2026-06-15T10:00:00Z',
        end: '2026-06-15T11:00:00Z',
        calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      }),
    });
    expect(res.status).toBe(202);
    const body = (await res.json()) as { uid: string };
    expect(body).toHaveProperty('uid');
    expect(typeof body.uid).toBe('string');
  });

  it('returns 400 for missing required fields', async () => {
    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/create', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ summary: 'Missing dates' }),
    });
    expect(res.status).toBe(400);
  });

  it('returns 403 when writing to a calendar not owned by the user (D-03 / V4)', async () => {
    // Override: no owned calendar matches this URL
    mockDbRows = [];
    const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve([]));
    mockFromFn.mockReturnValue({ where: mockSimpleWhere });
    mockSelectFn.mockReturnValue({ from: mockFromFn });

    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/create', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: 'Unauthorized write',
        allDay: false,
        start: '2026-06-15T10:00:00Z',
        end: '2026-06-15T11:00:00Z',
        calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/other@fm.com/Personal/',
      }),
    });
    expect(res.status).toBe(403);
  });
});

// ---------------------------------------------------------------------------
// PATCH /api/events/:uid/edit
// ---------------------------------------------------------------------------
describe('PATCH /api/events/:uid/edit', () => {
  beforeEach(() => {
    // Seed mock DB with a calendar event row that the edit will look up by uid.
    // The event's calendar must be owned by user 1 (dev user).
    mockDbRows = [
      {
        uid: 'uid-001@familysync',
        etag: '"etag-abc"',
        objectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/uid-001.ics',
        calendarId: 1,
        calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
        userId: 1,
      },
    ];
    // After BUG 1 + CR-01 fix, the edit lookup uses
    // .from(calendarEvents).innerJoin(calendars, ...).where(...).orderBy(...).limit(1).
    // Wire mockFromFn to expose innerJoin → where → orderBy → limit so the handler resolves mockDbRows.
    const mockInnerJoinWhere = vi.fn().mockReturnValue({
      orderBy: vi.fn().mockReturnValue({
        limit: vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)),
      }),
    });
    const mockInnerJoin = vi.fn().mockReturnValue({ where: mockInnerJoinWhere });
    mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin });
    mockSelectFn.mockReturnValue({ from: mockFromFn });
  });

  it('returns 202 and inserts an outbox row with etag for a valid edit request', async () => {
    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/uid-001%40familysync/edit', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: 'Updated title',
        allDay: false,
        start: '2026-06-15T10:00:00Z',
        end: '2026-06-15T11:00:00Z',
        calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      }),
    });
    expect(res.status).toBe(202);
  });

  // CR-01 (iteration 2): edit-as-move must carry the source event's RRULE through
  // to the create row so a moved recurring series does not silently collapse into a
  // single occurrence. The edit payload omits `recurrence`; the route must extract the
  // RRULE from the source event's rawVevent and stash it on the create payload as
  // `_preservedRrule` so the worker re-applies it.
  it('CR-01: move of a recurring event stashes the source RRULE on the create outbox row', async () => {
    // Seed a recurring source event (RRULE:FREQ=WEEKLY;BYDAY=MO) on the user's calendar.
    mockDbRows = [
      {
        uid: 'uid-001@familysync',
        etag: '"etag-abc"',
        objectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/uid-001.ics',
        calendarId: 1,
        calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
        userId: 1,
        rawVevent: SAMPLE_VEVENT_RECURRING_TIMED,
      },
    ];
    const mockInnerJoinWhere = vi.fn().mockReturnValue({
      orderBy: vi.fn().mockReturnValue({
        limit: vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)),
      }),
    });
    const mockInnerJoin = vi.fn().mockReturnValue({ where: mockInnerJoinWhere });
    mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin });
    mockSelectFn.mockReturnValue({ from: mockFromFn });

    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/uid-001%40familysync/edit', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: 'Moved event',
        allDay: false,
        start: '2026-06-15T10:00:00Z',
        end: '2026-06-15T11:00:00Z',
        // Different calendarUrl than the source → triggers the edit-as-move path (D-04)
        calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Family/',
      }),
    });
    expect(res.status).toBe(202);

    // The move inserts a delete+create pair inside a transaction. Find the create row's
    // payload and assert the preserved RRULE survived.
    const createInsertCall = mockInsertValuesFn.mock.calls.find((call) => {
      const arg = call[0] as { operation?: string; payload?: string };
      return arg?.operation === 'create';
    });
    expect(createInsertCall).toBeDefined();
    const createArg = createInsertCall![0] as { payload: string };
    const parsed = JSON.parse(createArg.payload) as { _preservedRrule?: string };
    expect(parsed._preservedRrule).toBe('FREQ=WEEKLY;BYDAY=MO');
  });
});

// ---------------------------------------------------------------------------
// DELETE /api/events/:uid
// ---------------------------------------------------------------------------
describe('DELETE /api/events/:uid', () => {
  beforeEach(() => {
    // Seed a matching event row
    mockDbRows = [
      {
        uid: 'uid-001@familysync',
        etag: '"etag-abc"',
        objectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/uid-001.ics',
        calendarId: 1,
        calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
        userId: 1,
      },
    ];
    // After BUG 1 + CR-01 fix, the delete lookup uses
    // .from(calendarEvents).innerJoin(calendars, ...).where(...).orderBy(...).limit(1).
    // Wire mockFromFn to expose innerJoin → where → orderBy → limit so the handler resolves mockDbRows.
    const mockInnerJoinWhere = vi.fn().mockReturnValue({
      orderBy: vi.fn().mockReturnValue({
        limit: vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)),
      }),
    });
    const mockInnerJoin = vi.fn().mockReturnValue({ where: mockInnerJoinWhere });
    mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin });
    mockSelectFn.mockReturnValue({ from: mockFromFn });
  });

  it('returns 202 and inserts a delete outbox row', async () => {
    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/uid-001%40familysync', {
      method: 'DELETE',
    });
    expect(res.status).toBe(202);
  });
});

// ---------------------------------------------------------------------------
// GET /api/events/sync-status
// ---------------------------------------------------------------------------
describe('GET /api/events/sync-status', () => {
  it('returns the outbox status for a given uid (row found → pending)', async () => {
    // Seed a pending outbox row
    mockDbRows = [{ uid: 'uid-001@familysync', status: 'pending', lastError: null, userId: 1 }];
    // Simple select chain: .from().where() returns the outbox row
    const mockOrderBy = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows));
    const mockLimit = vi.fn().mockReturnValue(mockOrderBy);
    const mockSimpleWhere = vi.fn().mockReturnValue({ limit: mockLimit });
    const _mockOrderByDirect = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows));
    // Some implementations use .where().orderBy() or just .where()
    mockSimpleWhere.mockReturnValue({
      limit: mockLimit,
      orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(mockDbRows) }),
    });
    mockFromFn.mockReturnValue({ where: mockSimpleWhere });
    mockSelectFn.mockReturnValue({ from: mockFromFn });

    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/sync-status?uid=uid-001%40familysync');
    expect(res.status).toBe(200);
    const body = (await res.json()) as { uid: string; status: string };
    expect(body).toHaveProperty('uid');
    expect(body).toHaveProperty('status');
    expect(['pending', 'done', 'failed', 'dead']).toContain(body.status);
  });

  it('returns status done when no outbox row exists for uid', async () => {
    mockDbRows = [];
    const mockSimpleWhere = vi.fn().mockReturnValue({
      orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
    });
    mockFromFn.mockReturnValue({ where: mockSimpleWhere });
    mockSelectFn.mockReturnValue({ from: mockFromFn });

    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/sync-status?uid=uid-999%40familysync');
    expect(res.status).toBe(200);
    const body = (await res.json()) as { uid: string; status: string };
    expect(body.status).toBe('done');
  });

  // WR-04 (iteration 2): with multiple rows for one uid, the query must rank a
  // failed/dead row above a later done row so a silently-failed queued write is not
  // masked by a subsequent success. The handler returns the top-ranked row's status and
  // surfaces its error. The orderBy must include the status-priority CASE expression so
  // the ranking is enforced in SQL (the mock cannot run real ORDER BY).
  it('WR-04: prioritizes a dead row over a done row and surfaces its error', async () => {
    // Seed the row the priority-ordered query WOULD return first: the dead one.
    mockDbRows = [{ uid: 'uid-001@familysync', status: 'dead', lastError: 'boom', userId: 1 }];
    const orderBySpy = vi.fn().mockReturnValue({
      limit: vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)),
    });
    const mockSimpleWhere = vi.fn().mockReturnValue({ orderBy: orderBySpy });
    mockFromFn.mockReturnValue({ where: mockSimpleWhere });
    mockSelectFn.mockReturnValue({ from: mockFromFn });

    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/sync-status?uid=uid-001%40familysync');
    expect(res.status).toBe(200);
    const body = (await res.json()) as { uid: string; status: string; error?: string };
    expect(body.status).toBe('dead');
    expect(body.error).toBe('boom');

    // The status-priority CASE expression must be part of the ORDER BY (WR-04).
    // Drizzle sql`` builds a SQL object whose static text lives in queryChunks; scan
    // those for the literal 'case' rather than JSON.stringify (the Drizzle structures
    // are circular and throw on serialization).
    expect(orderBySpy).toHaveBeenCalled();
    const orderByArgs = orderBySpy.mock.calls[0] as Array<{ queryChunks?: unknown[] }>;
    const caseText = orderByArgs
      .flatMap((arg) => (Array.isArray(arg?.queryChunks) ? arg.queryChunks : []))
      .map((chunk) => {
        // String chunks are { value: string[] } (StringChunk); join their values.
        const value = (chunk as { value?: unknown })?.value;
        return Array.isArray(value) ? value.join('') : '';
      })
      .join(' ')
      .toLowerCase();
    expect(caseText).toContain('case');
  });
});

// ---------------------------------------------------------------------------
// CR-01 contract tests — canonical client payload shape (title/start/end)
//
// The PWA sends {title, start, end, allDay, recurrence} — the exact
// CreateEventPayload shape from apps/pwa/src/api/client.ts:119-128.
// These tests assert the server schema accepts that shape (202), NOT 400.
// RED before Task 1 schema rename; GREEN after.
// ---------------------------------------------------------------------------
describe('CR-01: canonical client payload (title/start/end) accepted by server', () => {
  beforeEach(() => {
    // Seed a calendar row for calendar ownership check in create
    mockDbRows = [
      {
        id: 1,
        url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
        displayName: 'Default',
        color: '#4A90D9',
        userId: 1,
        isShared: false,
      },
    ];
    const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows));
    mockFromFn.mockReturnValue({ where: mockSimpleWhere });
    mockSelectFn.mockReturnValue({ from: mockFromFn });
  });

  it('POST /create with exact CreateEventPayload shape {title,start,end,allDay,recurrence} returns 202 not 400', async () => {
    // This mirrors the exact payload apps/pwa/src/api/client.ts CreateEventPayload sends
    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/create', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: 'Team standup',
        allDay: false,
        start: '2026-06-15T10:00:00Z',
        end: '2026-06-15T10:30:00Z',
        recurrence: 'none',
        calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      }),
    });
    expect(res.status).toBe(202);
    const body = (await res.json()) as { uid: string };
    expect(body).toHaveProperty('uid');
  });

  it('PATCH /:uid/edit with exact CreateEventPayload shape {title,start,end,allDay,recurrence} returns 202 not 400', async () => {
    // Seed an event row for edit lookup
    mockDbRows = [
      {
        uid: 'uid-cr01@familysync',
        etag: '"etag-cr01"',
        objectUrl:
          'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/uid-cr01.ics',
        calendarId: 1,
        calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
        userId: 1,
      },
    ];
    // Edit lookup uses innerJoin → where → orderBy → limit after BUG 1 + CR-01 fix
    const mockInnerJoinWhere = vi.fn().mockReturnValue({
      orderBy: vi.fn().mockReturnValue({
        limit: vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)),
      }),
    });
    const mockInnerJoin = vi.fn().mockReturnValue({ where: mockInnerJoinWhere });
    mockFromFn.mockReturnValue({ innerJoin: mockInnerJoin });
    mockSelectFn.mockReturnValue({ from: mockFromFn });

    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/uid-cr01%40familysync/edit', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: 'Updated standup',
        allDay: false,
        start: '2026-06-15T10:00:00Z',
        end: '2026-06-15T10:30:00Z',
        recurrence: 'weekly',
        calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      }),
    });
    expect(res.status).toBe(202);
  });
});

// ---------------------------------------------------------------------------
// CR-06 contract tests — real OIDC iss/sub → users.id resolution on write handlers
//
// The current handler returns 401 unconditionally when c.get('user') is null,
// even if getAuth() returns a valid OIDC session. These tests assert:
//   - OIDC session (no dev bypass) → resolveUserId calls upsertUser → 202 (not 401)
//   - No session at all → 401 (still unauthenticated)
//
// RED before Task 2 resolveUserId async rewrite; GREEN after.
// ---------------------------------------------------------------------------
describe('CR-06: OIDC iss/sub → users.id resolution on write handlers', () => {
  beforeEach(() => {
    // Disable dev bypass — simulate production OIDC path
    devBypassInjectUser.active = false;

    // Seed a calendar row for the create endpoint's ownership check
    mockDbRows = [
      {
        id: 1,
        url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
        displayName: 'Default',
        color: '#4A90D9',
        userId: 42, // matches the upserted user id
        isShared: false,
      },
    ];
    const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows));
    mockFromFn.mockReturnValue({ where: mockSimpleWhere });
    mockSelectFn.mockReturnValue({ from: mockFromFn });
  });

  it('POST /create with valid OIDC session (no dev bypass) returns 202, not 401', async () => {
    // getAuth returns a real OIDC payload — upsertUser resolves user.id = 42
    getAuthImpl.fn = () => ({
      iss: 'https://auth.example.com',
      sub: 'sub-abc',
      email: 'user@example.com',
    });
    mockUpsertUserFn.mockResolvedValue({
      id: 42,
      oidcIss: 'https://auth.example.com',
      oidcSub: 'sub-abc',
      displayName: 'OIDC User',
      color: '#E8734A',
    });

    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/create', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: 'OIDC event',
        allDay: false,
        start: '2026-06-15T10:00:00Z',
        end: '2026-06-15T10:30:00Z',
        recurrence: 'none',
        calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      }),
    });
    // Handler must NOT return 401 — the OIDC session is valid and resolves a user
    expect(res.status).toBe(202);
    // upsertUser was called with the OIDC iss/sub
    expect(mockUpsertUserFn).toHaveBeenCalledWith(
      'https://auth.example.com',
      'sub-abc',
      'user@example.com',
    );
  });

  it('POST /create with no session (getAuth returns null) returns 401', async () => {
    // getAuth returns null → no session; should return 401
    getAuthImpl.fn = () => null;

    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/create', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: 'Unauthenticated event',
        allDay: false,
        start: '2026-06-15T10:00:00Z',
        end: '2026-06-15T10:30:00Z',
        recurrence: 'none',
      }),
    });
    expect(res.status).toBe(401);
  });
});

// ---------------------------------------------------------------------------
// Regression: edit/delete lookups must join calendars table
//
// Uses the real Drizzle query builder (via vi.importActual) to produce SQL via
// toSQL() — no DB connection needed. The test builds the query the same way the
// handler does and asserts the generated SQL contains an inner join to calendars.
//
// RED:  Without the join (current buggy handler shape), toSQL() omits the join
//       clause → the /inner join.*calendars/ assertion fails.
// GREEN: After adding .innerJoin(calendars, ...) the SQL contains the join.
// ---------------------------------------------------------------------------
// Regression: BUG 1 — the edit/delete lookups select calendars.url/userId but
// must JOIN calendars to do so. Without the join Drizzle throws at query-build
// → the handler's catch returns 503 (delete dialog never closes).
//
// This couples to the HANDLER, not a query the test rebuilds. The select-chain
// mock exposes from() → innerJoin() → where(); a no-join handler that calls
// .from().where() hits an undefined .where() → throws → 503, AND never invokes
// the innerJoin spy. So both assertions go RED if the join is removed from
// events.ts; they pass only because the handler actually joins.
describe('regression: edit/delete lookups join calendars (BUG 1)', () => {
  const seededRow = {
    uid: 'uid-001@familysync',
    etag: '"etag-abc"',
    objectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/uid-001.ics',
    calendarId: 1,
    calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
    userId: 1,
  };
  let innerJoinSpy: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    mockDbRows = [seededRow];
    // Wire from() → innerJoin() → where() → orderBy() → limit(); the innerJoin spy
    // proves the handler routes through the join rather than calling .where() directly
    // on from(). The CR-01 fix appends .orderBy(...).limit(1) after .where(...).
    const innerJoinWhere = vi.fn().mockReturnValue({
      orderBy: vi.fn().mockReturnValue({
        limit: vi.fn().mockImplementation(() => Promise.resolve(mockDbRows)),
      }),
    });
    innerJoinSpy = vi.fn().mockReturnValue({ where: innerJoinWhere });
    mockFromFn.mockReturnValue({ innerJoin: innerJoinSpy });
    mockSelectFn.mockReturnValue({ from: mockFromFn });
  });

  it('PATCH /:uid/edit joins calendars (no 503) and invokes innerJoin', async () => {
    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/uid-001%40familysync/edit', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: 'Updated title',
        allDay: false,
        start: '2026-06-15T10:00:00Z',
        end: '2026-06-15T11:00:00Z',
        calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
      }),
    });
    expect(res.status).toBe(202);
    expect(innerJoinSpy).toHaveBeenCalled();
  });

  it('DELETE /:uid joins calendars (no 503) and invokes innerJoin', async () => {
    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/uid-001%40familysync', {
      method: 'DELETE',
    });
    expect(res.status).toBe(202);
    expect(innerJoinSpy).toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// GET /api/events/writable-calendars
// ---------------------------------------------------------------------------
describe('GET /api/events/writable-calendars', () => {
  it('returns calendars writable by the current user (own personal + shared isShared=1)', async () => {
    // Seed: user 1's personal + a shared calendar
    mockDbRows = [
      {
        id: 1,
        url: 'https://caldav.fm/personal/',
        displayName: 'Personal',
        color: '#4A90D9',
        userId: 1,
        isShared: false,
      },
      {
        id: 3,
        url: 'https://caldav.fm/family/',
        displayName: 'Family',
        color: '#F25C7A',
        userId: 1,
        isShared: true,
      },
    ];
    const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows));
    mockFromFn.mockReturnValue({ where: mockSimpleWhere });
    mockSelectFn.mockReturnValue({ from: mockFromFn });

    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/writable-calendars');
    expect(res.status).toBe(200);
    const body = (await res.json()) as { calendars: unknown[] };
    expect(body).toHaveProperty('calendars');
    expect(Array.isArray(body.calendars)).toBe(true);
    expect(body.calendars.length).toBe(2);
  });

  it("does NOT return another member's personal calendar (D-03 / V4 access control)", async () => {
    // Seed: only user 1's personal — no other member's personal should leak through.
    // The DB query itself is scoped (WHERE userId=1 OR isShared=1) so a second member's
    // personal calendar (userId=2, isShared=false) never appears in the result set.
    mockDbRows = [
      {
        id: 1,
        url: 'https://caldav.fm/personal/',
        displayName: 'Personal',
        color: '#4A90D9',
        userId: 1,
        isShared: false,
      },
    ];
    const mockSimpleWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows));
    mockFromFn.mockReturnValue({ where: mockSimpleWhere });
    mockSelectFn.mockReturnValue({ from: mockFromFn });

    const { app } = await import('../../src/index.js');
    const res = await app.request('/api/events/writable-calendars');
    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      calendars: Array<{ url: string; displayName: string; color: string; isShared: boolean }>;
    };
    // No calendar with a different userId and isShared=false should appear
    for (const cal of body.calendars) {
      // All returned calendars must expose the WritableCalendar shape
      expect(cal).toHaveProperty('url');
      expect(cal).toHaveProperty('displayName');
      expect(cal).toHaveProperty('color');
      expect(cal).toHaveProperty('isShared');
    }
    // Specifically: user 2's personal calendar (id=2, userId=2, isShared=false) is absent.
    // Since the DB mock only returns user 1's calendar, this is guaranteed by the query scope.
    expect(body.calendars.length).toBe(1);
  });
});
