/**
 * EventForm — create/edit event modal overlay (Plan 03-05).
 *
 * Rendering modes:
 *   - create: "New Event" form opened via FAB/toolbar (eventFormMode === 'create')
 *   - edit: pre-populated from TanStack Query cache by eventFormUid
 *
 * Responsive:
 *   - Phone (≤767px): bottom sheet (full-screen overlay)
 *   - Tablet/desktop (≥768px): centered dialog (max-width 480px)
 *
 * Security: T-03-15 — all field values rendered as plain-text JSX children.
 * NEVER use dangerouslySetInnerHTML for any event field.
 *
 * Calendar picker: hidden when member has exactly 1 writable calendar (D-02).
 * Populated from GET /api/events/writable-calendars (D-03 server-authoritative).
 *
 * Writes: 202 optimistic-accept (D-05/D-12). Enqueued to outbox by the API.
 * On success: form closes. SyncStateToast feedback lands in Plan 03-06.
 *
 * Accessibility:
 *   - role="dialog", aria-modal="true", aria-label="New Event"/"Edit Event"
 *   - Focus moves to Title input on open
 *   - Focus trap: Tab/Shift+Tab cycle focus within dialog; never reaches background (WR-07)
 *   - Escape / backdrop click closes form
 *   - All-day toggle: role="switch", aria-checked
 *   - Recurrence: <select> with labeled options
 *   - 44px minimum touch targets on all interactive elements
 */

import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { X, Loader2 } from 'lucide-react';
import { useCalendarStore, todayIso } from '../store/calendarStore.js';
import {
  createEvent,
  updateEvent,
  fetchWritableCalendars,
  type CreateEventPayload,
  type RecurrencePreset,
} from '../api/client.js';
import type { CalendarOccurrence } from '../api/client.js';
import {
  serializeEventDateTime,
  computeNewTimedEnd,
  computeNewAllDayEnd,
} from '../lib/eventDateTime.js';
import { SeriesEditPrompt } from './SeriesEditPrompt.js';
import { useFocusTrap } from '../hooks/useFocusTrap.js';

// ── Constants ─────────────────────────────────────────────────────────────────

const LAST_CALENDAR_KEY = 'eventForm.lastCalendarUrl';

// Phase 11: reminder preset values (minutes) for each event type.
// Used for edit-mode classification and rendering.
const TIMED_REMINDER_PRESETS = new Set([5, 10, 15, 30, 60, 120, 1440, 2880]);
const ALLDAY_REMINDER_PRESETS = new Set([0, 1440, 2880, 10080]);

// IN-03: todayIso is now imported from calendarStore (single source of truth).
// getDefaultStartDate/getDefaultEndDate collapsed to todayIso() calls at use sites.

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

/**
 * Humanize an off-list reminder lead (minutes) for the synthetic picker option label.
 * Per UI-SPEC Copywriting Contract thresholds:
 *   < 60 min  → "N min before"
 *   ≥ 60 min  → "N hours before"
 */
function humanizeReminderLead(minutes: number): string {
  if (minutes < 60) return `${minutes} min before`;
  const hours = minutes / 60;
  return `${hours} hour${hours !== 1 ? 's' : ''} before`;
}

/**
 * Derive the initial reminder picker value from an occurrence's reminder fields.
 *
 * CR-01 (Plan 11-05): When reminderIsCustom is true, the event carries an absolute
 * DATE-TIME trigger or multiple VALARMs that cannot be reduced to a single lead.
 * Returning '__custom__' makes the existing preserve branch live: the form emits an
 * absent reminderLeadMinutes field, and the outboxWorker's D-08 preserve path keeps
 * the original VALARM intact (no silent data loss on edit).
 *
 * Precedence: custom → '__custom__'; null → '__none__'; else preset/off-list string.
 */
function deriveReminderValue(
  leadMinutes: number | null,
  isAllDay: boolean,
  isCustom = false,
): string {
  if (isCustom) return '__custom__';
  if (leadMinutes === null) return '__none__';
  const presets = isAllDay ? ALLDAY_REMINDER_PRESETS : TIMED_REMINDER_PRESETS;
  if (presets.has(leadMinutes)) return String(leadMinutes);
  // Off-list positive value — use the numeric string; a synthetic option will be rendered
  return String(leadMinutes);
}

/** Determine if we're on phone breakpoint. */
function isPhoneBreakpoint(): boolean {
  return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
}

/** Read last-used calendar URL from localStorage (D-01). */
function readLastCalendarUrl(): string | null {
  try {
    return localStorage.getItem(LAST_CALENDAR_KEY);
  } catch {
    return null;
  }
}

/** Persist last-used calendar URL (D-01). */
function writeLastCalendarUrl(url: string): void {
  try {
    localStorage.setItem(LAST_CALENDAR_KEY, url);
  } catch {
    // Ignore write failures
  }
}

/**
 * CR-03: convert an EXCLUSIVE all-day end date ('YYYY-MM-DD') to the INCLUSIVE
 * last day the form displays. The occurrence/expand contract keeps all-day ends
 * exclusive (matching the DTEND Fastmail stores, RFC-5545 §3.6.1), and
 * buildVeventString re-advances the inclusive form value by one day on write.
 * Without this subtraction, round-tripping an edit re-advances an already-exclusive
 * end, silently growing multi-day all-day events by one day per save. Parses by
 * UTC components so the roll-back is DST-safe (mirrors vevent.ts's roll-forward).
 */
function exclusiveEndToInclusiveDate(dateStr: string): string {
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
  if (!m) return dateStr;
  const [, y, mo, d] = m;
  const date = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d)));
  date.setUTCDate(date.getUTCDate() - 1);
  const yy = String(date.getUTCFullYear());
  const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
  const dd = String(date.getUTCDate()).padStart(2, '0');
  return `${yy}-${mm}-${dd}`;
}

/**
 * Parse an ISO date string (possibly with time + offset) into
 * { date: 'YYYY-MM-DD', time: 'HH:MM' }. Falls back to today/09:00 if malformed.
 *
 * WR-05 fix: date and time are derived from the SAME local-accessor family.
 * NEVER mix toISOString().slice(0,10) (UTC date) with getHours() (local time).
 * Both date and time use getFullYear/getMonth/getDate/getHours/getMinutes so
 * the pair describes the same wall-clock consistently in the viewer's zone.
 */
function parseDateTime(iso: string): { date: string; time: string; ok: boolean } {
  try {
    // Strip IANA bracket suffix e.g. '[America/Toronto]'
    const clean = iso.replace(/\[[^\]]*\]$/, '');
    // WR-03: test and return `clean`, not the raw `iso`. Testing `iso` would let a
    // date-only value carrying a bracket suffix skip the all-day branch and fall through
    // to new Date(clean); using `clean` matches the "strip IANA suffix" intent above.
    if (/^\d{4}-\d{2}-\d{2}$/.test(clean)) {
      // All-day date string — use as-is (no time component)
      return { date: clean, time: '09:00', ok: true };
    }
    // WR-08: require a well-formed timed shape (date + 'T' + HH:MM) before trusting
    // new Date()'s permissive parsing. Otherwise a truncated/garbled cached value like
    // '2026-06' parses as a VALID UTC instant in V8 and would be saved on edit without
    // tripping the IN-02 blank-field guard. A genuinely malformed timed value now fails
    // here and is reported ok:false instead of silently resolving to an unintended day.
    if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(clean)) {
      throw new Error('Malformed timed datetime');
    }
    const d = new Date(clean);
    if (isNaN(d.getTime())) throw new Error('Invalid date');
    // WR-05: use ONLY local accessors so date and time are in the same zone frame.
    // Do NOT use toISOString() here — it returns UTC, which can differ from local time.
    const year = String(d.getFullYear());
    const month = String(d.getMonth() + 1).padStart(2, '0');
    const day = String(d.getDate()).padStart(2, '0');
    const hours = String(d.getHours()).padStart(2, '0');
    const mins = String(d.getMinutes()).padStart(2, '0');
    return { date: `${year}-${month}-${day}`, time: `${hours}:${mins}`, ok: true };
  } catch {
    // IN-02: signal failure so the CREATE path can fall back to today/09:00 (a benign
    // default for a brand-new event) while the EDIT path leaves the field blank and
    // blocks submit — never silently rewriting a corrupt cached value to today/09:00.
    return { date: todayIso(), time: '09:00', ok: false };
  }
}

/**
 * IN-02: resolve the form's initial date/time for a pre-filled occurrence value.
 * In CREATE mode (or when there is no occurrence) a parse failure falls back to the
 * benign today/09:00 default. In EDIT mode a parse failure leaves the field BLANK so
 * the user sees the value did not load and submit is blocked (validate() treats a blank
 * start/end as invalid), rather than silently substituting today/09:00 and saving it.
 */
function initFormDateTime(
  iso: string | undefined,
  isEdit: boolean,
  fallbackTime: string,
): { date: string; time: string } {
  if (iso === undefined) return { date: todayIso(), time: fallbackTime };
  const parsed = parseDateTime(iso);
  if (parsed.ok) return { date: parsed.date, time: parsed.time };
  // Parse failed
  if (isEdit) return { date: '', time: '' };
  return { date: todayIso(), time: fallbackTime };
}

// ── Component ─────────────────────────────────────────────────────────────────

export function EventForm() {
  const { eventFormOpen, eventFormMode, eventFormUid, setEventForm } = useCalendarStore();
  const setLastSyncedUid = useCalendarStore((s) => s.setLastSyncedUid);
  const queryClient = useQueryClient();
  const titleRef = useRef<HTMLInputElement>(null);
  const dialogRef = useRef<HTMLDivElement>(null);

  // ── Resolve event for edit mode ─────────────────────────────────────────────

  const occurrence: CalendarOccurrence | null = (() => {
    if (eventFormMode !== 'edit' || !eventFormUid) return null;
    const allEntries = queryClient.getQueriesData<{ occurrences: CalendarOccurrence[] }>({
      queryKey: ['events'],
    });
    for (const [, data] of allEntries) {
      if (!data?.occurrences) continue;
      const found = data.occurrences.find((o) => o.uid === eventFormUid || o.id === eventFormUid);
      if (found) return found;
    }
    return null;
  })();

  // ── Writable calendars (D-03) ───────────────────────────────────────────────

  const { data: writableCalendars = [] } = useQuery({
    queryKey: ['writableCalendars'],
    queryFn: fetchWritableCalendars,
    staleTime: 5 * 60 * 1000,
    enabled: eventFormOpen,
  });

  // ── Form state ──────────────────────────────────────────────────────────────

  const isEditMode = eventFormMode === 'edit' && !!eventFormUid;
  const initStart = initFormDateTime(occurrence?.start, isEditMode, '09:00');
  const initEnd = initFormDateTime(occurrence?.end, isEditMode, '10:00');
  // CR-03: occurrence.end for an all-day event is the EXCLUSIVE DTEND; the form's
  // end-date input is the INCLUSIVE last day. Convert when pre-filling so a re-edit
  // does not re-advance the span (buildVeventString rolls forward again on write).
  // IN-02: skip the roll-back when initEnd.date is blank (parse failure in edit mode).
  const initEndDate =
    occurrence?.allDay && initEnd.date ? exclusiveEndToInclusiveDate(initEnd.date) : initEnd.date;

  const [title, setTitle] = useState(occurrence?.title ?? '');
  const [allDay, setAllDay] = useState(occurrence?.allDay ?? false);
  const [startDate, setStartDate] = useState(initStart.date);
  const [startTime, setStartTime] = useState(initStart.time);
  const [endDate, setEndDate] = useState(initEndDate);
  const [endTime, setEndTime] = useState(initEnd.time);
  const [recurrence, setRecurrence] = useState<RecurrencePreset>('none');
  // Phase 11: reminder picker state. Sentinel values:
  //   '__none__'   = None (no reminder)
  //   '__custom__' = read-only "Custom (kept)" (absolute/multi VALARM, D-07/D-08)
  //   numeric str  = preset or synthetic off-list lead in minutes
  const [reminderValue, setReminderValue] = useState<string>('__none__');
  // D-06: recurrence bound state — "Ends" control
  const [recurrenceBound, setRecurrenceBound] = useState<'never' | 'until' | 'count'>('never');
  const [recurrenceUntil, setRecurrenceUntil] = useState('');
  const [recurrenceCount, setRecurrenceCount] = useState(1);
  const [location, setLocation] = useState(occurrence?.location ?? '');
  const [description, setDescription] = useState(occurrence?.description ?? '');
  const [calendarUrl, setCalendarUrl] = useState<string>(() => {
    if (occurrence?.calendarId) {
      // In edit mode we don't know the URL from occurrence, use last-used or first writable
      return readLastCalendarUrl() ?? writableCalendars[0]?.url ?? '';
    }
    return readLastCalendarUrl() ?? writableCalendars[0]?.url ?? '';
  });

  // Sync calendarUrl default when writableCalendars loads
  useEffect(() => {
    if (writableCalendars.length > 0 && !calendarUrl) {
      const lastUrl = readLastCalendarUrl();
      const found = lastUrl ? writableCalendars.find((c) => c.url === lastUrl) : null;
      setCalendarUrl(found ? found.url : writableCalendars[0].url);
    }
  }, [writableCalendars]); // eslint-disable-line react-hooks/exhaustive-deps

  // Reset form when opening (mode may change) or when occurrence resolves in cache.
  // WR-03: `occurrence` (via occurrence?.uid) is in the dep array so the effect
  // re-runs when the occurrence arrives in the TanStack cache after form open.
  // This prevents the "blank edit form" bug when the form opens before the cache
  // has hydrated the occurrence for the requested UID.
  useEffect(() => {
    if (eventFormOpen) {
      const editMode = eventFormMode === 'edit' && !!eventFormUid;
      const startParsed = initFormDateTime(occurrence?.start, editMode, '09:00');
      const endParsed = initFormDateTime(occurrence?.end, editMode, '10:00');
      // CR-03: see exclusiveEndToInclusiveDate — pre-fill the inclusive last day for
      // all-day events so re-saving an edit does not grow the span by a day each time.
      // IN-02: skip the roll-back when endParsed.date is blank (parse failure in edit mode).
      const endDateValue =
        occurrence?.allDay && endParsed.date
          ? exclusiveEndToInclusiveDate(endParsed.date)
          : endParsed.date;
      setTitle(occurrence?.title ?? '');
      setAllDay(occurrence?.allDay ?? false);
      setStartDate(startParsed.date);
      setStartTime(startParsed.time);
      setEndDate(endDateValue);
      setEndTime(endParsed.time);
      // WR-03 recurrence: derive from occurrence if present; default 'none' only when
      // genuinely absent. Note: occurrence.recurrence is not in CalendarOccurrence type
      // (the API expand contract does not expose it in v1 — D-03). We cast to any to
      // read it if a future API version adds it, and default to 'none' when not present
      // (WR-03 v1 comment: occurrence edits whose recurrence is not in the cache default
      // to 'none'; this will be addressed when the occurrence/expand contract is extended).
      // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access -- occurrence.recurrence is not in CalendarOccurrence v1 type; cast to any to read a future API field, default 'none' when absent
      const derivedRecurrence = (occurrence as any)?.recurrence as RecurrencePreset | undefined;
      setRecurrence(derivedRecurrence ?? 'none');
      // D-06: reset bound state to defaults on form open/occurrence change
      setRecurrenceBound('never');
      setRecurrenceUntil('');
      setRecurrenceCount(1);
      setLocation(occurrence?.location ?? '');
      setDescription(occurrence?.description ?? '');
      // Phase 11: derive reminder picker value from occurrence (edit-mode pre-population, D-01/D-07)
      // CR-01 (Plan 11-05): pass reminderIsCustom so custom alarms initialize to '__custom__'
      // instead of '__none__', making the D-08 preserve path reachable on edit.
      const occAllDay = occurrence?.allDay ?? false;
      const occIsCustom = occurrence?.reminderIsCustom ?? false;
      setReminderValue(
        deriveReminderValue(occurrence?.reminderLeadMinutes ?? null, occAllDay, occIsCustom),
      );
    }
  }, [eventFormOpen, eventFormMode, eventFormUid, occurrence?.uid]); // eslint-disable-line react-hooks/exhaustive-deps

  // ── Validation state ────────────────────────────────────────────────────────

  const [errors, setErrors] = useState<{
    title?: string;
    endTime?: string;
    recurrenceBound?: string;
  }>({});
  // D-08/D-09: series-edit confirmation prompt state
  const [seriesEditPromptOpen, setSeriesEditPromptOpen] = useState(false);

  // ── Mutations ───────────────────────────────────────────────────────────────

  const mutation = useMutation({
    mutationFn: (payload: CreateEventPayload) =>
      eventFormMode === 'edit' && eventFormUid
        ? updateEvent(eventFormUid, payload)
        : createEvent(payload),
    onSuccess: (data) => {
      // Wire sync-toast: set the returned UID so SyncStateToast starts polling (D-05/D-09)
      setLastSyncedUid(data.uid);
      // Do NOT invalidate here — SyncStateToast does it on done/conflict (D-06/D-08)
      if (calendarUrl) writeLastCalendarUrl(calendarUrl);
      setEventForm(false);
    },
  });

  // ── Handlers ────────────────────────────────────────────────────────────────

  const handleClose = () => setEventForm(false);

  const handleAllDayToggle = () => {
    const next = !allDay;
    setAllDay(next);
    // D-03 (Phase 11): reset reminder to None on allDay toggle — no carry-over between preset sets
    setReminderValue('__none__');
    if (!next) {
      // Turning off all-day: restore default times
      setStartTime('09:00');
      setEndTime('10:00');
    }
    if (next) {
      // WR-02: turning all-day ON discards the time inputs, so a midnight-spanning
      // timed event (start 06-10 23:00, end 06-11 01:00) would otherwise leave endDate
      // at 06-11 — a 2-day all-day span the user did not intend. Clamp endDate to
      // max(startDate, endDate) deterministically: when the end day is behind the start
      // it snaps forward to a single-day event; an already-valid multi-day all-day span
      // is preserved. Also clear any stale end-time error left over from the timed view.
      setEndDate((prev) => (prev < startDate ? startDate : prev));
      setErrors((prev) => (prev.endTime ? { ...prev, endTime: undefined } : prev));
    }
  };

  const validate = (): boolean => {
    const newErrors: { title?: string; endTime?: string; recurrenceBound?: string } = {};

    if (!title.trim()) {
      newErrors.title = 'Title is required';
    }

    // IN-02: a blank start/end date means a cached value failed to parse in edit mode
    // (initFormDateTime left it empty rather than substituting today/09:00). Block submit
    // so the corrupt value is never silently saved as today/09:00.
    if (!startDate || !endDate || (!allDay && (!startTime || !endTime))) {
      newErrors.endTime = "Couldn't read this event's date — re-open it from the calendar";
      setErrors(newErrors);
      return false;
    }

    if (!allDay) {
      const startISO = `${startDate}T${startTime}:00`;
      const endISO = `${endDate}T${endTime}:00`;
      if (endISO <= startISO) {
        newErrors.endTime = 'End time must be after start';
      }
    } else {
      if (endDate < startDate) {
        newErrors.endTime = 'End time must be after start';
      }
    }

    // D-06: validate recurrence bound inputs (T-06-06-input: client-side UX gate)
    if (recurrence !== 'none') {
      if (
        recurrenceBound === 'count' &&
        (!Number.isInteger(recurrenceCount) || recurrenceCount < 1)
      ) {
        // WR-03: NaN < 1 is false, so a NaN count (from inputs like '' / '-' / 'e')
        // previously bypassed this guard AND the payload spread, yielding an unbounded
        // series. Require a finite integer ≥ 1 explicitly.
        newErrors.recurrenceBound = 'Must be at least 1 occurrence';
      } else if (recurrenceBound === 'until') {
        // WR-02: a blank end date with bound='until' must be a validation error.
        // Previously it passed validation and the payload spread dropped
        // recurrenceUntil, silently creating an UNBOUNDED series — the opposite
        // of the user's stated "Ends: On date" intent.
        if (!recurrenceUntil) {
          newErrors.recurrenceBound = 'Choose an end date';
        } else if (startDate && recurrenceUntil < startDate) {
          // WR-07: only compare when startDate is non-empty. Both values are
          // zero-padded ISO DATE strings here, so lexicographic compare is valid;
          // guarding on a non-empty startDate avoids `recurrenceUntil < ''` (always
          // false) silently skipping the bound-before-start guard.
          newErrors.recurrenceBound = 'End date must be after the event starts';
        }
      }
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  /**
   * Build the payload and fire the mutation. Called directly for non-recurring edits
   * and create mode; called from the SeriesEditPrompt onConfirm for recurring edits
   * (D-08/D-09). The whole-series PATCH reuses the existing /api/events/:uid/edit route
   * which PUTs the master VEVENT — no RECURRENCE-ID, per D-08.
   */
  const executeSubmit = () => {
    // BUG A fix: serialize timed events to an unambiguous UTC instant here in
    // the browser (operator's zone is known) instead of sending a naive local
    // wall-clock string. The API container is UTC; a naive string was being read
    // as UTC, shifting 09:00 local to 09:00Z (4h off). See lib/eventDateTime.ts.
    const { start: serializedStart, end: serializedEnd } = serializeEventDateTime(
      allDay,
      startDate,
      startTime,
      endDate,
      endTime,
    );

    // WR-01: on EDIT, omit `recurrence` from the payload. The occurrence/expand contract
    // does not expose the event's existing recurrence (D-03), so the form cannot know it
    // and would otherwise send 'none' — silently stripping the RRULE and converting a
    // recurring series into a single event. Omitting the field signals "unchanged"; the
    // outbox worker then preserves the stored RRULE (see outboxWorker.ts WR-01). On
    // CREATE the user explicitly chose a recurrence, so it is always sent.
    const isEdit = eventFormMode === 'edit' && !!eventFormUid;

    // Phase 11: map reminder picker value to payload field (D-08).
    //   '__none__'   → null  (explicit clear)
    //   '__custom__' → omit  (unchanged custom alarm — server preserves original VALARM)
    //   numeric str  → integer (preset or synthetic off-list lead)
    let reminderPayload: { reminderLeadMinutes?: number | null } = {};
    if (reminderValue === '__none__') {
      reminderPayload = { reminderLeadMinutes: null };
    } else if (reminderValue !== '__custom__') {
      const parsed = parseInt(reminderValue, 10);
      if (Number.isFinite(parsed) && parsed >= 0) {
        reminderPayload = { reminderLeadMinutes: parsed };
      }
      // If parse fails (should not happen), omit field (safe default: no-change)
    }
    // __custom__ → reminderPayload stays {} (field absent = no-change, D-08)

    const payload: CreateEventPayload = {
      title: title.trim(),
      allDay,
      start: serializedStart,
      end: serializedEnd,
      ...(isEdit ? {} : { recurrence }),
      // D-06: only send bound fields when recurrence is set and bound type is not 'never'
      ...(!isEdit && recurrence !== 'none' && recurrenceBound === 'until' && recurrenceUntil
        ? { recurrenceUntil }
        : {}),
      ...(!isEdit && recurrence !== 'none' && recurrenceBound === 'count' && recurrenceCount >= 1
        ? { recurrenceCount }
        : {}),
      ...(location.trim() ? { location: location.trim() } : {}),
      ...(description.trim() ? { description: description.trim() } : {}),
      ...reminderPayload,
      ...(writableCalendars.length > 1 && calendarUrl ? { calendarUrl } : {}),
    };

    mutation.mutate(payload);
  };

  const handleSubmit = () => {
    if (!validate()) return;

    const isEdit = eventFormMode === 'edit' && !!eventFormUid;

    // D-08/D-09: gate recurring-series edits behind the confirmation prompt
    if (isEdit && occurrence?.hasRrule === true) {
      setSeriesEditPromptOpen(true);
      return;
    }

    executeSubmit();
  };

  // ── Keyboard: Escape to close ───────────────────────────────────────────────

  useEffect(() => {
    if (!eventFormOpen) return;
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') handleClose();
    };
    document.addEventListener('keydown', onKeyDown);
    return () => document.removeEventListener('keydown', onKeyDown);
  }, [eventFormOpen]); // eslint-disable-line react-hooks/exhaustive-deps

  // ── Focus trap: Tab / Shift+Tab cycles within dialog (WR-07) ───────────────
  // IN-05: shared with SeriesEditPrompt via useFocusTrap so the trap logic lives once.
  const handleDialogKeyDown = useFocusTrap(dialogRef);

  // ── Focus: move to title input on open ─────────────────────────────────────

  useEffect(() => {
    if (eventFormOpen && titleRef.current) {
      titleRef.current.focus();
    }
  }, [eventFormOpen]);

  // ── Early return ────────────────────────────────────────────────────────────

  if (!eventFormOpen) return null;

  // ── Responsive styles ───────────────────────────────────────────────────────

  const isPhone = isPhoneBreakpoint();
  const label = eventFormMode === 'edit' ? 'Edit Event' : 'New Event';
  // D-08/D-09: CTA label is "Update series" for recurring edits (UI-SPEC "EventForm primary CTAs")
  const isEditRecurring = eventFormMode === 'edit' && occurrence?.hasRrule === true;
  const saveLabel = mutation.isPending
    ? 'Saving…'
    : isEditRecurring
      ? 'Update series'
      : eventFormMode === 'edit'
        ? 'Save Changes'
        : 'Create Event';

  const dialogStyle: React.CSSProperties = isPhone
    ? {
        position: 'fixed',
        bottom: 0,
        left: 0,
        right: 0,
        background: 'var(--color-surface-raised)',
        borderRadius: 'var(--space-3) var(--space-3) 0 0',
        boxShadow: '0 -4px 24px rgba(0,0,0,0.12)',
        padding: 'var(--space-6)',
        maxHeight: '90dvh',
        overflowY: 'auto',
        zIndex: 200,
        fontFamily: 'var(--font-family-base)',
      }
    : {
        position: 'fixed',
        top: '50%',
        left: '50%',
        transform: 'translate(-50%, -50%)',
        background: 'var(--color-surface-raised)',
        borderRadius: 'var(--space-2)',
        boxShadow: '0 8px 32px rgba(0,0,0,0.16)',
        padding: 'var(--space-6)',
        width: '100%',
        maxWidth: '480px',
        maxHeight: '90dvh',
        overflowY: 'auto',
        zIndex: 200,
        fontFamily: 'var(--font-family-base)',
      };

  // ── Shared input style ──────────────────────────────────────────────────────

  const inputStyle: React.CSSProperties = {
    width: '100%',
    boxSizing: 'border-box',
    minHeight: '44px',
    padding: '0 var(--space-3)',
    border: '1px solid var(--color-border)',
    borderRadius: 'var(--space-1)',
    background: 'var(--color-surface)',
    fontSize: 'var(--text-body-size)',
    fontWeight: 'var(--text-body-weight)',
    color: 'var(--color-text-primary)',
    fontFamily: 'var(--font-family-base)',
    outline: 'none',
  };

  const labelStyle: React.CSSProperties = {
    display: 'block',
    fontSize: 'var(--text-label-size)',
    fontWeight: 'var(--text-label-weight)',
    color: 'var(--color-text-secondary)',
    marginBottom: 'var(--space-1)',
  };

  const fieldStyle: React.CSSProperties = {
    marginBottom: 'var(--space-4)',
  };

  const errorStyle: React.CSSProperties = {
    fontSize: 'var(--text-label-size)',
    color: 'var(--color-destructive)',
    marginTop: 'var(--space-1)',
  };

  const showPicker = writableCalendars.length > 1;

  return (
    <>
      {/* Backdrop */}
      <div
        data-testid="event-form-backdrop"
        onClick={handleClose}
        style={{
          position: 'fixed',
          inset: 0,
          background: 'var(--color-overlay)',
          zIndex: 199,
        }}
      />

      {/* Dialog */}
      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-label={label}
        tabIndex={-1}
        onKeyDown={handleDialogKeyDown}
        style={dialogStyle}
      >
        {/* Header */}
        <div
          style={{
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
            marginBottom: 'var(--space-4)',
          }}
        >
          <h2
            style={{
              margin: 0,
              fontSize: 'var(--text-heading-size)',
              fontWeight: 'var(--text-heading-weight)',
              lineHeight: 'var(--text-heading-line-height)',
              color: 'var(--color-text-primary)',
              fontFamily: 'var(--font-family-base)',
            }}
          >
            {/* Plain text — XSS guard (T-03-15) */}
            {label}
          </h2>
          <button
            aria-label="Close"
            onClick={handleClose}
            style={{
              background: 'none',
              border: 'none',
              cursor: 'pointer',
              minWidth: '44px',
              minHeight: '44px',
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'center',
              color: 'var(--color-text-secondary)',
              borderRadius: 'var(--space-1)',
              padding: 0,
            }}
          >
            <X size={20} aria-hidden="true" />
          </button>
        </div>

        {/* Title field */}
        <div style={fieldStyle}>
          <label htmlFor="event-title" style={labelStyle}>
            Title
          </label>
          <input
            id="event-title"
            ref={titleRef}
            type="text"
            placeholder="Event title"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            style={{
              ...inputStyle,
              ...(errors.title ? { borderColor: 'var(--color-destructive)' } : {}),
            }}
          />
          {errors.title && (
            <div style={errorStyle}>
              {/* Plain text — XSS guard (T-03-15) */}
              {errors.title}
            </div>
          )}
        </div>

        {/* All-day toggle */}
        <div
          style={{
            ...fieldStyle,
            display: 'flex',
            alignItems: 'center',
            gap: 'var(--space-3)',
          }}
        >
          <button
            role="switch"
            aria-checked={allDay}
            onClick={handleAllDayToggle}
            style={{
              width: '44px',
              height: '26px',
              borderRadius: '13px',
              background: allDay ? 'var(--color-text-primary)' : 'var(--color-border)',
              border: 'none',
              cursor: 'pointer',
              position: 'relative',
              transition: 'background 0.15s',
              flexShrink: 0,
              minWidth: '44px',
              minHeight: '26px',
              padding: 0,
            }}
          >
            <span
              aria-hidden="true"
              style={{
                position: 'absolute',
                top: '3px',
                left: allDay ? '21px' : '3px',
                width: '20px',
                height: '20px',
                borderRadius: '50%',
                background: '#fff',
                transition: 'left 0.15s',
                boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
              }}
            />
          </button>
          <span
            style={{
              fontSize: 'var(--text-label-size)',
              color: 'var(--color-text-secondary)',
              cursor: 'pointer',
            }}
            onClick={handleAllDayToggle}
          >
            All day
          </span>
        </div>

        {/* Start date / time */}
        <div style={{ ...fieldStyle, display: 'flex', gap: 'var(--space-3)' }}>
          <div style={{ flex: 1 }}>
            <label htmlFor="event-start-date" style={labelStyle}>
              Start
            </label>
            <input
              id="event-start-date"
              type="date"
              value={startDate}
              onChange={(e) => {
                const newStart = e.target.value;
                // D-04: recompute end to preserve duration when start date changes
                if (allDay) {
                  setEndDate(computeNewAllDayEnd(newStart, startDate, endDate));
                } else {
                  const { endDate: ed, endTime: et } = computeNewTimedEnd(
                    newStart,
                    startTime,
                    startDate,
                    startTime,
                    endDate,
                    endTime,
                  );
                  setEndDate(ed);
                  setEndTime(et);
                }
                setStartDate(newStart);
              }}
              style={inputStyle}
            />
          </div>
          {!allDay && (
            <div style={{ flex: '0 0 110px' }}>
              <label htmlFor="event-start-time" style={labelStyle}>
                &nbsp;
              </label>
              <input
                id="event-start-time"
                type="time"
                value={startTime}
                onChange={(e) => {
                  const newTime = e.target.value;
                  // D-04: recompute end to preserve duration when start time changes (timed only)
                  const { endDate: ed, endTime: et } = computeNewTimedEnd(
                    startDate,
                    newTime,
                    startDate,
                    startTime,
                    endDate,
                    endTime,
                  );
                  setEndDate(ed);
                  setEndTime(et);
                  setStartTime(newTime);
                }}
                style={inputStyle}
              />
            </div>
          )}
        </div>

        {/* End date / time */}
        <div style={{ ...fieldStyle, display: 'flex', gap: 'var(--space-3)' }}>
          <div style={{ flex: 1 }}>
            <label htmlFor="event-end-date" style={labelStyle}>
              End
            </label>
            <input
              id="event-end-date"
              type="date"
              value={endDate}
              onChange={(e) => setEndDate(e.target.value)}
              style={{
                ...inputStyle,
                ...(errors.endTime ? { borderColor: 'var(--color-destructive)' } : {}),
              }}
            />
          </div>
          {!allDay && (
            <div style={{ flex: '0 0 110px' }}>
              <label htmlFor="event-end-time" style={labelStyle}>
                &nbsp;
              </label>
              <input
                id="event-end-time"
                type="time"
                value={endTime}
                onChange={(e) => setEndTime(e.target.value)}
                style={{
                  ...inputStyle,
                  ...(errors.endTime ? { borderColor: 'var(--color-destructive)' } : {}),
                }}
              />
            </div>
          )}
        </div>
        {errors.endTime && (
          <div style={{ ...errorStyle, marginTop: '-8px', marginBottom: 'var(--space-4)' }}>
            {/* Plain text — XSS guard (T-03-15) */}
            {errors.endTime}
          </div>
        )}

        {/* Calendar picker (D-02: hidden when only 1 writable calendar) */}
        {showPicker && (
          <div style={fieldStyle}>
            <label htmlFor="event-calendar" style={labelStyle}>
              Calendar
            </label>
            <select
              id="event-calendar"
              value={calendarUrl}
              onChange={(e) => setCalendarUrl(e.target.value)}
              style={{
                ...inputStyle,
                padding: '0 var(--space-3)',
                cursor: 'pointer',
              }}
            >
              {writableCalendars.map((cal) => (
                <option key={cal.url} value={cal.url}>
                  {/* Plain text — XSS guard (T-03-15) */}
                  {cal.displayName}
                </option>
              ))}
            </select>
          </div>
        )}

        {/* Recurrence picker (D-11: whole-series only).
            WR-01: disabled in edit mode — the occurrence contract does not expose the
            event's recurrence, so the form cannot show/change it without risking a
            silent reset. Editing recurrence is deferred until the contract exposes it;
            the existing RRULE is preserved server-side on edit. */}
        <div style={fieldStyle}>
          <label htmlFor="event-recurrence" style={labelStyle}>
            Repeat
          </label>
          <select
            id="event-recurrence"
            value={recurrence}
            disabled={eventFormMode === 'edit'}
            onChange={(e) => setRecurrence(e.target.value as RecurrencePreset)}
            style={{
              ...inputStyle,
              padding: '0 var(--space-3)',
              cursor: eventFormMode === 'edit' ? 'not-allowed' : 'pointer',
              opacity: eventFormMode === 'edit' ? 0.6 : 1,
            }}
          >
            <option value="none">None</option>
            <option value="daily">Daily</option>
            <option value="weekly">Weekly</option>
            <option value="monthly">Monthly</option>
            <option value="yearly">Yearly</option>
          </select>
          {/* WR-01: surface the v1 constraint so a user editing a recurring event is not
              silently surprised that the schedule is locked. Additive helper text only —
              the existing RRULE is preserved server-side on edit. */}
          {eventFormMode === 'edit' && (
            <div
              style={{
                fontSize: 'var(--text-label-size)',
                color: 'var(--color-text-secondary)',
                marginTop: 'var(--space-1)',
              }}
            >
              {/* Plain text — XSS guard (T-03-15) */}
              Repeat can&apos;t be changed yet — edits keep the existing schedule.
            </div>
          )}
        </div>

        {/* Phase 11: Reminder picker (allDay-aware swap, D-01/D-02/D-03/D-07/D-08).
            NOT disabled in edit mode — reminders are editable (unlike Repeat/WR-01). */}
        <div style={fieldStyle}>
          <label htmlFor="event-reminder" style={labelStyle}>
            Reminder
          </label>
          <select
            id="event-reminder"
            value={reminderValue}
            onChange={(e) => setReminderValue(e.target.value)}
            style={{
              ...inputStyle,
              padding: '0 var(--space-3)',
              cursor: 'pointer',
            }}
          >
            {allDay ? (
              // D-02: all-day presets (day-granularity)
              <>
                <option value="__none__">None</option>
                <option value="0">Same day (9 AM)</option>
                <option value="1440">1 day before (9 AM)</option>
                <option value="2880">2 days before (9 AM)</option>
                <option value="10080">1 week before (9 AM)</option>
                {/* D-07: synthetic option for off-list all-day value */}
                {reminderValue !== '__none__' &&
                  reminderValue !== '__custom__' &&
                  !ALLDAY_REMINDER_PRESETS.has(parseInt(reminderValue, 10)) &&
                  Number.isFinite(parseInt(reminderValue, 10)) && (
                    <option value={reminderValue}>
                      {humanizeReminderLead(parseInt(reminderValue, 10))}
                    </option>
                  )}
                {/* D-07: read-only Custom (kept) for absolute/multi alarm */}
                {reminderValue === '__custom__' && (
                  <option value="__custom__" disabled>
                    Custom (kept)
                  </option>
                )}
              </>
            ) : (
              // D-01: timed presets
              <>
                <option value="__none__">None</option>
                <option value="5">5 minutes before</option>
                <option value="10">10 minutes before</option>
                <option value="15">15 minutes before</option>
                <option value="30">30 minutes before</option>
                <option value="60">1 hour before</option>
                <option value="120">2 hours before</option>
                <option value="1440">1 day before</option>
                <option value="2880">2 days before</option>
                {/* D-07: synthetic option for off-list timed value */}
                {reminderValue !== '__none__' &&
                  reminderValue !== '__custom__' &&
                  !TIMED_REMINDER_PRESETS.has(parseInt(reminderValue, 10)) &&
                  Number.isFinite(parseInt(reminderValue, 10)) && (
                    <option value={reminderValue}>
                      {humanizeReminderLead(parseInt(reminderValue, 10))}
                    </option>
                  )}
                {/* D-07: read-only Custom (kept) for absolute/multi alarm */}
                {reminderValue === '__custom__' && (
                  <option value="__custom__" disabled>
                    Custom (kept)
                  </option>
                )}
              </>
            )}
          </select>
          {/* Helper text: shown in edit mode when value is __custom__ or synthetic off-list (D-07) */}
          {/* WR-03: gate on the ACTIVE preset set only — not both — so a timed event with a
              value that happens to be in the allDay set still shows the off-list helper text */}
          {eventFormMode === 'edit' &&
            (reminderValue === '__custom__' ||
              (reminderValue !== '__none__' &&
                !(allDay ? ALLDAY_REMINDER_PRESETS : TIMED_REMINDER_PRESETS).has(
                  parseInt(reminderValue, 10),
                ) &&
                Number.isFinite(parseInt(reminderValue, 10)))) && (
              <div
                style={{
                  fontSize: 'var(--text-label-size)',
                  color: 'var(--color-text-secondary)',
                  marginTop: 'var(--space-1)',
                }}
              >
                {/* Plain text — XSS guard (T-11-10 / T-03-15) */}
                Custom reminder kept — select a preset to replace it.
              </div>
            )}
        </div>

        {/* D-06: Recurrence bound control — shown only when recurrence ≠ 'none' in create mode */}
        {eventFormMode !== 'edit' && recurrence !== 'none' && (
          <div style={fieldStyle}>
            <label htmlFor="recurrence-bound" style={labelStyle}>
              Ends
            </label>
            <select
              id="recurrence-bound"
              value={recurrenceBound}
              onChange={(e) => setRecurrenceBound(e.target.value as 'never' | 'until' | 'count')}
              style={{
                ...inputStyle,
                padding: '0 var(--space-3)',
                cursor: 'pointer',
              }}
            >
              <option value="never">Never</option>
              <option value="until">On date</option>
              <option value="count">After N times</option>
            </select>

            {/* "On date" — date input */}
            {recurrenceBound === 'until' && (
              <div style={{ marginTop: 'var(--space-2)' }}>
                <label htmlFor="recurrence-until" style={labelStyle}>
                  End date
                </label>
                <input
                  id="recurrence-until"
                  type="date"
                  value={recurrenceUntil}
                  onChange={(e) => setRecurrenceUntil(e.target.value)}
                  style={{
                    ...inputStyle,
                    ...(errors.recurrenceBound ? { borderColor: 'var(--color-destructive)' } : {}),
                  }}
                />
              </div>
            )}

            {/* "After N times" — number input */}
            {recurrenceBound === 'count' && (
              <div style={{ marginTop: 'var(--space-2)' }}>
                <label htmlFor="recurrence-count" style={labelStyle}>
                  Occurrences
                </label>
                <input
                  id="recurrence-count"
                  type="number"
                  min={1}
                  placeholder="e.g. 10"
                  value={recurrenceCount}
                  onChange={(e) => {
                    // WR-03: parseInt + Number.isFinite guard. Number('') === 0 and
                    // Number('-'/'e') === NaN both previously slipped through; coerce any
                    // non-finite intermediate to 0 so the validate() guard catches it.
                    const n = parseInt(e.target.value, 10);
                    setRecurrenceCount(Number.isFinite(n) ? n : 0);
                  }}
                  style={{
                    ...inputStyle,
                    ...(errors.recurrenceBound ? { borderColor: 'var(--color-destructive)' } : {}),
                  }}
                />
              </div>
            )}

            {/* Inline validation error (T-06-06-input) */}
            {errors.recurrenceBound && (
              <div style={errorStyle}>
                {/* Plain text — XSS guard (T-03-15) */}
                {errors.recurrenceBound}
              </div>
            )}
          </div>
        )}

        {/* Location */}
        <div style={fieldStyle}>
          <label htmlFor="event-location" style={labelStyle}>
            Location
          </label>
          <input
            id="event-location"
            type="text"
            placeholder="Add location"
            value={location}
            onChange={(e) => setLocation(e.target.value)}
            style={inputStyle}
          />
        </div>

        {/* Description */}
        <div style={fieldStyle}>
          <label htmlFor="event-description" style={labelStyle}>
            Description
          </label>
          <textarea
            id="event-description"
            placeholder="Add description"
            value={description}
            onChange={(e) => setDescription(e.target.value)}
            rows={3}
            style={{
              ...inputStyle,
              minHeight: '80px',
              padding: 'var(--space-3)',
              resize: 'vertical',
            }}
          />
        </div>

        {/* Footer: Cancel + Save */}
        <div
          style={{
            display: 'flex',
            justifyContent: 'space-between',
            gap: 'var(--space-4)',
            marginTop: 'var(--space-4)',
          }}
        >
          <button
            type="button"
            onClick={handleClose}
            style={{
              background: 'none',
              border: 'none',
              cursor: 'pointer',
              minHeight: '44px',
              padding: '0 var(--space-4)',
              fontSize: 'var(--text-label-size)',
              color: 'var(--color-text-secondary)',
              borderRadius: 'var(--space-1)',
              fontFamily: 'var(--font-family-base)',
            }}
          >
            Cancel
          </button>

          <button
            type="button"
            onClick={handleSubmit}
            disabled={mutation.isPending}
            style={{
              background: 'var(--color-text-primary)',
              color: '#ffffff',
              border: 'none',
              cursor: mutation.isPending ? 'not-allowed' : 'pointer',
              minHeight: '44px',
              padding: '0 var(--space-6)',
              fontSize: 'var(--text-label-size)',
              fontWeight: 600,
              borderRadius: 'var(--space-1)',
              display: 'flex',
              alignItems: 'center',
              gap: 'var(--space-2)',
              fontFamily: 'var(--font-family-base)',
              opacity: mutation.isPending ? 0.7 : 1,
            }}
          >
            {mutation.isPending && (
              <Loader2
                size={16}
                aria-hidden="true"
                style={{ animation: 'spin 1s linear infinite' }}
              />
            )}
            {/* Plain text — XSS guard (T-03-15) */}
            {saveLabel}
          </button>
        </div>
      </div>

      {/* D-08/D-09: Series-edit confirmation prompt — shown when editing a recurring occurrence */}
      <SeriesEditPrompt
        open={seriesEditPromptOpen}
        onConfirm={() => {
          setSeriesEditPromptOpen(false);
          executeSubmit();
        }}
        onCancel={() => setSeriesEditPromptOpen(false)}
      />
    </>
  );
}
