// #273: Floating, draggable 5-minute pre-meeting alert.
// Mounted globally from App.tsx so it stays visible while the user
// navigates around tx-os. Polls /api/executive-meetings for today and
// the per-user alert state every 30 s, and listens to React Query
// invalidations from the executive_meetings_changed socket event.

import {
  Fragment,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
import {
  Calendar,
  ChevronDown,
  ChevronUp,
  Clock,
  ExternalLink,
  GripVertical,
  Users,
  X,
} from "lucide-react";
import { hexToRgba, useAlertPrefs } from "@/lib/upcoming-alert-prefs";
import { formatTime as formatTimeLocale } from "@/lib/i18n-format";
import { notificationPlayer } from "@/lib/notification-sounds";
// #486: ApiError + apiJson moved to a shared module so the new
// schedule row quick-actions popover can reuse the PostponeDialog
// (exported below) and inspect the same structured 409 stale_meeting
// payload without duplicating the fetch wrapper.
import { ApiError, apiJson } from "@/lib/api-json";

const POSITION_KEY = "txos.upcomingMeetingAlert.position";
const ALERT_LEAD_MINUTES = 5;
const POLL_MS = 30_000;

// Subset of attendee fields we need for the details panel. The server
// returns more (sortOrder, attendanceType, etc.) but the popup only
// shows name + type grouping.
type Attendee = {
  id?: number;
  name: string;
  attendanceType: "internal" | "external" | "virtual" | string;
  kind?: "person" | "subheading";
};

type Meeting = {
  id: number;
  dailyNumber: number;
  titleAr: string;
  titleEn: string;
  meetingDate: string;
  startTime: string | null;
  endTime: string | null;
  location: string | null;
  meetingUrl: string | null;
  status: string;
  notes: string | null;
  platform: "none" | "webex" | "teams" | "zoom" | "other";
  attendees: Attendee[];
  // #283: optimistic-locking token used by the postpone flow to detect
  // when another user mutated this meeting after it was loaded. Server
  // sets it on every update; client echoes it back as expectedUpdatedAt.
  updatedAt: string;
  // #635: external meeting flag — the alert "Postpone" button is
  // disabled when true (the server also rejects with
  // `external_meeting_no_auto_postpone`).
  isExternal?: boolean;
};
type DayResponse = { date: string; meetings: Meeting[] };
type AlertState = {
  meetingId: number;
  dismissed: boolean;
  acknowledged: boolean;
};
type Capabilities = {
  canRead: boolean;
  canMutate: boolean;
};

function parseTimeToMinutes(t: string | null | undefined): number | null {
  if (!t) return null;
  const m = /^(\d{1,2}):(\d{2})/.exec(t);
  if (!m) return null;
  const h = Number(m[1]);
  const mm = Number(m[2]);
  if (h < 0 || h > 23 || mm < 0 || mm > 59) return null;
  return h * 60 + mm;
}
function todayLocalDate(): string {
  const d = new Date();
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}
function nowLocalMinutes(): number {
  const d = new Date();
  return d.getHours() * 60 + d.getMinutes();
}
// #307: Render scheduled HH:mm strings as a localized 12-hour clock
// (e.g. "2:30 PM" / "2:30 م"). The stored value is always a wall-clock
// "HH:mm[:ss]" so we anchor it on today's date just to feed Intl.
function formatTime(t: string | null, lang: string): string {
  if (!t) return "";
  const m = /^(\d{1,2}):(\d{2})/.exec(t);
  if (!m) return t;
  const d = new Date();
  d.setHours(Number(m[1]), Number(m[2]), 0, 0);
  return formatTimeLocale(d, lang, {
    hour: "numeric",
    minute: "2-digit",
    hour12: true,
  });
}

// #283/#486: ApiError + apiJson are imported above from
// `@/lib/api-json` so this file and the schedule row quick-actions
// popover share one fetch wrapper and one error class.

type DragPosition = { x: number; y: number };
function loadPosition(): DragPosition | null {
  try {
    const raw = localStorage.getItem(POSITION_KEY);
    if (!raw) return null;
    const obj = JSON.parse(raw) as DragPosition;
    if (typeof obj.x !== "number" || typeof obj.y !== "number") return null;
    return obj;
  } catch {
    return null;
  }
}
function savePosition(pos: DragPosition) {
  try {
    localStorage.setItem(POSITION_KEY, JSON.stringify(pos));
  } catch {
    /* ignore storage errors */
  }
}
function defaultPosition(): DragPosition {
  // Top-right corner with 16px margin; gracefully degrades on small screens.
  if (typeof window === "undefined") return { x: 16, y: 16 };
  const w = Math.min(380, window.innerWidth - 32);
  return { x: Math.max(16, window.innerWidth - w - 16), y: 80 };
}

export function UpcomingMeetingAlert() {
  const { user, isLoading: authLoading } = useAuth();
  const { t, i18n } = useTranslation();
  const isRtl = i18n.language === "ar";
  const { toast } = useToast();
  const queryClient = useQueryClient();
  const [now, setNow] = useState<number>(() => nowLocalMinutes());
  const [postponeOpen, setPostponeOpen] = useState(false);
  const [savingAction, setSavingAction] = useState<string | null>(null);
  // Per-user theme + enable toggle for this floating popup. Lives in
  // localStorage and is shared with the settings card via a custom
  // event — see `lib/upcoming-alert-prefs.ts`.
  const [alertPrefs] = useAlertPrefs();
  // Inline-expand state for the new "Details" toggle that replaced the
  // old "Open meetings" button. Resets whenever the eligible meeting
  // changes so a new alert doesn't pop open already-expanded.
  const [expanded, setExpanded] = useState(false);

  // Tick the clock every minute so the countdown stays current. Also
  // tick when the tab regains focus so users coming back to it don't
  // see a stale 5-minutes-ago timestamp.
  useEffect(() => {
    const interval = setInterval(() => setNow(nowLocalMinutes()), 30_000);
    const onFocus = () => setNow(nowLocalMinutes());
    window.addEventListener("focus", onFocus);
    return () => {
      clearInterval(interval);
      window.removeEventListener("focus", onFocus);
    };
  }, []);

  const today = todayLocalDate();

  const { data: caps } = useQuery<Capabilities>({
    queryKey: ["/api/executive-meetings/me", user?.id ?? null],
    enabled: !authLoading && !!user,
    queryFn: async () => {
      const res = await fetch("/api/executive-meetings/me", {
        credentials: "include",
      });
      if (res.status === 401 || res.status === 403) {
        return { canRead: false, canMutate: false };
      }
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const json = (await res.json()) as Capabilities;
      return { canRead: json.canRead, canMutate: json.canMutate };
    },
    staleTime: 5 * 60_000,
  });

  const enabled = !!user && !!caps?.canRead;

  const { data: dayData } = useQuery<DayResponse>({
    queryKey: ["/api/executive-meetings", today],
    enabled,
    queryFn: async () => {
      const res = await fetch(`/api/executive-meetings?date=${today}`, {
        credentials: "include",
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    },
    refetchInterval: POLL_MS,
    refetchOnWindowFocus: true,
  });

  const { data: alertStateData, refetch: refetchAlertState } = useQuery<{
    states: AlertState[];
  }>({
    queryKey: ["/api/executive-meetings/alert-state", today, user?.id ?? null],
    enabled,
    queryFn: async () => {
      const res = await fetch(
        `/api/executive-meetings/alert-state?date=${today}`,
        { credentials: "include" },
      );
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    },
    refetchInterval: POLL_MS,
  });

  const stateById = useMemo(() => {
    const map = new Map<number, AlertState>();
    for (const s of alertStateData?.states ?? []) map.set(s.meetingId, s);
    return map;
  }, [alertStateData]);

  // #480: id → dailyNumber lookup for today's meetings, used by the
  // cascade-affected-meetings table inside the postpone/reschedule
  // dialog so each row can show the user-facing meeting number.
  const meetingNumbersById = useMemo(() => {
    const map = new Map<number, number>();
    for (const m of dayData?.meetings ?? []) map.set(m.id, m.dailyNumber);
    return map;
  }, [dayData]);

  // Pick the *next upcoming* meeting that:
  //  - is on today,
  //  - has a known startTime,
  //  - is not cancelled or completed,
  //  - is strictly in the future and starts within the next 5 minutes
  //    (0 < remainingMinutes <= ALERT_LEAD_MINUTES),
  //  - hasn't been acknowledged or dismissed by this user.
  // The alert hides as soon as the meeting actually starts (delta <= 0).
  const eligible = useMemo(() => {
    const meetings = dayData?.meetings ?? [];
    const candidates = meetings
      .map((m) => {
        const s = parseTimeToMinutes(m.startTime);
        if (s == null) return null;
        if (m.status === "cancelled" || m.status === "completed") return null;
        const state = stateById.get(m.id);
        if (state?.dismissed || state?.acknowledged) return null;
        const delta = s - now; // positive = in the future
        if (delta <= 0) return null;
        if (delta > ALERT_LEAD_MINUTES) return null;
        return { meeting: m, delta };
      })
      .filter(
        (
          x,
        ): x is {
          meeting: Meeting;
          delta: number;
        } => !!x,
      );
    candidates.sort((a, b) => a.delta - b.delta);
    return candidates[0] ?? null;
  }, [dayData, stateById, now]);

  // Record "shown" the first time we surface a given meeting so admins
  // can audit who actually saw the alert. We use a ref to dedupe.
  const shownRef = useRef<Set<number>>(new Set());
  useEffect(() => {
    if (!eligible || !enabled) return;
    const id = eligible.meeting.id;
    const state = stateById.get(id);
    if (state) return; // already has a row -> already counted as shown
    if (shownRef.current.has(id)) return;
    shownRef.current.add(id);
    void apiJson(`/api/executive-meetings/${id}/alert-state`, {
      method: "POST",
      body: JSON.stringify({ action: "shown" }),
    })
      .then(() => refetchAlertState())
      .catch(() => {
        shownRef.current.delete(id);
      });
  }, [eligible, enabled, stateById, refetchAlertState]);

  // ----- Drag handling -----
  const panelRef = useRef<HTMLDivElement | null>(null);
  const [pos, setPos] = useState<DragPosition>(
    () => loadPosition() ?? defaultPosition(),
  );

  // #273: keep the floating panel inside the viewport whenever the
  // window resizes (e.g. mobile orientation change, or the user
  // dragging the browser window narrower than where they had pinned
  // the alert). Runs once on mount to clamp any stale localStorage
  // position from a wider viewport, then on every resize.
  useEffect(() => {
    const clamp = () => {
      const w = window.innerWidth;
      const h = window.innerHeight;
      const el = panelRef.current;
      const elW = el?.offsetWidth ?? 360;
      const elH = el?.offsetHeight ?? 160;
      setPos((p) => {
        const x = Math.max(8, Math.min(Math.max(8, w - elW - 8), p.x));
        const y = Math.max(8, Math.min(Math.max(8, h - elH - 8), p.y));
        if (x === p.x && y === p.y) return p;
        const next = { x, y };
        savePosition(next);
        return next;
      });
    };
    clamp();
    window.addEventListener("resize", clamp);
    window.addEventListener("orientationchange", clamp);
    return () => {
      window.removeEventListener("resize", clamp);
      window.removeEventListener("orientationchange", clamp);
    };
  }, []);

  const dragState = useRef<{
    pointerId: number;
    offsetX: number;
    offsetY: number;
  } | null>(null);
  const onDragStart = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (e.button !== 0 && e.pointerType === "mouse") return;
    const target = e.currentTarget;
    target.setPointerCapture(e.pointerId);
    const rect = target.parentElement?.getBoundingClientRect();
    dragState.current = {
      pointerId: e.pointerId,
      offsetX: e.clientX - (rect?.left ?? 0),
      offsetY: e.clientY - (rect?.top ?? 0),
    };
  };
  const onDragMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    const st = dragState.current;
    if (!st || st.pointerId !== e.pointerId) return;
    const w = window.innerWidth;
    const h = window.innerHeight;
    // Clamp against the *full panel* dimensions (panelRef), not the
    // drag-handle parent, otherwise the user can drag the alert so far
    // down/right that the body and action buttons spill off-screen
    // until the next resize-clamp pass corrects it.
    const panel = panelRef.current;
    const elW = panel?.offsetWidth ?? 360;
    const elH = panel?.offsetHeight ?? 160;
    const x = Math.max(8, Math.min(Math.max(8, w - elW - 8), e.clientX - st.offsetX));
    const y = Math.max(8, Math.min(Math.max(8, h - elH - 8), e.clientY - st.offsetY));
    setPos({ x, y });
  };
  const onDragEnd = (e: ReactPointerEvent<HTMLDivElement>) => {
    const st = dragState.current;
    if (!st || st.pointerId !== e.pointerId) return;
    e.currentTarget.releasePointerCapture(e.pointerId);
    dragState.current = null;
    setPos((p) => {
      savePosition(p);
      return p;
    });
  };

  const handleDone = useCallback(async () => {
    if (!eligible) return;
    const id = eligible.meeting.id;
    setSavingAction("done");
    try {
      await apiJson(`/api/executive-meetings/${id}/alert-state`, {
        method: "POST",
        body: JSON.stringify({ action: "acknowledged" }),
      });
      await refetchAlertState();
    } catch (err) {
      toast({
        title: t("executiveMeetings.alert.saveFailed"),
        description: err instanceof Error ? err.message : String(err),
        variant: "destructive",
      });
    } finally {
      setSavingAction(null);
    }
  }, [eligible, refetchAlertState, t, toast]);

  const handleDismiss = useCallback(async () => {
    if (!eligible) return;
    const id = eligible.meeting.id;
    setSavingAction("dismiss");
    try {
      await apiJson(`/api/executive-meetings/${id}/alert-state`, {
        method: "POST",
        body: JSON.stringify({ action: "dismissed" }),
      });
      await refetchAlertState();
      toast({ title: t("executiveMeetings.alert.dismissToast") });
    } catch (err) {
      toast({
        title: t("executiveMeetings.alert.saveFailed"),
        description: err instanceof Error ? err.message : String(err),
        variant: "destructive",
      });
    } finally {
      setSavingAction(null);
    }
  }, [eligible, refetchAlertState, t, toast]);

  // Whenever the eligible meeting changes (a new alert pops up after the
  // user dismisses or finishes the previous one) collapse the details
  // section so the next alert opens compact again. Tracked by id only so
  // unrelated re-renders (countdown tick, query refetch with same data)
  // don't reset user expansion mid-view.
  const eligibleId = eligible?.meeting.id ?? null;
  useEffect(() => {
    setExpanded(false);
  }, [eligibleId]);

  // scale-in + fade entrance that mirrors the incoming-note
  // popup, so the alert feels like the same family of "important
  // floating thing arrived". Toggled false → true on the next paint of
  // each new eligible meeting so the CSS transition has a starting
  // state to interpolate from.
  const [enterAnim, setEnterAnim] = useState(false);
  useEffect(() => {
    if (!eligibleId) return;
    setEnterAnim(false);
    const handle = requestAnimationFrame(() => setEnterAnim(true));
    return () => cancelAnimationFrame(handle);
  }, [eligibleId]);

  // Play the meeting reminder sound exactly once per *new* eligible
  // alert-instance so polling refetches don't replay the chime every
  // 30s. Plays whether the tab is foreground or background — the user
  // wants to hear the reminder either way. Honors mute and the
  // per-channel meeting toggle.
  //
  // #594: the dedupe key intentionally includes `startTime`, not just
  // `meeting.id`. When the user postpones a meeting by N minutes, the
  // server keeps the same `id` but moves `startTime` forward. Without
  // the start-time in the key, the second alert (at the new time) was
  // treated as "already chimed" and stayed silent — only a full tab
  // reload would clear `playedRef` and restore the chime. Keying on
  // `id:startTime` makes every postpone (and any future reschedule)
  // count as a fresh alert instance and chime again.
  const playedRef = useRef<Set<string>>(new Set());
  const eligibleStartTime = eligible?.meeting.startTime ?? null;
  useEffect(() => {
    if (!eligibleId || !eligibleStartTime || !user) return;
    const key = `${eligibleId}:${eligibleStartTime}`;
    if (playedRef.current.has(key)) return;
    playedRef.current.add(key);
    if (user.notificationsMuted) return;
    if (!user.notifyMeetingsEnabled) return;
    notificationPlayer.play(user.notificationSoundMeeting, {
      vibrate: user.vibrationEnabledMeeting,
    });
  }, [eligibleId, eligibleStartTime, user]);

  if (!enabled || !eligible) return null;
  // User toggled the popup off in settings — render nothing. Detection
  // happens after `enabled/eligible` so the upstream "shown" recording
  // in the effect above still fires for audit purposes only when the
  // user actually wanted to see alerts.
  if (!alertPrefs.enabled) return null;

  const meeting = eligible.meeting;
  const title = isRtl
    ? meeting.titleAr || meeting.titleEn
    : meeting.titleEn || meeting.titleAr;
  const minutesAway = Math.max(0, eligible.delta);
  const headline =
    minutesAway === 0
      ? t("executiveMeetings.alert.startsNow")
      : t("executiveMeetings.alert.minutesAway", { count: minutesAway });

  // Theme tokens derived from the user's saved preset. The accent is
  // used for the divider lines and the close-X hover; the bg/fg are
  // applied directly. We compute soft variants once per render so the
  // popup feels cohesive without us having to ship a CSS variable.
  const dividerColor = hexToRgba(alertPrefs.accent, 0.45);
  const closeHoverBg = hexToRgba(alertPrefs.accent, 0.25);
  const detailsBg = hexToRgba(alertPrefs.accent, 0.12);
  // #344: `accentText` was removed because the only consumer (the
  // "starts in N minutes" headline) now uses a fixed red blink class.

  return (
    <>
      <div
        ref={panelRef}
        role="dialog"
        aria-live="polite"
        aria-label={t("executiveMeetings.alert.title")}
        data-testid="upcoming-meeting-alert"
        dir={isRtl ? "rtl" : "ltr"}
        // #282: While the postpone modal is open, hide the floating
        // alert panel so the modal (and its overlay) are not covered by
        // the alert's `z-[60]` layer. We collapse the panel with
        // `hidden` (display:none) instead of bumping z-index — that way
        // the user can't accidentally drag/click the alert through the
        // modal, and we leave the shared dialog primitive untouched so
        // other dialogs/toasts are unaffected. The panel reappears
        // automatically when the modal closes (if the alert is still
        // valid for the current time window).
        // #478: `pointer-events-auto` keeps the alert's buttons clickable
        // even while a Radix `Dialog` is open elsewhere in the app (e.g.
        // the notes thread dialog). Radix dialogs ship with
        // `react-remove-scroll`, which sets `pointer-events: none` on
        // the document body — anything portaled outside the dialog
        // (including this fixed alert at `z-[60]`) inherits that and
        // silently swallows clicks. Forcing pointer-events back to auto
        // here lets the user act on the reminder without having to
        // close the unrelated dialog first.
        className={`pointer-events-auto fixed z-[60] w-[min(380px,calc(100vw-32px))] rounded-lg shadow-xl ${
          postponeOpen ? "hidden" : ""
        } ${enterAnim ? "scale-100 opacity-100" : "scale-95 opacity-0"}`}
        aria-hidden={postponeOpen ? true : undefined}
        style={{
          left: pos.x,
          top: pos.y,
          backgroundColor: alertPrefs.bg,
          color: alertPrefs.fg,
          // #438: ring-4 frame in the user's accent matching the
          // incoming-note popup. Implemented via boxShadow so the
          // color tracks `alertPrefs.accent` (Tailwind ring-* requires
          // a static class). The first shadow is the colored ring; the
          // second preserves the existing shadow-xl drop.
          boxShadow:
            `0 0 0 4px ${hexToRgba(alertPrefs.accent, 0.7)},` +
            ` 0 20px 25px -5px rgba(0,0,0,0.1),` +
            ` 0 8px 10px -6px rgba(0,0,0,0.1)`,
          transition:
            "transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1)," +
            " opacity 200ms ease-out",
          transformOrigin: "center",
        }}
      >
        <div
          className="flex items-center justify-between gap-2 border-b px-3 py-2"
          style={{ borderColor: dividerColor }}
        >
          <div
            className="flex flex-1 cursor-grab items-center gap-2 select-none active:cursor-grabbing"
            onPointerDown={onDragStart}
            onPointerMove={onDragMove}
            onPointerUp={onDragEnd}
            onPointerCancel={onDragEnd}
            data-testid="alert-drag-handle"
            aria-label={t("executiveMeetings.alert.dragHandle")}
            // #441: On touch devices (iPad/Safari) the browser's
            // default touch-action treats a finger drag on the handle
            // as a candidate scroll gesture and swallows the
            // pointermove events before our handler can run, causing
            // the alert to stutter or refuse to follow the finger.
            // `touch-action: none` tells the browser we're handling
            // the gesture ourselves so it delivers every move.
            style={{ touchAction: "none" }}
          >
            {/* #438: pulsing accent halo around the drag handle to
                match the incoming-note popup's avatar ping. Inline
                background uses the user's accent so it tracks any
                preset. */}
            <span
              className="relative inline-flex h-4 w-4 shrink-0 items-center justify-center"
              // #441: the ping halo overlays the GripVertical icon, so
              // without `pointer-events: none` it can intercept the
              // finger touch on iPad and prevent the drag from
              // starting on the handle below it.
              style={{ pointerEvents: "none" }}
            >
              <span
                className="absolute inset-0 rounded-full animate-ping"
                style={{ backgroundColor: hexToRgba(alertPrefs.accent, 0.6) }}
                aria-hidden="true"
              />
              <GripVertical
                className="relative h-4 w-4 opacity-80"
                aria-hidden="true"
              />
            </span>
            <span className="text-sm font-semibold">
              {t("executiveMeetings.alert.title")}
            </span>
          </div>
          <button
            type="button"
            onClick={handleDismiss}
            disabled={savingAction !== null}
            aria-label={t("executiveMeetings.alert.dismiss")}
            data-testid="alert-close"
            className="rounded p-1 transition-colors"
            // Hover background derived from the user's accent so it
            // works for any preset (light/dark, warm/cool).
            onMouseEnter={(e) => {
              e.currentTarget.style.backgroundColor = closeHoverBg;
            }}
            onMouseLeave={(e) => {
              e.currentTarget.style.backgroundColor = "transparent";
            }}
          >
            <X className="h-4 w-4" aria-hidden="true" />
          </button>
        </div>
        <div className="px-3 py-3">
          <div
            className="text-sm font-medium"
            data-testid="alert-meeting-title"
            // titles may contain sanitized inline rich-text from the
            // schedule editor; show as plain text only here.
          >
            {title.replace(/<[^>]+>/g, "").trim() || title}
          </div>
          <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
            <span
              className="em-blink font-semibold text-red-600"
              data-testid="alert-countdown"
            >
              {headline}
            </span>
            {meeting.startTime ? (
              <span
                className="inline-flex items-center gap-1 opacity-80"
                data-testid="alert-time-window"
                // The time window is always rendered LTR (start on the
                // left, end on the right) even inside the Arabic UI —
                // requested explicitly by the user (#344). The clock
                // icon precedes the digits in source order; with
                // dir="ltr" that puts it visually on the left.
                dir="ltr"
              >
                <Clock className="h-3 w-3" aria-hidden="true" />
                {meeting.endTime
                  ? `${formatTime(meeting.startTime, i18n.language)} – ${formatTime(meeting.endTime, i18n.language)}`
                  : t("executiveMeetings.alert.atTime", {
                      time: formatTime(meeting.startTime, i18n.language),
                    })}
              </span>
            ) : null}
            {meeting.location ? (
              <span className="inline-flex items-center gap-1 opacity-80">
                <Calendar className="h-3 w-3" aria-hidden="true" />
                {meeting.location}
              </span>
            ) : null}
          </div>
        </div>
        {expanded ? (
          <DetailsPanel
            meeting={meeting}
            isRtl={isRtl}
            t={t}
            lang={i18n.language}
            dividerColor={dividerColor}
            sectionBg={detailsBg}
          />
        ) : null}
        <div
          className="flex items-center gap-2 border-t px-3 py-2"
          style={{ borderColor: dividerColor }}
        >
          <Button
            type="button"
            size="sm"
            onClick={handleDone}
            disabled={savingAction !== null}
            data-testid="alert-done"
          >
            {t("executiveMeetings.alert.done")}
          </Button>
          {caps?.canMutate ? (
            <Button
              type="button"
              size="sm"
              variant="outline"
              onClick={() => setPostponeOpen(true)}
              // #635: external meetings can't be auto-postponed (the
              // server also rejects with external_meeting_no_auto_postpone).
              disabled={savingAction !== null || meeting.isExternal === true}
              title={
                meeting.isExternal
                  ? t("executiveMeetings.alert.externalCannotPostpone")
                  : undefined
              }
              data-testid="alert-postpone"
            >
              {t("executiveMeetings.alert.postpone")}
            </Button>
          ) : null}
          {/*
            #344: The labelled "Dismiss alert" button was removed at
            the user's request — the X close button in the header
            (which still calls handleDismiss) is the single way to
            hide the popup, keeping the action bar focused on
            "Done" / "Postpone" / "Details".
          */}
          <Button
            type="button"
            size="sm"
            variant="ghost"
            className="ms-auto"
            onClick={() => setExpanded((v) => !v)}
            disabled={savingAction !== null}
            data-testid="alert-details-toggle"
            aria-expanded={expanded}
            aria-controls="alert-details-panel"
          >
            {expanded ? (
              <>
                <ChevronUp className="me-1 h-3.5 w-3.5" aria-hidden="true" />
                {t("executiveMeetings.alert.detailsClose")}
              </>
            ) : (
              <>
                <ChevronDown
                  className="me-1 h-3.5 w-3.5"
                  aria-hidden="true"
                />
                {t("executiveMeetings.alert.details")}
              </>
            )}
          </Button>
        </div>
      </div>
      {caps?.canMutate ? (
        <PostponeDialog
          open={postponeOpen}
          onOpenChange={setPostponeOpen}
          meeting={meeting}
          isRtl={isRtl}
          meetingNumbersById={meetingNumbersById}
          accent={alertPrefs.accent}
          onSaved={async () => {
            setPostponeOpen(false);
            // Invalidate today and the alert-state cache so the alert
            // disappears (or re-appears) immediately based on the new
            // status / time window.
            await Promise.all([
              queryClient.invalidateQueries({
                queryKey: ["/api/executive-meetings"],
              }),
              refetchAlertState(),
            ]);
          }}
        />
      ) : null}
    </>
  );
}

// ---------- Postpone sub-modal ----------

// #302: cascade-prompt shapes used by both the postpone-minutes and
// reschedule flows. Hoisted to module scope so the small
// `CascadePromptBlock` helper component can be defined alongside the
// dialog without re-declaring them inside the function body.
type CascadeFollower = {
  id: number;
  titleAr: string;
  titleEn: string;
  oldStart: string;
  oldEnd: string;
  newStart: string;
  newEnd: string;
};
type CascadeBlocked = {
  id: number;
  titleAr: string;
  titleEn: string;
  projectedStart: string;
  projectedEnd: string;
};
type CascadePrompt =
  | {
      kind: "postpone";
      delta: number;
      loading: boolean;
      followers: CascadeFollower[];
      blockedBy: CascadeBlocked | null;
    }
  | {
      kind: "reschedule";
      delta: number;
      // Snapshot of the form values at the moment of the click so the
      // submit uses the same draft the preview was computed against,
      // even if the user later changes the inputs underneath.
      draft: {
        meetingDate: string;
        startTime: string;
        endTime: string;
        note: string;
      };
      loading: boolean;
      followers: CascadeFollower[];
      blockedBy: CascadeBlocked | null;
    };

function CascadePromptBlock({
  prompt,
  isRtl,
  busy,
  meetingNumbersById,
  accent,
  lang,
  onShiftFollowing,
  onKeepTimes,
  onBack,
}: {
  prompt: CascadePrompt;
  isRtl: boolean;
  busy: boolean;
  // #480: maps meeting id → dailyNumber so the affected-meetings table
  // can show the user-facing meeting number alongside title/times.
  // Built by the parent from already-loaded dayData.meetings; missing
  // ids fall back to "—" rather than blocking the prompt.
  meetingNumbersById: Map<number, number>;
  // #481: accent hex from the user's alert color theme, used to derive
  // the prompt's background + border via hexToRgba so the cascade block
  // matches the rest of the alert instead of hard-coded amber.
  accent: string;
  // #481: active i18n language so times render localized 12-hour with
  // ص/م (AR) or AM/PM (EN).
  lang: string;
  onShiftFollowing: () => void;
  onKeepTimes: () => void;
  onBack: () => void;
}) {
  const { t } = useTranslation();
  const promptBg = hexToRgba(accent, 0.12);
  const promptBorder = hexToRgba(accent, 0.45);
  // While the preview is in flight, we still render the block (so the
  // user sees something happen after they click "Yes") but with empty
  // content + a loading hint. The buttons are disabled until either
  // followers or blockedBy land.
  if (prompt.loading) {
    return (
      <div
        className="flex flex-col gap-2 rounded border p-3"
        style={{ backgroundColor: promptBg, borderColor: promptBorder }}
        data-testid="cascade-prompt-loading"
      >
        <p className="text-sm font-medium">
          {t("executiveMeetings.alert.cascadeLoading")}
        </p>
      </div>
    );
  }

  const titleOf = (m: { titleAr: string; titleEn: string }) => {
    const raw = isRtl
      ? m.titleAr || m.titleEn
      : m.titleEn || m.titleAr;
    return raw.replace(/<[^>]+>/g, "").trim() || raw;
  };

  if (prompt.blockedBy) {
    // Midnight rejection: server (or preview) refused because shifting
    // a follower would push it past 24:00. Offer "Keep their times"
    // (apply the primary only) and "Back" (return to chips/form). The
    // offending meeting is named verbatim so the user knows which one
    // is the blocker without re-opening the schedule.
    return (
      <div
        className="flex flex-col gap-2 rounded border border-rose-300 bg-rose-50 p-3 dark:border-rose-700 dark:bg-rose-950"
        data-testid="cascade-prompt-blocked"
      >
        <p className="text-sm font-medium">
          {t("executiveMeetings.alert.cascadeMidnightError", {
            title: titleOf(prompt.blockedBy),
            projectedStart: formatTime(prompt.blockedBy.projectedStart, lang),
          })}
        </p>
        <div className="flex flex-wrap items-center gap-2">
          <Button
            type="button"
            size="sm"
            onClick={onKeepTimes}
            disabled={busy}
            data-testid="cascade-keep-times"
          >
            {t("executiveMeetings.alert.cascadeKeep")}
          </Button>
          <Button
            type="button"
            size="sm"
            variant="outline"
            onClick={onBack}
            disabled={busy}
            data-testid="cascade-back"
          >
            {t("executiveMeetings.alert.back")}
          </Button>
        </div>
      </div>
    );
  }

  return (
    <div
      className="flex flex-col gap-2 rounded border p-3"
      style={{ backgroundColor: promptBg, borderColor: promptBorder }}
      data-testid="cascade-prompt"
    >
      <p className="text-sm font-medium">
        {t("executiveMeetings.alert.cascadePromptHeader", {
          count: prompt.followers.length,
          minutes: prompt.delta,
          minutesText: t("executiveMeetings.alert.cascadeMinutesUnit", {
            count: prompt.delta,
          }),
        })}
      </p>
      <p className="text-xs text-muted-foreground">
        {t("executiveMeetings.alert.cascadeListTitle")}
      </p>
      {/* #481: 3-column table (Meeting #, Title, From → To) so the user
          can scan affected meetings by their meeting-of-the-day number
          alongside the title and the projected time shift. */}
      <div className="max-h-32 overflow-y-auto overflow-x-hidden rounded bg-white/60 px-2 py-1 dark:bg-black/30">
        <table
          className="w-full table-fixed border-collapse text-xs"
          data-testid="cascade-followers-list"
        >
          {/* #485: explicit colgroup so the meeting-# header doesn't
              wrap to two lines, the title column truncates with an
              ellipsis instead of pushing the times off-screen, and
              the From → To column always fits on one line. */}
          <colgroup>
            {/* w-20 leaves room for the Arabic header "رقم الاجتماع"
                with whitespace-nowrap; w-12 was too tight at common
                font sizes. */}
            <col className="w-20" />
            <col />
            <col className="w-40" />
          </colgroup>
          <thead className="text-muted-foreground">
            <tr>
              <th scope="col" className="px-1 py-1 text-start font-medium tabular-nums whitespace-nowrap">
                {t("executiveMeetings.alert.cascadeColMeetingNumber")}
              </th>
              <th scope="col" className="px-1 py-1 text-start font-medium">
                {t("executiveMeetings.alert.cascadeColTitle")}
              </th>
              <th scope="col" className="px-1 py-1 text-end font-medium tabular-nums whitespace-nowrap">
                {t("executiveMeetings.alert.cascadeColTimes")}
              </th>
            </tr>
          </thead>
          <tbody>
            {prompt.followers.map((f) => {
              const dn = meetingNumbersById.get(f.id);
              const title = titleOf(f);
              return (
                <tr
                  key={f.id}
                  className="border-t border-black/5 dark:border-white/10"
                  data-testid={`cascade-follower-${f.id}`}
                >
                  <td className="px-1 py-0.5 tabular-nums">
                    {dn != null ? dn : "—"}
                  </td>
                  <td className="px-1 py-0.5 truncate" title={title}>
                    {title}
                  </td>
                  <td className="px-1 py-0.5 text-end tabular-nums whitespace-nowrap">
                    {formatTime(f.oldStart, lang)} {isRtl ? "←" : "→"} {formatTime(f.newStart, lang)}
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
      <div className="flex flex-wrap items-center gap-2">
        <Button
          type="button"
          size="sm"
          onClick={onShiftFollowing}
          disabled={busy}
          data-testid="cascade-shift-following"
        >
          {t("executiveMeetings.alert.cascadeShift", {
            count: prompt.followers.length,
          })}
        </Button>
        <Button
          type="button"
          size="sm"
          variant="outline"
          onClick={onKeepTimes}
          disabled={busy}
          data-testid="cascade-keep-times"
        >
          {t("executiveMeetings.alert.cascadeKeep")}
        </Button>
        <Button
          type="button"
          size="sm"
          variant="ghost"
          onClick={onBack}
          disabled={busy}
          data-testid="cascade-back"
        >
          {t("executiveMeetings.alert.back")}
        </Button>
      </div>
    </div>
  );
}

type PostponeDialogProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  meeting: Meeting;
  isRtl: boolean;
  // #480: id → dailyNumber lookup for the cascade-affected-meetings
  // table. Provided by the parent so we don't refetch today's meetings
  // inside the dialog.
  meetingNumbersById: Map<number, number>;
  // #481: accent hex from the parent's alert color theme; threaded
  // into CascadePromptBlock so the cascade box matches the alert's
  // chosen palette instead of hard-coded amber.
  accent: string;
  onSaved: () => void | Promise<void>;
};

type PostponeTab = "minutes" | "reschedule" | "cancel";

// #486: Exported so the Executive Meetings schedule page can mount the
// same dialog from its row quick-actions popover. The Meeting type
// (also exported as PostponeDialogMeeting) is the structural minimum
// the dialog reads from — any caller passing a row with these fields
// satisfies it.
export type PostponeDialogMeeting = Meeting;
export { PostponeDialog };
function PostponeDialog({
  open,
  onOpenChange,
  meeting,
  isRtl,
  meetingNumbersById,
  accent,
  onSaved,
}: PostponeDialogProps) {
  const { t, i18n } = useTranslation();
  const { toast } = useToast();
  const [minutes, setMinutes] = useState<string>("10");
  const [resDate, setResDate] = useState<string>(meeting.meetingDate);
  const [resStart, setResStart] = useState<string>(
    meeting.startTime ? meeting.startTime.slice(0, 5) : "",
  );
  const [resEnd, setResEnd] = useState<string>(
    meeting.endTime ? meeting.endTime.slice(0, 5) : "",
  );
  const [resNote, setResNote] = useState<string>("");
  const [busy, setBusy] = useState<string | null>(null);
  const [confirmCancel, setConfirmCancel] = useState<boolean>(false);
  // #275: tabs replace the three stacked sections — only the active
  // workflow is rendered, keeping the dialog compact (~520px tall).
  const [activeTab, setActiveTab] = useState<PostponeTab>("minutes");
  // #275: pendingMinutes holds the chip/manual value the user picked
  // before the API call fires. While it's non-null, the dialog shows
  // an "Are you sure? — Confirm / Back" prompt instead of executing.
  const [pendingMinutes, setPendingMinutes] = useState<number | null>(null);
  // #283: When the server returns 409 stale_meeting (someone else
  // postponed/rescheduled this meeting between load and click), we
  // capture the conflict payload here and render an explicit
  // "Already postponed by X — apply N more minutes anyway?" prompt
  // instead of silently double-shifting the meeting. `latestUpdatedAt`
  // is the freshest server-observed token; the apply-anyway retry
  // resubmits with it so a *third* concurrent mutation between
  // conflict-display and click triggers another 409 instead of a
  // silent stack.
  const [staleConflict, setStaleConflict] = useState<{
    minutes: number;
    actorName: string | null;
    currentStart: string | null;
    currentEnd: string | null;
    latestUpdatedAt: string;
  } | null>(null);
  // #302: cascade-prompt state. Once the user has confirmed the
  // delta (or filled in the reschedule form and clicked Apply), we
  // run cascade-preview. If there are no later active meetings we
  // auto-fall-through to the single-meeting submit. Otherwise we
  // render a "Shift following meetings (N) / Keep their times / Back"
  // prompt. `blockedBy` is set when either the preview *or* the
  // actual submit returns 400 cascade_crosses_midnight, in which
  // case the prompt collapses to "Back / Keep their times".
  const [cascadePrompt, setCascadePrompt] = useState<CascadePrompt | null>(
    null,
  );

  // Reset local form state whenever the dialog opens against a new meeting
  // so stale values don't leak between alerts.
  useEffect(() => {
    if (!open) return;
    setMinutes("10");
    setResDate(meeting.meetingDate);
    setResStart(meeting.startTime ? meeting.startTime.slice(0, 5) : "");
    setResEnd(meeting.endTime ? meeting.endTime.slice(0, 5) : "");
    setResNote("");
    setBusy(null);
    setConfirmCancel(false);
    setActiveTab("minutes");
    setPendingMinutes(null);
    setStaleConflict(null);
    setCascadePrompt(null);
  }, [open, meeting.id, meeting.meetingDate, meeting.startTime, meeting.endTime]);

  const title = isRtl
    ? meeting.titleAr || meeting.titleEn
    : meeting.titleEn || meeting.titleAr;
  const titleText = title.replace(/<[^>]+>/g, "").trim() || title;

  const handleResult = (
    res: { conflicts?: boolean } | undefined,
    successKey: string,
  ) => {
    toast({ title: t(successKey) });
    if (res?.conflicts) {
      // The mutation already succeeded — the conflict notice is purely
      // informational ("you may now have overlapping meetings"), so use
      // the default (non-destructive) toast variant rather than the red
      // "destructive" style that implies the action failed.
      toast({
        title: t("executiveMeetings.alert.conflictWarning"),
      });
    }
  };
  const handleErr = (err: unknown) => {
    // #635: map the external-meeting 409 to the localized reason so
    // schedule quick-action callers don't show the raw English error
    // from the server.
    const code =
      err && typeof err === "object" && "code" in err
        ? (err as { code?: unknown }).code
        : undefined;
    if (code === "external_meeting_no_auto_postpone") {
      toast({
        title: t("executiveMeetings.alert.externalCannotPostpone"),
        variant: "destructive",
      });
      return;
    }
    toast({
      title: t("executiveMeetings.alert.saveFailed"),
      description: err instanceof Error ? err.message : String(err),
      variant: "destructive",
    });
  };

  // #273: shared mutation used by both the manual "Apply" button and
  // each minute chip click so the chips can shift start/end immediately.
  // #283: every call sends `expectedUpdatedAt`. The first attempt uses
  // the token from the loaded meeting; the "apply anyway" retry passes
  // the freshly-seen token from the conflict payload so a third
  // concurrent mutation between conflict-display and click will trigger
  // another 409 instead of silently stacking.
  const postponeBy = async (
    delta: number,
    expectedUpdatedAt?: string,
    // #302: cascade later same-day meetings by the same delta.
    cascadeFollowing: boolean = false,
  ) => {
    if (!Number.isFinite(delta) || delta <= 0) return;
    const token = expectedUpdatedAt ?? meeting.updatedAt;
    if (!token) {
      // Defensive: should never happen — meeting payload always includes
      // updatedAt. If it ever is missing, fail loudly rather than send
      // a request the server will reject as malformed.
      toast({
        title: t("executiveMeetings.alert.saveFailed"),
        description: "Missing updatedAt token",
        variant: "destructive",
      });
      return;
    }
    setBusy("minutes");
    try {
      const res = await apiJson<{ conflicts?: boolean; cascadedIds?: number[] }>(
        `/api/executive-meetings/${meeting.id}/postpone-minutes`,
        {
          method: "POST",
          body: JSON.stringify({
            minutes: Math.floor(delta),
            expectedUpdatedAt: token,
            cascadeFollowing,
          }),
        },
      );
      handleResult(res, "executiveMeetings.alert.saved");
      setStaleConflict(null);
      setCascadePrompt(null);
      await onSaved();
    } catch (err) {
      // #302: cascade_crosses_midnight → re-render the cascade prompt
      // in the blocked state so the user can choose "Keep their times"
      // (apply primary only) or back out. Don't toast — the prompt
      // itself surfaces the offending meeting.
      if (
        err instanceof ApiError &&
        err.code === "cascade_crosses_midnight"
      ) {
        const blockedBy = (err.body as { blockedBy?: CascadeBlocked })
          .blockedBy;
        if (blockedBy) {
          setCascadePrompt({
            kind: "postpone",
            delta: Math.floor(delta),
            loading: false,
            followers: [],
            blockedBy,
          });
          return;
        }
      }
      // #283: 409 stale_meeting → don't error-toast; capture the
      // conflict so the dialog can show the "already postponed by X"
      // prompt with the option to apply N minutes anyway. We carry
      // `lastModifiedAt` forward so the retry uses the freshly-seen
      // token, not the original one.
      if (err instanceof ApiError && err.code === "stale_meeting") {
        const conflict = (err.body as {
          conflict?: {
            currentStartTime: string | null;
            currentEndTime: string | null;
            lastModifiedAt: string;
            lastActor: {
              displayNameAr: string | null;
              displayNameEn: string | null;
              username: string | null;
            } | null;
          };
        }).conflict;
        if (!conflict) {
          handleErr(err);
          return;
        }
        const actor = conflict.lastActor ?? null;
        const actorName = actor
          ? (isRtl
              ? actor.displayNameAr || actor.displayNameEn || actor.username
              : actor.displayNameEn || actor.displayNameAr || actor.username)
          : null;
        setStaleConflict({
          minutes: Math.floor(delta),
          actorName: actorName ?? null,
          currentStart: conflict.currentStartTime ?? null,
          currentEnd: conflict.currentEndTime ?? null,
          latestUpdatedAt: conflict.lastModifiedAt,
        });
        // Clear the "are you sure?" prompt — we're replacing it with
        // the conflict-aware prompt rendered below.
        setPendingMinutes(null);
      } else {
        handleErr(err);
      }
    } finally {
      setBusy(null);
    }
  };
  // #275: route both chip clicks and the manual "Apply" through the
  // pending-confirm gate so the user always sees an explicit
  // "Are you sure?" before the meeting actually shifts.
  const requestPostpone = (n: number) => {
    if (!Number.isFinite(n) || n <= 0) return;
    setPendingMinutes(Math.floor(n));
  };
  const onPostponeMinutes = () => requestPostpone(Number(minutes));

  // #302: shared helper — POST cascade-preview, then either
  // auto-fall-through to the single-meeting submit (no followers) or
  // surface the cascade prompt. Used by both the postpone-minutes
  // confirm step and the reschedule Apply step.
  const runCascadePreview = async (args:
    | { kind: "postpone"; delta: number }
    | {
        kind: "reschedule";
        delta: number;
        draft: { meetingDate: string; startTime: string; endTime: string; note: string };
      }
  ) => {
    // #302: in-flight guard. If a preview (or its auto-fall-through
    // submit) is already running, ignore the new trigger so we can't
    // duplicate the resulting postpone/reschedule mutation. Both the
    // confirm button and Apply button gate on `cascadePrompt` and
    // `busy` to disable themselves, but a fast double-click could
    // still slip through React's render — this is the belt-and-braces
    // server-call guard.
    if (cascadePrompt?.loading || busy) return;
    setCascadePrompt({
      ...args,
      loading: true,
      followers: [],
      blockedBy: null,
    } as CascadePrompt);
    try {
      const res = await apiJson<{
        followers: CascadeFollower[];
        blockedBy: CascadeBlocked | null;
      }>(`/api/executive-meetings/${meeting.id}/cascade-preview`, {
        method: "POST",
        body: JSON.stringify({ deltaMinutes: args.delta }),
      });
      // No followers AND not blocked → silently fall through to the
      // single-meeting submit. The user never sees a cascade prompt
      // when there's nothing to cascade.
      if (res.followers.length === 0 && !res.blockedBy) {
        setCascadePrompt(null);
        if (args.kind === "postpone") {
          await postponeBy(args.delta, undefined, false);
        } else {
          await rescheduleSubmit(args.draft, false);
        }
        return;
      }
      setCascadePrompt({
        ...args,
        loading: false,
        followers: res.followers,
        blockedBy: res.blockedBy,
      } as CascadePrompt);
    } catch (err) {
      // Preview failures are non-recoverable from the UI — fall back
      // to a toast and clear the prompt so the user can retry.
      setCascadePrompt(null);
      handleErr(err);
    }
  };

  // #302: extracted reschedule submit so the cascade-prompt path can
  // resubmit with the snapshotted draft + an explicit cascade flag.
  // Original `onReschedule` (kept below) now defers to the cascade
  // preview when same-day forward-shift is detected.
  const rescheduleSubmit = async (
    draft: { meetingDate: string; startTime: string; endTime: string; note: string },
    cascadeFollowing: boolean,
  ) => {
    if (!draft.meetingDate || !draft.startTime || !draft.endTime) return;
    setBusy("reschedule");
    try {
      const res = await apiJson<{ conflicts?: boolean; cascadedIds?: number[] }>(
        `/api/executive-meetings/${meeting.id}/reschedule`,
        {
          method: "POST",
          body: JSON.stringify({
            meetingDate: draft.meetingDate,
            startTime: `${draft.startTime}:00`,
            endTime: `${draft.endTime}:00`,
            note: draft.note || undefined,
            cascadeFollowing,
          }),
        },
      );
      handleResult(res, "executiveMeetings.alert.saved");
      setCascadePrompt(null);
      await onSaved();
    } catch (err) {
      // #302: same blocked-rendering treatment as postpone.
      if (
        err instanceof ApiError &&
        err.code === "cascade_crosses_midnight"
      ) {
        const blockedBy = (err.body as { blockedBy?: CascadeBlocked })
          .blockedBy;
        if (blockedBy) {
          // Compute delta for display continuity with the prompt.
          const oldStartMin = meeting.startTime
            ? Number(meeting.startTime.slice(0, 2)) * 60 +
              Number(meeting.startTime.slice(3, 5))
            : null;
          const newStartMin =
            Number(draft.startTime.slice(0, 2)) * 60 +
            Number(draft.startTime.slice(3, 5));
          const delta =
            oldStartMin != null ? newStartMin - oldStartMin : 0;
          setCascadePrompt({
            kind: "reschedule",
            delta,
            draft,
            loading: false,
            followers: [],
            blockedBy,
          });
          return;
        }
      }
      handleErr(err);
    } finally {
      setBusy(null);
    }
  };

  const onReschedule = async () => {
    if (!resDate || !resStart || !resEnd) return;
    const draft = {
      meetingDate: resDate,
      startTime: resStart,
      endTime: resEnd,
      note: resNote,
    };
    // #302: only run the cascade-preview gate when (a) the meeting
    // stays on the same date AND (b) the new start is later than the
    // old one — otherwise the cascade flag would be a no-op server-
    // side and the prompt would be a confusing no-op too.
    const oldStart = meeting.startTime ? meeting.startTime.slice(0, 5) : "";
    const sameDate = draft.meetingDate === meeting.meetingDate;
    const oldStartMin =
      oldStart.length >= 5
        ? Number(oldStart.slice(0, 2)) * 60 + Number(oldStart.slice(3, 5))
        : null;
    const newStartMin =
      Number(draft.startTime.slice(0, 2)) * 60 +
      Number(draft.startTime.slice(3, 5));
    const delta = oldStartMin != null ? newStartMin - oldStartMin : 0;
    if (sameDate && oldStartMin != null && delta > 0) {
      await runCascadePreview({ kind: "reschedule", delta, draft });
      return;
    }
    await rescheduleSubmit(draft, false);
  };
  const onCancelMeeting = async () => {
    setBusy("cancel");
    try {
      await apiJson(`/api/executive-meetings/${meeting.id}/cancel`, {
        method: "POST",
        body: JSON.stringify({}),
      });
      toast({ title: t("executiveMeetings.alert.saved") });
      await onSaved();
    } catch (err) {
      handleErr(err);
    } finally {
      setBusy(null);
    }
  };

  const noTimeWindow = !meeting.startTime || !meeting.endTime;

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent
        dir={isRtl ? "rtl" : "ltr"}
        data-testid="postpone-dialog"
        className="max-w-[min(100%,560px)] sm:max-w-xl md:max-w-2xl bg-white rounded-2xl sm:rounded-3xl shadow-2xl ring-1 ring-slate-200 border-0 p-5 sm:p-6 gap-3"
      >
        {/* #563: reserve pr-8 (~2rem) so the Radix close button
            (positioned right-4 top-4 inside DialogContent) never
            overlaps the title/description. Physical right padding is
            correct for BOTH AR (RTL) and LTR because Radix uses CSS
            physical positioning for the close. */}
        <DialogHeader className="space-y-1 pr-8 pb-3 border-b border-slate-200">
          <DialogTitle className="text-lg sm:text-xl text-[#0B1E3F]">
            {t("executiveMeetings.alert.postponeTitle")}
          </DialogTitle>
          <DialogDescription className="truncate text-xs sm:text-sm text-slate-600">
            {t("executiveMeetings.alert.postponeIntro", { title: titleText })}
          </DialogDescription>
        </DialogHeader>

        {/* #275: segmented tab strip with full ARIA tab semantics —
            tablist + linked tabpanels, roving tabindex, and arrow-key
            navigation that respects RTL (Left/Right swap directions). */}
        {(() => {
          const tabs = [
            { key: "minutes" as const, labelKey: "tabPostpone" },
            { key: "reschedule" as const, labelKey: "tabReschedule" },
            { key: "cancel" as const, labelKey: "tabCancel" },
          ];
          const switchTab = (next: PostponeTab) => {
            setActiveTab(next);
            setPendingMinutes(null);
            setConfirmCancel(false);
            // Move focus to the newly active tab so screen-reader and
            // keyboard users land where the panel is now anchored.
            window.requestAnimationFrame(() => {
              document
                .querySelector<HTMLButtonElement>(
                  `[data-testid="postpone-tab-${next}"]`,
                )
                ?.focus();
            });
          };
          const onTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
            const idx = tabs.findIndex((tab) => tab.key === activeTab);
            if (idx < 0) return;
            // In RTL, ArrowLeft/ArrowRight visually swap meaning.
            const forward = isRtl ? "ArrowLeft" : "ArrowRight";
            const back = isRtl ? "ArrowRight" : "ArrowLeft";
            let nextIdx: number | null = null;
            if (e.key === forward) nextIdx = (idx + 1) % tabs.length;
            else if (e.key === back)
              nextIdx = (idx - 1 + tabs.length) % tabs.length;
            else if (e.key === "Home") nextIdx = 0;
            else if (e.key === "End") nextIdx = tabs.length - 1;
            if (nextIdx == null) return;
            e.preventDefault();
            switchTab(tabs[nextIdx].key);
          };
          return (
            <div
              role="tablist"
              aria-label={t("executiveMeetings.alert.postponeTitle")}
              aria-orientation="horizontal"
              onKeyDown={onTabKeyDown}
              className="grid grid-cols-3 rounded-xl border border-slate-200 bg-slate-100 p-1 text-xs sm:text-sm"
            >
              {tabs.map((tab) => {
                const selected = activeTab === tab.key;
                return (
                  <button
                    key={tab.key}
                    type="button"
                    role="tab"
                    id={`postpone-tab-${tab.key}`}
                    aria-controls={`postpone-panel-${tab.key}`}
                    aria-selected={selected}
                    // Roving tabindex: only the active tab is in the tab
                    // sequence; arrow keys move between the rest.
                    tabIndex={selected ? 0 : -1}
                    disabled={busy !== null}
                    onClick={() => switchTab(tab.key)}
                    data-testid={`postpone-tab-${tab.key}`}
                    className={`rounded-lg px-3 py-2 font-semibold transition-colors ${
                      selected
                        ? "bg-white text-[#0B1E3F] shadow ring-1 ring-slate-200"
                        : "text-slate-600 hover:text-[#0B1E3F]"
                    }`}
                  >
                    {t(`executiveMeetings.alert.${tab.labelKey}`)}
                  </button>
                );
              })}
            </div>
          );
        })()}

        {activeTab === "minutes" ? (
          <div
            role="tabpanel"
            id="postpone-panel-minutes"
            aria-labelledby="postpone-tab-minutes"
            tabIndex={0}
            className="space-y-2 focus:outline-none"
            data-testid="postpone-minutes-panel"
          >
            {noTimeWindow ? (
              <p className="text-xs text-muted-foreground">
                {t("executiveMeetings.alert.noTimeWindow")}
              </p>
            ) : null}
            {cascadePrompt && cascadePrompt.kind === "postpone" ? (
              <CascadePromptBlock
                prompt={cascadePrompt}
                isRtl={isRtl}
                busy={busy !== null}
                meetingNumbersById={meetingNumbersById}
                accent={accent}
                lang={i18n.language}
                onShiftFollowing={() =>
                  void postponeBy(cascadePrompt.delta, undefined, true)
                }
                onKeepTimes={() =>
                  void postponeBy(cascadePrompt.delta, undefined, false)
                }
                onBack={() => setCascadePrompt(null)}
              />
            ) : staleConflict ? (
              <div
                className="flex flex-col gap-2 rounded border border-rose-300 bg-rose-50 p-3 dark:border-rose-700 dark:bg-rose-950"
                data-testid="postpone-stale-block"
              >
                <p className="text-sm font-medium">
                  {staleConflict.actorName
                    ? t("executiveMeetings.alert.staleMeetingByUser", {
                        name: staleConflict.actorName,
                      })
                    : t("executiveMeetings.alert.staleMeetingTitle")}
                </p>
                {staleConflict.currentStart ? (
                  <p className="text-xs text-muted-foreground">
                    {t("executiveMeetings.alert.staleMeetingCurrentTime", {
                      start: formatTime(staleConflict.currentStart, i18n.language),
                      end: staleConflict.currentEnd
                        ? formatTime(staleConflict.currentEnd, i18n.language)
                        : "—",
                    })}
                  </p>
                ) : null}
                <div className="flex items-center gap-2">
                  <Button
                    type="button"
                    size="sm"
                    variant="destructive"
                    onClick={() => {
                      const n = staleConflict.minutes;
                      const fresh = staleConflict.latestUpdatedAt;
                      setStaleConflict(null);
                      // #283: pass the freshly-seen token; if the
                      // meeting was changed *again* between conflict
                      // display and click, the server will return 409
                      // a second time so the user re-confirms against
                      // the newer state.
                      void postponeBy(n, fresh);
                    }}
                    disabled={busy !== null}
                    data-testid="postpone-stale-apply-anyway"
                  >
                    {t("executiveMeetings.alert.staleMeetingApplyAnyway", {
                      count: staleConflict.minutes,
                    })}
                  </Button>
                  <Button
                    type="button"
                    size="sm"
                    variant="outline"
                    onClick={() => setStaleConflict(null)}
                    disabled={busy !== null}
                    data-testid="postpone-stale-cancel"
                  >
                    {t("executiveMeetings.alert.back")}
                  </Button>
                </div>
              </div>
            ) : pendingMinutes == null ? (
              <>
                <div className="flex flex-wrap items-center gap-1">
                  {[5, 10, 15, 30, 45, 60].map((n) => (
                    <Button
                      key={n}
                      type="button"
                      size="sm"
                      variant={Number(minutes) === n ? "default" : "outline"}
                      disabled={noTimeWindow || busy !== null}
                      // #275: chip click selects the value and arms the
                      // confirm step — the actual API call happens only
                      // after the user clicks "Yes, postpone N min".
                      onClick={() => {
                        setMinutes(String(n));
                        requestPostpone(n);
                      }}
                      data-testid={`postpone-chip-${n}`}
                      className="h-10 min-w-11 px-3 text-sm rounded-xl border-2"
                    >
                      {n}
                    </Button>
                  ))}
                </div>
                <div className="flex items-center gap-2">
                  <Input
                    type="number"
                    min={1}
                    max={1440}
                    value={minutes}
                    onChange={(e) => setMinutes(e.target.value)}
                    placeholder={t(
                      "executiveMeetings.alert.postponeMinutesPlaceholder",
                    )}
                    disabled={noTimeWindow || busy !== null}
                    data-testid="postpone-minutes-input"
                    className="w-24 min-h-11 bg-white border-2 border-slate-300 rounded-xl text-base"
                  />
                  <Button
                    type="button"
                    size="sm"
                    onClick={onPostponeMinutes}
                    disabled={
                      noTimeWindow ||
                      busy !== null ||
                      !Number.isFinite(Number(minutes)) ||
                      Number(minutes) <= 0
                    }
                    data-testid="postpone-minutes-apply"
                    className="min-h-11 rounded-xl px-4"
                  >
                    {t("executiveMeetings.alert.postponeMinutesApply", {
                      count: Number(minutes) || 0,
                    })}
                  </Button>
                </div>
              </>
            ) : (
              <div
                className="flex flex-col gap-2 rounded border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-950"
                data-testid="postpone-confirm-block"
              >
                <p className="text-sm font-medium">
                  {t("executiveMeetings.alert.postponeConfirmPrompt", {
                    count: pendingMinutes,
                  })}
                </p>
                <div className="flex items-center gap-2">
                  <Button
                    type="button"
                    size="sm"
                    onClick={() => {
                      const n = pendingMinutes;
                      setPendingMinutes(null);
                      // #302: gate the actual submit on the cascade
                      // preview so we can show "also shift the next
                      // meetings?" when it applies. The helper falls
                      // through to plain `postponeBy` automatically
                      // when there are no followers.
                      void runCascadePreview({ kind: "postpone", delta: n });
                    }}
                    disabled={busy !== null}
                    data-testid="postpone-confirm-yes"
                  >
                    {t("executiveMeetings.alert.postponeConfirmYes", {
                      count: pendingMinutes,
                    })}
                  </Button>
                  <Button
                    type="button"
                    size="sm"
                    variant="outline"
                    onClick={() => setPendingMinutes(null)}
                    disabled={busy !== null}
                    data-testid="postpone-confirm-back"
                  >
                    {t("executiveMeetings.alert.back")}
                  </Button>
                </div>
              </div>
            )}
          </div>
        ) : null}

        {activeTab === "reschedule" ? (
          <div
            role="tabpanel"
            id="postpone-panel-reschedule"
            aria-labelledby="postpone-tab-reschedule"
            tabIndex={0}
            className="space-y-2 focus:outline-none"
            data-testid="postpone-reschedule-panel"
          >
            <p className="text-xs text-muted-foreground">
              {t("executiveMeetings.alert.rescheduleHint")}
            </p>
            {cascadePrompt && cascadePrompt.kind === "reschedule" ? (
              <CascadePromptBlock
                prompt={cascadePrompt}
                isRtl={isRtl}
                busy={busy !== null}
                meetingNumbersById={meetingNumbersById}
                accent={accent}
                lang={i18n.language}
                onShiftFollowing={() =>
                  void rescheduleSubmit(cascadePrompt.draft, true)
                }
                onKeepTimes={() =>
                  void rescheduleSubmit(cascadePrompt.draft, false)
                }
                onBack={() => setCascadePrompt(null)}
              />
            ) : null}
            {/* #577: Date on its own full-width row, Start + End
                side-by-side on the row below — at every breakpoint.
                Earlier (#563) we tried `grid-cols-1 lg:grid-cols-3`
                to avoid iPad portrait collisions but on iPad
                landscape (≥1024 px) the 3-column path still packed
                Date + Start + End into one row, where iOS Safari's
                native date/time spinner glyphs and Arabic AM/PM
                markers visibly collided. Keeping Date alone on top
                and pairing the two times below is the only layout
                that stays clean from 320 px phones up through
                desktop without depending on a custom picker.
                `min-w-0` lets each cell shrink inside its track so
                inputs respect their cell, and `w-full` on the Input
                ensures it fills the cell. */}
            {/* Final layout (#606 follow-up): each input gets its own
                full-width row at EVERY breakpoint — phones, iPad portrait
                AND iPad landscape — so the iOS native time pills can never
                overlap. Inputs are kept compact (h-9, single border,
                rounded-md, text-sm) so the stacked layout still feels
                tight on a phone and doesn't push the Apply button below
                the fold. The dialog itself remains a comfortable width on
                large screens; only the field row is narrowed. */}
            <div className="space-y-2">
              <div className="space-y-1 min-w-0">
                <Label className="text-xs font-semibold text-slate-700">
                  {t("executiveMeetings.alert.rescheduleDate")}
                </Label>
                <Input
                  type="date"
                  value={resDate}
                  onChange={(e) => setResDate(e.target.value)}
                  disabled={busy !== null}
                  data-testid="reschedule-date"
                  className="w-full max-w-xs h-9 bg-white border border-slate-300 rounded-md text-sm px-2.5"
                />
              </div>
              <div className="space-y-1 min-w-0">
                <Label className="text-xs font-semibold text-slate-700">
                  {t("executiveMeetings.alert.rescheduleStart")}
                </Label>
                <Input
                  type="time"
                  value={resStart}
                  onChange={(e) => setResStart(e.target.value)}
                  disabled={busy !== null}
                  data-testid="reschedule-start"
                  className="w-full max-w-xs h-9 bg-white border border-slate-300 rounded-md text-sm px-2.5"
                />
              </div>
              <div className="space-y-1 min-w-0">
                <Label className="text-xs font-semibold text-slate-700">
                  {t("executiveMeetings.alert.rescheduleEnd")}
                </Label>
                <Input
                  type="time"
                  value={resEnd}
                  onChange={(e) => setResEnd(e.target.value)}
                  disabled={busy !== null}
                  data-testid="reschedule-end"
                  className="w-full max-w-xs h-9 bg-white border border-slate-300 rounded-md text-sm px-2.5"
                />
              </div>
            </div>
            <Textarea
              value={resNote}
              onChange={(e) => setResNote(e.target.value)}
              placeholder={t("executiveMeetings.alert.rescheduleNote")}
              disabled={busy !== null}
              rows={2}
              data-testid="reschedule-note"
              className="w-full bg-white border border-slate-300 rounded-md text-sm px-2.5 py-2"
            />
            <Button
              type="button"
              size="sm"
              onClick={onReschedule}
              disabled={busy !== null || !resDate || !resStart || !resEnd}
              data-testid="reschedule-apply"
              className="h-9 rounded-md px-5 text-sm"
            >
              {t("executiveMeetings.alert.rescheduleApply")}
            </Button>
          </div>
        ) : null}

        {activeTab === "cancel" ? (
          <div
            role="tabpanel"
            id="postpone-panel-cancel"
            aria-labelledby="postpone-tab-cancel"
            tabIndex={0}
            className="space-y-2 focus:outline-none"
            data-testid="postpone-cancel-panel"
          >
            {/* #563: cancelHint paragraph removed per user request —
                the destructive button + confirm prompt convey intent
                without the long disclaimer. */}
            {!confirmCancel ? (
              <Button
                type="button"
                size="sm"
                variant="destructive"
                onClick={() => setConfirmCancel(true)}
                disabled={busy !== null}
                data-testid="cancel-meeting"
              >
                {t("executiveMeetings.alert.cancelApply")}
              </Button>
            ) : (
              <div
                className="flex flex-col gap-2"
                data-testid="cancel-confirm-block"
              >
                <p className="text-xs font-medium text-destructive">
                  {t("executiveMeetings.alert.cancelConfirmPrompt")}
                </p>
                <div className="flex items-center gap-2">
                  <Button
                    type="button"
                    size="sm"
                    variant="destructive"
                    onClick={onCancelMeeting}
                    disabled={busy !== null}
                    data-testid="cancel-meeting-confirm"
                  >
                    {t("executiveMeetings.alert.cancelConfirmYes")}
                  </Button>
                  <Button
                    type="button"
                    size="sm"
                    variant="outline"
                    onClick={() => setConfirmCancel(false)}
                    disabled={busy !== null}
                    data-testid="cancel-meeting-back"
                  >
                    {t("executiveMeetings.alert.back")}
                  </Button>
                </div>
              </div>
            )}
          </div>
        ) : null}
      </DialogContent>
    </Dialog>
  );
}

// ---------- Inline details panel ----------
//
// Replaces the old "Open meetings" navigate-out button. When the user
// clicks "Details" we expand this section between the body and the
// action footer of the same draggable popup. We deliberately keep the
// rendering compact: long lists scroll inside a max-height container so
// the alert never grows taller than the viewport. Styling pulls from
// the same theme tokens as the parent so the panel always matches the
// user's chosen preset.

type DetailsPanelProps = {
  meeting: Meeting;
  isRtl: boolean;
  t: (k: string, opts?: Record<string, unknown>) => string;
  lang: string;
  dividerColor: string;
  sectionBg: string;
};

function DetailsPanel({
  meeting,
  isRtl,
  t,
  lang,
  dividerColor,
  sectionBg,
}: DetailsPanelProps) {
  // Walk through attendees in order. Subheading rows act as group
  // headers (e.g. "الحضور الخارجي", "الحضور الداخلي"); person rows
  // following a subheading belong to that group. If persons appear
  // before any subheading they land in a header-less group.
  type AttendeeGroup = { heading: string | null; members: Attendee[] };
  const attendeeGroups: AttendeeGroup[] = [];
  let currentGroup: AttendeeGroup = { heading: null, members: [] };
  for (const a of meeting.attendees ?? []) {
    const cleanName = (a.name ?? "").replace(/<[^>]+>/g, "").trim();
    if (!cleanName) continue;
    if (a.kind === "subheading") {
      if (currentGroup.heading !== null || currentGroup.members.length > 0) {
        attendeeGroups.push(currentGroup);
      }
      currentGroup = { heading: cleanName, members: [] };
    } else {
      currentGroup.members.push(a);
    }
  }
  if (currentGroup.heading !== null || currentGroup.members.length > 0) {
    attendeeGroups.push(currentGroup);
  }
  const personCount = attendeeGroups.reduce(
    (sum, g) => sum + g.members.length,
    0,
  );

  const safeUrl = (() => {
    const raw = (meeting.meetingUrl ?? "").trim();
    if (!raw) return null;
    try {
      const u = new URL(raw);
      return u.protocol === "http:" || u.protocol === "https:" ? raw : null;
    } catch {
      return null;
    }
  })();

  // #344: The "time" row in the details panel duplicated the time
  // window already shown in the header strip, so it was removed at
  // the user's request. Header is the single source of truth for the
  // start/end times.

  // Each row renders a label cell + a value cell inside the parent
  // CSS grid. Skipping rows whose value is missing keeps the table
  // tight and avoids empty cells. The Icon is decorative — labels do
  // the real work for screen readers.
  type Row = {
    key: string;
    icon: ReactNode;
    label: string;
    value: ReactNode;
  };
  const rows: Row[] = [];

  if (meeting.location && meeting.location.trim()) {
    rows.push({
      key: "location",
      icon: <Calendar className="h-3.5 w-3.5 opacity-70" aria-hidden="true" />,
      label: t("executiveMeetings.alert.detailsLocation"),
      value: (
        <span
          className="break-words"
          data-testid="alert-details-location"
        >
          {meeting.location}
        </span>
      ),
    });
  }

  if (safeUrl) {
    rows.push({
      key: "url",
      icon: (
        <ExternalLink
          className="h-3.5 w-3.5 opacity-70"
          aria-hidden="true"
        />
      ),
      label: t("executiveMeetings.alert.detailsUrl"),
      value: (
        <a
          href={safeUrl}
          target="_blank"
          rel="noreferrer noopener"
          className="block truncate underline hover:no-underline"
          data-testid="alert-details-url"
        >
          {safeUrl}
        </a>
      ),
    });
  }

  rows.push({
    key: "attendees",
    icon: <Users className="h-3.5 w-3.5 opacity-70" aria-hidden="true" />,
    label: t("executiveMeetings.alert.detailsAttendees", {
      n: personCount,
    }),
    value:
      personCount === 0 ? (
        <div className="opacity-80" data-testid="alert-details-no-attendees">
          {t("executiveMeetings.alert.detailsNoAttendees")}
        </div>
      ) : (
        <div
          className="max-h-40 overflow-y-auto"
          data-testid="alert-details-attendees"
        >
          {/* #479: render each attendee group as its own numbered
              table. Numbering restarts at 1 per group; if a future
              spec wants a single continuous count across all groups,
              swap the per-group `idx + 1` for a counter incremented in
              the outer .map. */}
          {attendeeGroups.map((group, gIdx) => {
            if (group.members.length === 0) return null;
            return (
              <table
                key={`group-${gIdx}`}
                className="mb-2 w-full last:mb-0"
                aria-label={group.heading || undefined}
              >
                {group.heading ? (
                  <caption className="mb-0.5 text-start text-xs font-bold">
                    {group.heading}
                  </caption>
                ) : null}
                <tbody className="leading-snug">
                  {group.members.map((p, idx) => {
                    const cleanName = p.name
                      .replace(/<[^>]+>/g, "")
                      .trim();
                    if (!cleanName) return null;
                    return (
                      <tr key={`${gIdx}-${p.id ?? idx}-${p.name}`}>
                        <th
                          scope="row"
                          className="w-7 pe-1 text-start align-top font-normal tabular-nums opacity-70"
                        >
                          {idx + 1}.
                        </th>
                        <td className="align-top">{cleanName}</td>
                      </tr>
                    );
                  })}
                </tbody>
              </table>
            );
          })}
        </div>
      ),
  });

  return (
    <div
      id="alert-details-panel"
      className="border-t px-3 py-3 text-xs"
      style={{ borderColor: dividerColor, backgroundColor: sectionBg }}
      data-testid="alert-details-panel"
      dir={isRtl ? "rtl" : "ltr"}
    >
      <div
        className="grid items-start gap-x-3 gap-y-2"
        style={{ gridTemplateColumns: "auto 1fr" }}
      >
        {rows.map((row, idx) => (
          <Fragment key={row.key}>
            <div
              className="flex items-center gap-1.5 self-start whitespace-nowrap font-semibold opacity-80"
              style={{
                paddingTop: idx === 0 ? 0 : 6,
                borderTop:
                  idx === 0 ? "none" : `1px solid ${dividerColor}`,
              }}
            >
              {row.icon}
              <span>{row.label}</span>
            </div>
            <div
              className="min-w-0 self-start text-start"
              style={{
                paddingTop: idx === 0 ? 0 : 6,
                borderTop:
                  idx === 0 ? "none" : `1px solid ${dividerColor}`,
              }}
            >
              {row.value}
            </div>
          </Fragment>
        ))}
      </div>
    </div>
  );
}
