/**
 * Typed API client for the FamilySync backend.
 *
 * credentials: 'include' is required so the OIDC session cookie is sent with
 * every cross-origin request (Vite dev proxy routes to :3000; production is
 * same-origin via Pangolin).
 *
 * Auth note: the OIDC guard's 302 to Authelia is CORS-blocked for fetch/XHR —
 * browsers do not follow cross-origin redirects from XHR to an external IdP.
 * Re-authentication therefore requires a TOP-LEVEL navigation to /api/login
 * (see apps/pwa/src/lib/loginRedirect.ts). fetchMe and other fetch calls here
 * are pure data fetches; they throw on non-ok responses and leave the redirect
 * decision to the caller (CalendarShell via maybeRedirectToLogin).
 *
 * D-11 (Plan 06-05): All fetch wrappers now use redirect:'manual' and throw a
 * typed SessionExpiredError on 401 / opaqueredirect. The global QueryCache /
 * MutationCache error handler in main.tsx catches this class and arms the
 * session-expiry interstitial. A generic Error is still thrown for other non-ok
 * statuses so error UI can distinguish auth failures from transient errors.
 */

// ── Auth error ────────────────────────────────────────────────────────────

/**
 * Typed error thrown by all fetch wrappers when the server returns a 401 or
 * an opaque redirect (the OIDC guard's 302 to Authelia surfaced as
 * res.type==='opaqueredirect' via redirect:'manual').
 *
 * Object.setPrototypeOf is required so instanceof checks work correctly after
 * TypeScript compilation to ES5 / CommonJS, where extending built-in Error
 * breaks the prototype chain.
 */
export class SessionExpiredError extends Error {
  readonly name = 'SessionExpiredError';
  constructor() {
    super('Session expired — re-authentication required');
    Object.setPrototypeOf(this, SessionExpiredError.prototype);
  }
}

/**
 * Unified auth-response checker applied to every fetch in this module.
 *
 * - 401 or opaqueredirect  → throws SessionExpiredError (caught by global QueryCache handler)
 * - other non-ok           → throws a generic Error (distinguishable from auth failures)
 * - ok                     → no-op (caller proceeds to parse body)
 *
 * @param res   The fetch Response object
 * @param label A short human-readable description for the generic error message
 */
function handleAuthResponse(res: Response, label: string): void {
  if (res.type === 'opaqueredirect' || res.status === 401) {
    throw new SessionExpiredError();
  }
  if (!res.ok) {
    throw new Error(`${label} failed: ${res.status}`);
  }
}

// ── /api/me ────────────────────────────────────────────────────────────────

export interface MeUser {
  id: number;
  displayName: string | null;
  color: string;
}

export interface MeResponse {
  user: MeUser;
}

export async function fetchMe(): Promise<MeResponse> {
  // redirect: 'manual' is critical. The OIDC guard answers an unauthenticated
  // request with a 302 to Authelia (cross-origin). With the default
  // redirect: 'follow', the browser follows that credentialed cross-origin
  // redirect and the fetch HANGS (never resolves, never rejects) — leaving the
  // query stuck "loading" so the SPA spins forever and the auth-redirect below
  // never fires. With 'manual', the 302 comes back as an opaqueredirect
  // (res.type === 'opaqueredirect', res.status === 0) that we detect immediately.
  const res = await fetch('/api/me', {
    credentials: 'include',
    redirect: 'manual',
  });

  handleAuthResponse(res, 'GET /api/me');

  return res.json() as Promise<MeResponse>;
}

// ── /api/events (windowed — Phase 2) ─────────────────────────────────────

/**
 * A single concrete occurrence of a calendar event.
 *
 * Mirrors the CalendarOccurrence shape produced by the backend's
 * expandOccurrences() helper (apps/api/src/broker/expand.ts).
 *
 * calendarId and ownerUserId are both present:
 *   calendarId    — DB calendar-row id (do NOT use for Schedule-X routing)
 *   ownerUserId   — DB user id (use for Schedule-X calendarId routing)
 *   isShared      — true when this event belongs to the shared-family calendar
 *
 * hydrateEvents() uses isShared/ownerUserId — never String(calendarId) —
 * to build the Schedule-X calendarId that keys into buildCalendarConfig().
 */
export interface CalendarOccurrence {
  id: string; // `ev-<sanitized-uid>-<epochMs>` — stable identity (server: expand.ts makeOccurrenceId)
  uid: string;
  calendarId: number; // DB calendar-row id — do NOT use for SX calendarId routing
  calendarName: string;
  ownerUserId: number; // DB user id — the correct Schedule-X routing key
  /**
   * Display name of the calendar owner (users.displayName from the API).
   * Null when the user has not configured a display name.
   * Popover renders: isShared ? 'Family' : (ownerName ?? calendarName)
   */
  ownerName: string | null;
  color: string; // hex from users.color or shared-family constant
  isShared: boolean; // true → 'shared' slot; false → String(ownerUserId) slot
  title: string;
  start: string; // 'YYYY-MM-DD' for allDay:true; ISO 8601 with IANA tz for timed
  end: string;
  allDay: boolean;
  location: string | null;
  description: string | null;
  /**
   * True when this occurrence belongs to a recurring series (has an RRULE).
   * Mirrors CalendarOccurrence.hasRrule in apps/api/src/broker/expand.ts — must
   * stay in sync with the server type (Pitfall 4 — atomic mirror, Plan 06-05).
   */
  hasRrule: boolean;
}

export interface OccurrencesResponse {
  occurrences: CalendarOccurrence[];
}

/**
 * Fetch windowed calendar occurrences.
 *
 * The ?start=&end= window is mandatory — an unwindowed call would expand 500+
 * cached events with all their recurring occurrences (RESEARCH.md Pitfall 5).
 *
 * @param start ISO date string 'YYYY-MM-DD' — window start (inclusive)
 * @param end   ISO date string 'YYYY-MM-DD' — window end (exclusive)
 */
export async function fetchEvents(start: string, end: string): Promise<OccurrencesResponse> {
  const res = await fetch(`/api/events?start=${start}&end=${end}`, {
    credentials: 'include',
    redirect: 'manual',
  });

  handleAuthResponse(res, 'GET /api/events');

  return res.json() as Promise<OccurrencesResponse>;
}

// Phase 1 legacy types (CalendarEvent, EventsResponse, fetchEventsLegacy) removed in Plan 05
// when the Phase 1 broker-proof component was retired.

// ── /api/events (write — Plan 03-05) ─────────────────────────────────────────

/**
 * Recurrence presets supported by the EventForm.
 * Maps 1:1 to the RRULE frequency values the API accepts.
 */
export type RecurrencePreset = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly';

/**
 * Payload for creating or updating a calendar event.
 * Mirrors the Zod schema on POST /api/events/create and PATCH /api/events/:uid/edit.
 */
export interface CreateEventPayload {
  title: string;
  allDay: boolean;
  start: string; // 'YYYY-MM-DD' for allDay; ISO 8601 for timed
  end: string; // same format as start
  // WR-01: optional. CREATE always sends it; EDIT omits it so the API/worker preserve
  // the event's existing RRULE (the occurrence contract does not expose recurrence, so
  // the form cannot echo it back without silently resetting it to 'none').
  recurrence?: RecurrencePreset;
  /**
   * RRULE UNTIL date (D-06). ISO 'YYYY-MM-DD' string. Only sent when recurrence !== 'none'
   * and the user selects the "On date" bound. Mutually exclusive with recurrenceCount.
   * The outbox worker converts this to RRULE UNTIL format (DATE for all-day, DATETIME UTC for timed).
   */
  recurrenceUntil?: string;
  /**
   * RRULE COUNT (D-06). Integer >= 1. Only sent when recurrence !== 'none' and the user
   * selects the "After N times" bound. Mutually exclusive with recurrenceUntil.
   */
  recurrenceCount?: number;
  location?: string;
  description?: string;
  calendarUrl?: string; // omit to use the member's default writable calendar (D-01)
}

/** Response from POST /api/events/create and PATCH /api/events/:uid/edit */
export interface CreateEventResponse {
  uid: string;
}

/**
 * A writable calendar returned by GET /api/events/writable-calendars.
 * The server is the authoritative source of the writable set (D-03).
 * The client never derives writability — it reads this endpoint verbatim.
 */
export interface WritableCalendar {
  url: string;
  displayName: string;
  color: string;
  isShared: boolean;
}

/**
 * Create a new calendar event.
 *
 * POSTs to /api/events/create and returns immediately with 202 + uid.
 * The server enqueues the write to CalDAV asynchronously (D-05/D-12).
 */
export async function createEvent(payload: CreateEventPayload): Promise<CreateEventResponse> {
  const res = await fetch('/api/events/create', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include',
    redirect: 'manual',
    body: JSON.stringify(payload),
  });

  handleAuthResponse(res, 'POST /api/events/create');

  return res.json() as Promise<CreateEventResponse>;
}

/**
 * Update an existing calendar event.
 *
 * PATCHes /api/events/:uid/edit with the updated payload.
 * Returns 202 + uid; the write is enqueued asynchronously (D-05).
 */
export async function updateEvent(
  uid: string,
  payload: CreateEventPayload,
): Promise<CreateEventResponse> {
  const res = await fetch(`/api/events/${uid}/edit`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include',
    redirect: 'manual',
    body: JSON.stringify(payload),
  });

  handleAuthResponse(res, `PATCH /api/events/${uid}/edit`);

  return res.json() as Promise<CreateEventResponse>;
}

/**
 * Delete a calendar event.
 *
 * DELETEs /api/events/:uid; the API enqueues the delete to the outbox (D-05).
 * Returns 202 Accepted (async). Throws on any non-ok response.
 */
export async function deleteEvent(uid: string): Promise<void> {
  const res = await fetch(`/api/events/${uid}`, {
    method: 'DELETE',
    credentials: 'include',
    redirect: 'manual',
  });

  handleAuthResponse(res, `DELETE /api/events/${uid}`);
}

// ── /api/events/sync-status (Plan 03-06) ─────────────────────────────────────

/**
 * Status values for an outbox write operation.
 * Mirrors the calendarOutbox.status enum on the server.
 */
export type SyncStatusValue = 'pending' | 'done' | 'failed' | 'dead';

/**
 * Response from GET /api/events/sync-status?uid=
 * The server returns the current outbox status for the given UID + member.
 */
export interface SyncStatus {
  uid: string;
  status: SyncStatusValue;
  /** Present on failed status — may contain '412' prefix for conflict detection. */
  error?: string;
}

/**
 * Poll the sync-status for a specific event UID.
 *
 * Used by SyncStateToast to track pending → done | failed | dead transitions.
 * The server filters by the current member so no cross-member leakage (T-03-19).
 */
export async function fetchSyncStatus(uid: string): Promise<SyncStatus> {
  const res = await fetch(`/api/events/sync-status?uid=${uid}`, {
    credentials: 'include',
    redirect: 'manual',
  });

  handleAuthResponse(res, 'GET /api/events/sync-status');

  return res.json() as Promise<SyncStatus>;
}

/**
 * Fetch the authoritative list of writable calendars for the current member.
 *
 * The server owns the D-03 writable set (WHERE userId=currentUser.id OR isShared=1).
 * The client NEVER derives writability — it reads this endpoint verbatim.
 * Drives the calendar picker visibility (D-02: hidden when only 1 writable calendar).
 */
export async function fetchWritableCalendars(): Promise<WritableCalendar[]> {
  const res = await fetch('/api/events/writable-calendars', {
    credentials: 'include',
    redirect: 'manual',
  });

  handleAuthResponse(res, 'GET /api/events/writable-calendars');

  const body = (await res.json()) as { calendars: WritableCalendar[] };
  return body.calendars;
}
