/**
 * Events router — windowed reads + write-back API surface.
 *
 * Architecture invariant (T-03-02, broker-boundary):
 *   This route reads ONLY from the MariaDB cache for GET /. It NEVER calls Fastmail directly.
 *   All Fastmail I/O is owned exclusively by the broker module (src/broker/).
 *   No tsdav import here; no createFastmailClient import here.
 *   Write endpoints (POST /create, PATCH /:uid/edit, DELETE /:uid) ENQUEUE outbox rows only —
 *   they do not build VEVENTs and do not call Fastmail; the outbox worker (Plan 04) does both.
 *
 * Security (threat model T-02b-01 / T-02b-02, T-03-06..T-03-11):
 *   - start/end query params validated with zod ISO-date regex before any SQL.
 *   - Window hard-capped at 90 days (DoS guard).
 *   - Write payloads validated with zod (title 255, location/description 2000 — T-03-08).
 *   - All write endpoints assert calendar ownership (userId = currentUser.id OR isShared) — T-03-06.
 *   - sync-status scoped strictly to currentUser.id — T-03-07.
 *   - writable-calendars query restricts to userId = currentUser.id OR isShared=1 — T-03-11.
 *   - Drizzle parameterized queries prevent SQL injection — T-03-09.
 *   - etag read server-side from calendarEvents; client never supplies it — T-03-10.
 *
 * Mounted under /api/* in index.ts — behind oidcAuthMiddleware.
 */

import { randomUUID } from 'node:crypto';
import { Hono } from 'hono';
import type { Context } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { and, or, eq, desc } from 'drizzle-orm';
import { sql } from 'drizzle-orm';
import { db } from '../db/client.js';
import { calendarEvents, calendars, users, calendarOutbox } from '../db/schema.js';
import { expandOccurrences } from '../broker/expand.js';
import { extractRruleString } from '../broker/vevent.js';
import { getAuth } from '../auth/middleware.js';
import { upsertUser, deriveDisplayName } from '../auth/user.js';
import { signalOutboxDrain } from '../lib/outboxTrigger.js';
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js';

export const eventsRouter = new Hono();

/** Shared-family calendar rose color (D-06). */
const SHARED_FAMILY_COLOR = '#F25C7A';

/** Maximum allowed date-window span to prevent DoS (T-02b-02). */
const MAX_WINDOW_DAYS = 90;

// ---------------------------------------------------------------------------
// Auth helper — shared by all write endpoints
//
// Resolution order (D-10, CR-06):
//   1. Dev-bypass path: c.get('user') is set by devAuthBypass() middleware when
//      DEV_AUTH_BYPASS=true. Return its .id directly — no OIDC round-trip.
//   2. OIDC path: call getAuth(c). If null → unauthenticated, return null.
//      Otherwise extract iss/sub/email and call upsertUser — which writes the
//      user row on first login and returns the existing row on subsequent calls.
//      Identity is keyed on oidc_iss + oidc_sub (D-10), never email.
//   3. Callers emit 401 when resolveUserId returns null.
//
// IN-04: typed as Hono's Context instead of `any`. c.get('user') resolves through the
// ContextVariableMap augmentation in auth/devBypass.ts (typed as the DEV_USER shape),
// and getAuth(c) accepts a Context — so no `any` / eslint-disable is needed here.
async function resolveUserId(c: Context): Promise<number | null> {
  const devUser = c.get('user') as { id: number } | undefined;
  if (devUser) return devUser.id;

  const auth = await getAuth(c);
  if (!auth) return null;

  const iss = (auth.iss as string | undefined) ?? '';
  const sub = auth.sub ?? '';

  // Derive displayName via the shared helper (name → preferred_username → email
  // → sub fallback) so the write-path upsert agrees with me.ts and never
  // overwrites a correctly-derived name with a worse one.
  const displayName = deriveDisplayName(auth);

  const user = await upsertUser(iss, sub, displayName);
  return user?.id ?? null;
}

// ---------------------------------------------------------------------------
// Zod schemas
// ---------------------------------------------------------------------------

/** Windowed GET query params. */
const eventsQuerySchema = z.object({
  start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});

/**
 * Shared event field validation (V5 — bounded lengths, T-03-08).
 *
 * Field names match the PWA CreateEventPayload (apps/pwa/src/api/client.ts:119-128)
 * exactly — title/start/end — so no rename map is needed end-to-end (CR-01).
 * The stored payload JSON uses these same names; the outbox worker (plan 03-10)
 * reads title/start/end when building the VEVENT.
 */
const eventFieldsSchema = z.object({
  title: z.string().min(1).max(255),
  allDay: z.boolean(),
  start: z.string().min(1).max(64), // ISO string or DATE (YYYY-MM-DD for allDay)
  end: z.string().min(1).max(64),
  location: z.string().max(2000).optional(),
  description: z.string().max(2000).optional(),
  recurrence: z.enum(['none', 'daily', 'weekly', 'monthly', 'yearly']).optional(),
  calendarUrl: z.string().url().max(1024).optional(),
  // D-06: recurrence bounding (RRULE UNTIL / COUNT)
  // CR-01: validate the exact 'YYYY-MM-DD' shape (reusing eventsQuerySchema's regex) so a
  // ≤10-char non-date string cannot survive .replace(/-/g,'') and inject extra ';'-delimited
  // RRULE parts when spliced into the UNTIL template (outboxWorker.assembleRruleString).
  // int().min(1) prevents zero/negative counts.
  recurrenceUntil: z
    .string()
    .regex(/^\d{4}-\d{2}-\d{2}$/)
    .optional(), // 'YYYY-MM-DD' → RRULE UNTIL
  recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT
});

/** sync-status query params. */
const syncStatusQuerySchema = z.object({
  uid: z.string().min(1).max(512),
});

// ---------------------------------------------------------------------------
// GET /api/events?start=YYYY-MM-DD&end=YYYY-MM-DD
//
// Returns a flat array of concrete occurrences windowed to [start, end).
// Joins calendarEvents → calendars → users to resolve color and ownership.
// Expands recurring masters (hasRrule=true) via expandOccurrences() so RRULE,
// EXDATE, and DST are all handled server-side (D-09).
//
// Response shape: { occurrences: CalendarOccurrence[] }
// ---------------------------------------------------------------------------
eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
  // Resolve the current user first — only return events for owned + shared calendars (T-03-06).
  const currentUserId = await resolveUserId(c);
  if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);

  const { start, end } = c.req.valid('query');

  // --- Window span guard (T-02b-02) ---
  const windowStartDate = new Date(start + 'T00:00:00Z');
  const windowEndDate = new Date(end + 'T00:00:00Z');
  const spanDays = (windowEndDate.getTime() - windowStartDate.getTime()) / (1000 * 60 * 60 * 24);
  if (spanDays > MAX_WINDOW_DAYS || spanDays <= 0) {
    return c.json({ error: 'Date window must be between 1 and 90 days' }, 400);
  }

  try {
    // --- SQL pre-filter strategy (RESEARCH.md §Open Questions 3 / Pitfall 5) ---
    //
    // The WHERE clause must include THREE categories of events:
    //   1. Non-recurring timed events with dtstartUtc in [windowStart, windowEnd)
    //   2. Non-recurring all-day events with dtstartDate in [start, end) (DATE comparison)
    //   3. Recurring masters (hasRrule=true) with dtstartUtc < windowEnd
    //      (a weekly meeting created 3 years ago can still have occurrences in the window)
    //
    // expandOccurrences() does the precise window-boundary check for all rows returned.
    //
    // Using raw SQL for the complex WHERE to keep the query readable and unambiguous.
    // Drizzle's `sql` tag parameterizes all values — no string interpolation.
    const rows = await db
      .select({
        rawVevent: calendarEvents.rawVevent,
        calendarId: calendars.id,
        calendarName: calendars.displayName,
        isShared: calendars.isShared,
        userId: users.id,
        userColor: users.color,
        ownerName: users.displayName,
      })
      .from(calendarEvents)
      .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
      .innerJoin(users, eq(calendars.userId, users.id))
      .where(
        and(
          // Ownership predicate (BUG 3 fix): restrict to calendars owned by the current user
          // OR shared-family calendars (isShared=true). Mirrors the /writable-calendars idiom
          // (~line 509) so both endpoints agree on the authoritative writable set (D-03).
          or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)),
          // Date-window pre-filter (RESEARCH.md §Open Questions 3 / Pitfall 5):
          or(
            // Recurring masters: may have occurrences inside the window even if dtstartUtc is old.
            // Two sub-cases:
            //   (a) Timed recurring masters: dtstartUtc < windowEnd
            //   (b) All-day recurring masters: dtstartUtc is NULL (DATE-only), use dtstartDate < end
            // A NULL dtstartUtc causes the timed comparison to be NULL/false, so (b) carries it.
            and(
              sql`${calendarEvents.hasRrule} = 1`,
              or(
                sql`${calendarEvents.dtstartUtc} < ${windowEndDate}`,
                sql`${calendarEvents.dtstartDate} < ${end}`,
              ),
            ),
            // Non-recurring timed events: dtstartUtc falls in [windowStart, windowEnd)
            and(
              sql`${calendarEvents.hasRrule} = 0`,
              sql`${calendarEvents.dtstartUtc} IS NOT NULL`,
              sql`${calendarEvents.dtstartUtc} >= ${windowStartDate}`,
              sql`${calendarEvents.dtstartUtc} < ${windowEndDate}`,
            ),
            // Non-recurring all-day events: dtstartDate falls in [start, end) — DATE comparison.
            // WR-01: gate on hasRrule = 0 so a recurring all-day master whose dtstartDate
            // lands in the window is NOT also matched here (it is already carried by the
            // recurring branch above). Without this gate the master is expanded twice and
            // every occurrence is duplicated on the wire.
            and(
              sql`${calendarEvents.hasRrule} = 0`,
              sql`${calendarEvents.dtstartDate} IS NOT NULL`,
              sql`${calendarEvents.dtstartDate} >= ${start}`,
              sql`${calendarEvents.dtstartDate} < ${end}`,
            ),
          ),
        ),
      );

    // --- Expand each row into concrete occurrences ---
    const allOccurrences = rows.flatMap((row) => {
      const color = row.isShared ? SHARED_FAMILY_COLOR : row.userColor;
      return expandOccurrences(
        row.rawVevent,
        windowStartDate,
        windowEndDate,
        row.calendarId,
        row.calendarName ?? '',
        row.userId,
        row.ownerName ?? null,
        color,
        row.isShared,
      );
    });

    return c.json({ occurrences: allOccurrences });
  } catch (err) {
    console.error('[events] DB query or expansion failed:', err);
    return c.json({ error: 'Service unavailable' }, 503);
  }
});

// ---------------------------------------------------------------------------
// POST /api/events/create
//
// Validates input, resolves the target calendar (D-03 ownership check), and
// enqueues a `pending` outbox row. Returns 202 immediately (D-05, optimistic-accept).
// Does NOT build a VEVENT and does NOT call Fastmail — that is the worker's job (D-12).
// ---------------------------------------------------------------------------
eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) => {
  const currentUserId = await resolveUserId(c);
  if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);

  const payload = c.req.valid('json');

  try {
    // --- Resolve target calendar (D-03 / T-03-06) ---
    // If calendarUrl given: assert the calendar is owned by the current user OR is shared.
    // If not given: use the first personal calendar (D-01 last-used is a frontend concern).
    let targetCalendarUrl: string;

    if (payload.calendarUrl) {
      // Look up the calendar — it must be owned by the current user or be shared.
      const [calRow] = await db
        .select({ url: calendars.url, userId: calendars.userId, isShared: calendars.isShared })
        .from(calendars)
        .where(
          and(
            eq(calendars.url, payload.calendarUrl),
            or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)),
          ),
        );

      if (!calRow) {
        return c.json({ error: 'Calendar not found or access denied' }, 403);
      }
      targetCalendarUrl = calRow.url;
    } else {
      // Default to the member's first personal calendar (D-01).
      // WR-02: add a deterministic ORDER BY + LIMIT. Without them, a member with
      // multiple personal calendars gets an arbitrary, query-to-query-unstable "first"
      // row. Ordering by id (insertion order) gives a stable default; limit(1) avoids
      // fetching the whole set just to take [0].
      const [calRow] = await db
        .select({ url: calendars.url })
        .from(calendars)
        .where(eq(calendars.userId, currentUserId))
        .orderBy(calendars.id)
        .limit(1);

      if (!calRow) {
        return c.json({ error: 'No writable calendar found for user' }, 422);
      }
      targetCalendarUrl = calRow.url;
    }

    // Generate a UID for the new event (Node.js 22 built-in)
    const uid = `${randomUUID()}@familysync`;

    // Enqueue the outbox row (pending) — the worker builds the VEVENT and calls Fastmail.
    await db.insert(calendarOutbox).values({
      userId: currentUserId,
      operation: 'create',
      status: 'pending',
      uid,
      calendarUrl: targetCalendarUrl,
      payload: JSON.stringify(payload),
    });

    // must stay AFTER the enqueue commit — worker selects committed pending rows only
    signalOutboxDrain();
    return c.json({ uid }, 202);
  } catch (err) {
    console.error('[events/create] DB operation failed:', err);
    return c.json({ error: 'Service unavailable' }, 503);
  }
});

// ---------------------------------------------------------------------------
// PATCH /api/events/:uid/edit
//
// Looks up the cached event by uid, asserts ownership (D-03), and enqueues an
// outbox row. If the target calendarUrl differs from the current calendar (calendar
// move, D-04), TWO rows are inserted in a single transaction (delete+create pair).
// Returns 202 immediately (D-05). Does NOT call Fastmail (D-12).
// ---------------------------------------------------------------------------
eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c) => {
  const currentUserId = await resolveUserId(c);
  if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);

  const uid = c.req.param('uid');
  const payload = c.req.valid('json');

  try {
    // --- Look up the event and verify ownership ---
    // Join calendarEvents → calendars so we can read calendars.url and calendars.userId
    // in the same query. Without the join, referencing calendars.* produces invalid SQL
    // (Drizzle throws at toSQL() time) → 503. Mirrors the GET / join idiom at line 153.
    //
    // CR-01: calendar_events is keyed (calendarId, uid), NOT uid alone (schema.ts:121).
    // With a shared Fastmail account (D-16) the SAME uid is cached once per member's
    // calendar, so a uid-only lookup returns 2+ rows and an arbitrary [0] (typically the
    // OTHER member's row). Scope the lookup to the acting member's writable set
    // (own calendars OR shared) so the etag/objectUrl/ownership we act on belong to the
    // right calendar. Order so the current user's OWN row ranks before a shared/other row
    // — when both a personal and a shared copy of the uid exist, the acting member's copy
    // is authoritative for the write target. limit(1) makes the pick deterministic.
    const [eventRow] = await db
      .select({
        uid: calendarEvents.uid,
        etag: calendarEvents.etag,
        objectUrl: calendarEvents.objectUrl,
        calendarId: calendarEvents.calendarId,
        calendarUrl: calendars.url,
        userId: calendars.userId,
        rawVevent: calendarEvents.rawVevent,
      })
      .from(calendarEvents)
      .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
      .where(
        and(
          eq(calendarEvents.uid, uid),
          or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)),
        ),
      )
      .orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`)
      .limit(1);

    if (!eventRow) {
      return c.json({ error: 'Event not found' }, 404);
    }

    // Ownership check: must be the calendar owner or shared (T-03-06).
    // The WHERE above already restricts to the writable set, so any returned row is
    // either the user's own calendar or a shared one — re-verify defensively.
    if (eventRow.userId !== currentUserId) {
      // Check if the calendar is shared (shared calendars are writable by all household members)
      const [calRow] = await db
        .select({ isShared: calendars.isShared })
        .from(calendars)
        .where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)));

      if (!calRow) {
        return c.json({ error: 'Access denied' }, 403);
      }
    }

    const newCalendarUrl = payload.calendarUrl ?? eventRow.calendarUrl;
    const isCalendarMove = newCalendarUrl !== eventRow.calendarUrl;

    if (isCalendarMove) {
      // D-04: edit-as-move — insert delete+create pair in one transaction (D-04 / Pitfall 5)
      const newUid = `${randomUUID()}@familysync`;
      const groupId = randomUUID();

      // CR-01: carry the existing RRULE through the move. The edit payload omits
      // `recurrence` (the occurrence contract does not expose it, D-03), and the
      // create lands under a brand-new uid that the worker can never look up the
      // original RRULE from. Unlike the same-calendar `update` branch — which reads
      // rawVevent and re-applies the stored RRULE — the create branch has no source
      // for it. Extract the RRULE from the source event here and stash it on the
      // create payload so the worker re-applies it, preventing a recurring series
      // from silently collapsing into a single occurrence on a calendar move.
      // Only stash when the edit did NOT carry an explicit recurrence: an explicit
      // value (including 'none') is a deliberate user change and must win.
      const preservedRrule =
        payload.recurrence === undefined ? extractRruleString(eventRow.rawVevent ?? '') : undefined;
      const createPayload =
        preservedRrule !== undefined ? { ...payload, _preservedRrule: preservedRrule } : payload;

      await db.transaction(async (tx) => {
        // Delete from old calendar
        await tx.insert(calendarOutbox).values({
          userId: currentUserId,
          operation: 'delete',
          status: 'pending',
          uid,
          calendarUrl: eventRow.calendarUrl ?? '',
          calendarObjectUrl: eventRow.objectUrl ?? undefined,
          etag: eventRow.etag ?? undefined,
          groupId,
        });
        // Create on new calendar
        await tx.insert(calendarOutbox).values({
          userId: currentUserId,
          operation: 'create',
          status: 'pending',
          uid: newUid,
          calendarUrl: newCalendarUrl,
          payload: JSON.stringify(createPayload),
          groupId,
        });
      });

      // must stay AFTER the enqueue commit — worker selects committed pending rows only
      signalOutboxDrain();
      return c.json({ uid: newUid }, 202);
    }

    // Same calendar — simple update row
    await db.insert(calendarOutbox).values({
      userId: currentUserId,
      operation: 'update',
      status: 'pending',
      uid,
      calendarUrl: eventRow.calendarUrl ?? '',
      calendarObjectUrl: eventRow.objectUrl ?? undefined,
      etag: eventRow.etag ?? undefined,
      payload: JSON.stringify(payload),
    });

    // must stay AFTER the enqueue commit — worker selects committed pending rows only
    signalOutboxDrain();
    return c.json({ uid }, 202);
  } catch (err) {
    console.error('[events/edit] DB operation failed:', err);
    return c.json({ error: 'Service unavailable' }, 503);
  }
});

// ---------------------------------------------------------------------------
// DELETE /api/events/:uid
//
// Asserts ownership, enqueues a delete outbox row with the cached etag (D-08 / T-03-10).
// Returns 202 immediately (D-05). Does NOT call Fastmail (D-12).
// ---------------------------------------------------------------------------
eventsRouter.delete('/:uid', async (c) => {
  const currentUserId = await resolveUserId(c);
  if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);

  const uid = c.req.param('uid');

  try {
    // Look up the event — join calendars so calendars.url / calendars.userId are accessible.
    // Same innerJoin idiom as the GET / handler (line 153). Without this join, Drizzle
    // throws at toSQL() time → 503.
    //
    // CR-01: scope to the acting member's writable set and pick deterministically — a
    // shared Fastmail account (D-16) caches the same uid once per member's calendar, so a
    // uid-only lookup would otherwise act on an arbitrary member's etag/objectUrl.
    const [eventRow] = await db
      .select({
        uid: calendarEvents.uid,
        etag: calendarEvents.etag,
        objectUrl: calendarEvents.objectUrl,
        calendarId: calendarEvents.calendarId,
        calendarUrl: calendars.url,
        userId: calendars.userId,
      })
      .from(calendarEvents)
      .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
      .where(
        and(
          eq(calendarEvents.uid, uid),
          or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)),
        ),
      )
      .orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`)
      .limit(1);

    if (!eventRow) {
      return c.json({ error: 'Event not found' }, 404);
    }

    // Ownership check (T-03-06)
    if (eventRow.userId !== currentUserId) {
      const [calRow] = await db
        .select({ isShared: calendars.isShared })
        .from(calendars)
        .where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)));

      if (!calRow) {
        return c.json({ error: 'Access denied' }, 403);
      }
    }

    // Enqueue delete — etag from calendarEvents (T-03-10, never from client)
    await db.insert(calendarOutbox).values({
      userId: currentUserId,
      operation: 'delete',
      status: 'pending',
      uid,
      calendarUrl: eventRow.calendarUrl ?? '',
      calendarObjectUrl: eventRow.objectUrl ?? undefined,
      etag: eventRow.etag ?? undefined,
    });

    // must stay AFTER the enqueue commit — worker selects committed pending rows only
    signalOutboxDrain();
    return c.json({ uid }, 202);
  } catch (err) {
    console.error('[events/delete] DB operation failed:', err);
    return c.json({ error: 'Service unavailable' }, 503);
  }
});

// ---------------------------------------------------------------------------
// GET /api/events/sync-status?uid=<uid>
//
// Returns the outbox status for the given UID, scoped to the current member (T-03-07).
// Polled by TanStack Query on the client to power the optimistic-accept toast (D-09).
// Returns { uid, status: 'done' } when no outbox row exists (nothing pending = settled).
// ---------------------------------------------------------------------------
eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), async (c) => {
  const currentUserId = await resolveUserId(c);
  if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);

  const { uid } = c.req.valid('query');

  try {
    // Scope strictly to current member's rows (T-03-07 — never leak another member's outbox).
    //
    // WR-04: rapid successive same-uid edits enqueue multiple outbox rows. A plain
    // "newest row" pick (ORDER BY createdAt DESC LIMIT 1) reports only the latest row's
    // status — so if the newest succeeds but an older row dead-lettered, the user sees
    // "Saved" while a queued write silently failed. Rank an unsettled/failed row ABOVE a
    // done row: a row in pending/failed/dead for the uid outranks a done row, and only
    // among same-priority rows do we fall back to newest-first. This surfaces a failure
    // for ANY row of the uid instead of masking it behind a later success.
    const rows = await db
      .select({
        uid: calendarOutbox.uid,
        status: calendarOutbox.status,
        lastError: calendarOutbox.lastError,
      })
      .from(calendarOutbox)
      .where(and(eq(calendarOutbox.userId, currentUserId), eq(calendarOutbox.uid, uid)))
      // status priority: failed/dead first, then pending, then done.
      .orderBy(
        sql`case ${calendarOutbox.status} when 'failed' then 0 when 'dead' then 0 when 'pending' then 1 else 2 end`,
        desc(calendarOutbox.createdAt),
      )
      .limit(1);

    if (!rows.length) {
      // No outbox row → nothing pending = settled as done
      return c.json({ uid, status: 'done' });
    }

    const row = rows[0];
    return c.json({
      uid: row.uid,
      status: row.status,
      ...(row.lastError != null ? { error: row.lastError } : {}),
    });
  } catch (err) {
    console.error('[events/sync-status] DB query failed:', err);
    return c.json({ error: 'Service unavailable' }, 503);
  }
});

// ---------------------------------------------------------------------------
// GET /api/events/writable-calendars
//
// Returns the D-03 writable set for the current member:
//   - The member's own personal calendar(s) (userId = currentUser.id)
//   - The shared Family calendar (isShared = true)
//
// The other member's personal calendar (different userId, isShared=false) MUST NOT
// appear — it is a read-only overlay only (D-03). This endpoint is AUTHORITATIVE:
// the client (Plan 05) consumes it verbatim and never derives the writable set itself (T-03-11).
//
// Response: { calendars: [{ url, displayName, color, isShared }] }
// ---------------------------------------------------------------------------
eventsRouter.get('/writable-calendars', async (c) => {
  const currentUserId = await resolveUserId(c);
  if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);

  try {
    // D-03 writable set: own personal calendars + shared Family calendar.
    // Another member's personal (userId ≠ currentUserId AND isShared=false) is excluded (T-03-11).
    const rows = await db
      .select({
        url: calendars.url,
        displayName: calendars.displayName,
        color: calendars.color,
        isShared: calendars.isShared,
      })
      .from(calendars)
      .where(or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)));

    return c.json({
      calendars: rows.map((row) => ({
        url: row.url,
        displayName: row.displayName ?? '',
        color: row.color ?? '',
        isShared: row.isShared,
      })),
    });
  } catch (err) {
    console.error('[events/writable-calendars] DB query failed:', err);
    return c.json({ error: 'Service unavailable' }, 503);
  }
});
