// #273: e2e for the floating 5-minute pre-meeting alert. Seeds a
// meeting on today's date with a start time ~3 minutes in the
// future so the global alert appears, then exercises Done /
// Dismiss / Postpone-by-minutes / Cancel and asserts the audit
// log records each action.
import { test, expect } from "@playwright/test";
import pg from "pg";

const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
  throw new Error(
    "DATABASE_URL must be set to run the upcoming-meeting-alert test",
  );
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
const TEST_TAG = "ALERT_UPCOMING_TEST";

async function loginViaUi(page, username, password) {
  await page.goto("/login");
  await page.locator("#username").fill(username);
  await page.locator("#password").fill(password);
  await Promise.all([
    page.waitForURL((url) => !url.pathname.endsWith("/login"), {
      timeout: 15_000,
    }),
    page.locator('form button[type="submit"]').click(),
  ]);
}
async function setLang(page, lang) {
  await page.addInitScript((l) => {
    try {
      window.localStorage.setItem("tx-lang", l);
    } catch {
      /* ignore */
    }
  }, lang);
}
function todayLocal() {
  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 timePlusMinutes(deltaMins) {
  const d = new Date(Date.now() + deltaMins * 60_000);
  const hh = String(d.getHours()).padStart(2, "0");
  const mm = String(d.getMinutes()).padStart(2, "0");
  return `${hh}:${mm}:00`;
}
async function nextDailyNumberForToday(date) {
  const { rows } = await pool.query(
    `SELECT COALESCE(MAX(daily_number), 0) + 1 AS n
       FROM executive_meetings
      WHERE meeting_date = $1`,
    [date],
  );
  return rows[0].n;
}
async function insertImminentMeeting({ titleAr, titleEn, deltaMins = 3 }) {
  const date = todayLocal();
  const startTime = timePlusMinutes(deltaMins);
  const endTime = timePlusMinutes(deltaMins + 30);
  const dn = await nextDailyNumberForToday(date);
  const { rows } = await pool.query(
    `INSERT INTO executive_meetings
       (daily_number, title_ar, title_en, meeting_date, start_time, end_time, status)
     VALUES ($1, $2, $3, $4, $5, $6, 'scheduled')
     RETURNING id, start_time, end_time`,
    [dn, titleAr, titleEn, date, startTime, endTime],
  );
  const id = rows[0].id;
  createdMeetingIds.push(id);
  return {
    id,
    date,
    dailyNumber: dn,
    startTime: rows[0].start_time,
    endTime: rows[0].end_time,
  };
}
async function audits(meetingId, action) {
  const { rows } = await pool.query(
    `SELECT count(*)::int AS c FROM executive_meeting_audit_logs
      WHERE entity_type = 'meeting' AND entity_id = $1 AND action = $2`,
    [meetingId, action],
  );
  return rows[0].c;
}
async function getMeetingRow(id) {
  const { rows } = await pool.query(
    `SELECT start_time, end_time, status FROM executive_meetings WHERE id = $1`,
    [id],
  );
  return rows[0];
}

test.afterAll(async () => {
  if (createdMeetingIds.length > 0) {
    await pool.query(
      `DELETE FROM executive_meeting_alert_state WHERE meeting_id = ANY($1::int[])`,
      [createdMeetingIds],
    );
    await pool.query(
      `DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`,
      [createdMeetingIds],
    );
    await pool.query(
      `DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
      [createdMeetingIds],
    );
    await pool.query(`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, [
      createdMeetingIds,
    ]);
  }
  await pool.end();
});

test("Upcoming-meeting alert: appears for an imminent meeting and Done acknowledges it", async ({
  page,
}) => {
  await setLang(page, "en");
  const m = await insertImminentMeeting({
    titleAr: `${TEST_TAG} done AR`,
    titleEn: `${TEST_TAG} done EN`,
    deltaMins: 3,
  });
  await loginViaUi(page, "admin", "admin123");
  await page.goto("/");

  const alert = page.getByTestId("upcoming-meeting-alert");
  await expect(alert).toBeVisible({ timeout: 15_000 });
  // Title may render the AR or EN variant depending on the admin's
  // preferred language stored on the server; assert on the shared tag.
  await expect(page.getByTestId("alert-meeting-title")).toContainText(
    `${TEST_TAG} done`,
  );

  await page.getByTestId("alert-done").click();
  await expect(alert).toBeHidden({ timeout: 5_000 });

  // The audit log records both "shown" and "acknowledged" exactly once
  // for this meeting/user.
  await expect
    .poll(() => audits(m.id, "meeting_alert_shown"), { timeout: 5_000 })
    .toBeGreaterThanOrEqual(1);
  await expect
    .poll(() => audits(m.id, "meeting_alert_acknowledged"), { timeout: 5_000 })
    .toBeGreaterThanOrEqual(1);
});

test("Upcoming-meeting alert: Postpone by 10 minutes shifts both start and end and clears the alert", async ({
  page,
}) => {
  await setLang(page, "en");
  const m = await insertImminentMeeting({
    titleAr: `${TEST_TAG} postpone AR`,
    titleEn: `${TEST_TAG} postpone EN`,
    deltaMins: 4,
  });
  const before = await getMeetingRow(m.id);
  const beforeStart = before.start_time.slice(0, 5);
  await loginViaUi(page, "admin", "admin123");
  await page.goto("/");

  await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
    timeout: 15_000,
  });
  await page.getByTestId("alert-postpone").click();
  const dialog = page.getByTestId("postpone-dialog");
  await expect(dialog).toBeVisible();
  // #275: Postpone tab is the default; assert the panel is visible
  // before interacting with its controls.
  await expect(page.getByTestId("postpone-minutes-panel")).toBeVisible();

  await page.getByTestId("postpone-minutes-input").fill("10");
  await page.getByTestId("postpone-minutes-apply").click();
  // #275: clicking Apply now arms a confirmation prompt instead of
  // mutating immediately. The user must explicitly confirm.
  await expect(page.getByTestId("postpone-confirm-block")).toBeVisible();
  await page.getByTestId("postpone-confirm-yes").click();
  // #302: if other tests on today's date left "later" meetings behind,
  // a cascade prompt (or its blocked-by-midnight variant) appears.
  // Poll for the keep-times button OR the meeting being postponed —
  // whichever comes first — so this test stays robust whether the
  // prompt appears or cascade-preview returns no followers and the
  // mutation goes through directly.
  const keepTimes = page.getByTestId("cascade-keep-times");
  const deadline = Date.now() + 12_000;
  while (Date.now() < deadline) {
    const isVisible = await keepTimes.isVisible().catch(() => false);
    if (isVisible) {
      await keepTimes.click();
      break;
    }
    const r = await getMeetingRow(m.id);
    if (r.status === "postponed" && r.start_time.slice(0, 5) !== beforeStart) {
      break;
    }
    await page.waitForTimeout(250);
  }

  await expect.poll(async () => {
    const r = await getMeetingRow(m.id);
    return r.status === "postponed" && r.start_time.slice(0, 5) !== beforeStart;
  }, { timeout: 15_000 }).toBe(true);

  await expect
    .poll(() => audits(m.id, "meeting_postponed_minutes"), { timeout: 5_000 })
    .toBeGreaterThanOrEqual(1);
});

test("Upcoming-meeting alert: clicking Back on the postpone confirm leaves the meeting unchanged", async ({
  page,
}) => {
  // #275: Verifies the new "are you sure?" gate is honoured — clicking
  // a chip and then Back must not mutate the meeting at all (no time
  // shift, no audit row, alert stays visible).
  await setLang(page, "en");
  const m = await insertImminentMeeting({
    titleAr: `${TEST_TAG} back AR`,
    titleEn: `${TEST_TAG} back EN`,
    deltaMins: 4,
  });
  const before = await getMeetingRow(m.id);
  const beforeStart = before.start_time;
  const beforeEnd = before.end_time;
  const beforeAudits = await audits(m.id, "meeting_postponed_minutes");

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/");
  await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
    timeout: 15_000,
  });
  await page.getByTestId("alert-postpone").click();
  await expect(page.getByTestId("postpone-dialog")).toBeVisible();
  await page.getByTestId("postpone-chip-15").click();
  await expect(page.getByTestId("postpone-confirm-block")).toBeVisible();
  await page.getByTestId("postpone-confirm-back").click();
  await expect(page.getByTestId("postpone-confirm-block")).toBeHidden();
  // The chip row reappears once Back is clicked.
  await expect(page.getByTestId("postpone-chip-15")).toBeVisible();

  // Give the UI a moment to (NOT) fire any postpone request, then verify
  // the meeting and audit log are untouched.
  await page.waitForTimeout(500);
  const after = await getMeetingRow(m.id);
  expect(String(after.start_time)).toBe(String(beforeStart));
  expect(String(after.end_time)).toBe(String(beforeEnd));
  expect(after.status).not.toBe("postponed");
  const afterAudits = await audits(m.id, "meeting_postponed_minutes");
  expect(afterAudits).toBe(beforeAudits);

  // Cleanup so this seeded meeting doesn't preempt later tests' alerts.
  await pool.query(
    `UPDATE executive_meetings
        SET start_time = (start_time::time + interval '2 hours')::time,
            end_time   = (end_time::time   + interval '2 hours')::time
      WHERE id = $1`,
    [m.id],
  );
});

test("Upcoming-meeting alert: switching tabs clears any armed postpone confirm", async ({
  page,
}) => {
  // #275: defensive UX — if the user arms the confirm prompt and then
  // navigates to a different tab and back, the confirm must be reset
  // so they can't accidentally complete an action they no longer have
  // eyes on.
  await setLang(page, "en");
  const m = await insertImminentMeeting({
    titleAr: `${TEST_TAG} tabswitch AR`,
    titleEn: `${TEST_TAG} tabswitch EN`,
    deltaMins: 4,
  });
  const before = await getMeetingRow(m.id);
  const beforeAudits = await audits(m.id, "meeting_postponed_minutes");

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/");
  await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
    timeout: 15_000,
  });
  await page.getByTestId("alert-postpone").click();
  await expect(page.getByTestId("postpone-dialog")).toBeVisible();

  // Arm the confirm by clicking a chip on the Postpone tab.
  await page.getByTestId("postpone-chip-30").click();
  await expect(page.getByTestId("postpone-confirm-block")).toBeVisible();

  // Switch to Reschedule, then back to Postpone — the confirm must
  // be cleared and the chip row visible again.
  await page.getByTestId("postpone-tab-reschedule").click();
  await expect(page.getByTestId("postpone-reschedule-panel")).toBeVisible();
  await page.getByTestId("postpone-tab-minutes").click();
  await expect(page.getByTestId("postpone-minutes-panel")).toBeVisible();
  await expect(page.getByTestId("postpone-confirm-block")).toBeHidden();
  await expect(page.getByTestId("postpone-chip-30")).toBeVisible();

  // Sanity: the meeting and audit log are untouched.
  await page.waitForTimeout(300);
  const after = await getMeetingRow(m.id);
  expect(String(after.start_time)).toBe(String(before.start_time));
  expect(after.status).not.toBe("postponed");
  const afterAudits = await audits(m.id, "meeting_postponed_minutes");
  expect(afterAudits).toBe(beforeAudits);

  // Cleanup so this seeded meeting doesn't preempt later tests' alerts.
  await pool.query(
    `UPDATE executive_meetings
        SET start_time = (start_time::time + interval '2 hours')::time,
            end_time   = (end_time::time   + interval '2 hours')::time
      WHERE id = $1`,
    [m.id],
  );
});

test("Upcoming-meeting alert: Cancel marks the meeting cancelled and removes the alert", async ({
  page,
}) => {
  await setLang(page, "en");
  const m = await insertImminentMeeting({
    titleAr: `${TEST_TAG} cancel AR`,
    titleEn: `${TEST_TAG} cancel EN`,
    deltaMins: 2,
  });
  await loginViaUi(page, "admin", "admin123");
  await page.goto("/");

  await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
    timeout: 15_000,
  });
  await page.getByTestId("alert-postpone").click();
  await expect(page.getByTestId("postpone-dialog")).toBeVisible();
  // #275: cancel controls live in their own tab.
  await page.getByTestId("postpone-tab-cancel").click();
  await page.getByTestId("cancel-meeting").click();
  // Confirmation step protects against accidental cancellation.
  await page.getByTestId("cancel-meeting-confirm").click();

  await expect.poll(async () => {
    const r = await getMeetingRow(m.id);
    return r?.status === "cancelled";
  }, { timeout: 10_000 }).toBe(true);
  await expect
    .poll(() => audits(m.id, "meeting_cancelled"), { timeout: 5_000 })
    .toBeGreaterThanOrEqual(1);
});

test("Upcoming-meeting alert: Dismiss (X) hides the alert and writes a dismissed audit row", async ({
  page,
}) => {
  await setLang(page, "en");
  const m = await insertImminentMeeting({
    titleAr: `${TEST_TAG} dismiss AR`,
    titleEn: `${TEST_TAG} dismiss EN`,
    deltaMins: 3,
  });
  await loginViaUi(page, "admin", "admin123");
  await page.goto("/");

  const alert = page.getByTestId("upcoming-meeting-alert");
  await expect(alert).toBeVisible({ timeout: 15_000 });
  await page.getByTestId("alert-close").click();
  await expect(alert).toBeHidden({ timeout: 5_000 });
  await expect
    .poll(() => audits(m.id, "meeting_alert_dismissed"), { timeout: 5_000 })
    .toBeGreaterThanOrEqual(1);
});

test("Upcoming-meeting alert: clicking a postpone chip immediately shifts start/end and surfaces a conflict warning when overlapping another meeting", async ({
  page,
}) => {
  await setLang(page, "en");
  // Imminent meeting at +3 mins, plus a neighbour starting at +13 mins.
  // Clicking the "10" chip must (a) shift start and end forward by 10
  // minutes immediately on a single click — no Apply step needed — and
  // (b) trigger the conflict-warning toast because the new window
  // overlaps the neighbour.
  const m = await insertImminentMeeting({
    titleAr: `${TEST_TAG} chip AR`,
    titleEn: `${TEST_TAG} chip EN`,
    deltaMins: 3,
  });
  const neighbour = await insertImminentMeeting({
    titleAr: `${TEST_TAG} neighbour AR`,
    titleEn: `${TEST_TAG} neighbour EN`,
    deltaMins: 13,
  });
  // Snapshot original times so we can assert the +10-minute shift below.
  const before = await pool.query(
    `SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
    [m.id],
  );
  const startBefore = String(before.rows[0].start_time);
  const endBefore = String(before.rows[0].end_time);

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/");

  await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
    timeout: 15_000,
  });
  await expect(page.getByTestId("alert-time-window")).toBeVisible();

  await page.getByTestId("alert-postpone").click();
  await expect(page.getByTestId("postpone-dialog")).toBeVisible();
  // #275: chip click arms the confirm step rather than firing the API
  // immediately. The user must explicitly press the confirm button.
  await page.getByTestId("postpone-chip-10").click();
  await expect(page.getByTestId("postpone-confirm-block")).toBeVisible();
  await page.getByTestId("postpone-confirm-yes").click();
  // #302: This test pre-creates a "neighbour" later meeting on the
  // same day so the cascade prompt MAY appear, depending on how much
  // earlier-test pollution exists in today's schedule. The point of
  // this test is the conflict-warning toast (overlap), not cascade
  // semantics, so race the keep-times button against the audit row
  // appearing — whichever comes first wins, so the test is robust to
  // both cascade-on and cascade-off paths.
  {
    const keepTimes = page.getByTestId("cascade-keep-times");
    const deadline = Date.now() + 8_000;
    while (Date.now() < deadline) {
      if (await keepTimes.isVisible().catch(() => false)) {
        await keepTimes.click();
        break;
      }
      const auditCount = await audits(m.id, "meeting_postponed_minutes");
      if (auditCount >= 1) break;
      await page.waitForTimeout(200);
    }
  }

  // Conflict warning toast (EN or AR — admin preferredLanguage may flip it).
  // The Radix toast renders both a visible title element and an
  // assertive a11y live-region with the same text, so scope the
  // assertion to the visible ToastTitle to avoid strict-mode duplicates.
  await expect(
    page
      .getByText(/overlaps another meeting|يتداخل مع اجتماع/i)
      .first(),
  ).toBeVisible({ timeout: 5_000 });
  await expect
    .poll(() => audits(m.id, "meeting_postponed_minutes"), { timeout: 5_000 })
    .toBeGreaterThanOrEqual(1);

  // Verify start/end actually shifted by exactly +10 minutes in the DB.
  const toMin = (hms) => {
    const [h, mi] = String(hms).split(":");
    return Number(h) * 60 + Number(mi);
  };
  await expect
    .poll(async () => {
      const { rows } = await pool.query(
        `SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
        [m.id],
      );
      const ds = toMin(rows[0].start_time) - toMin(startBefore);
      const de = toMin(rows[0].end_time) - toMin(endBefore);
      return ds === 10 && de === 10;
    }, { timeout: 5_000 })
    .toBe(true);
  expect(neighbour.id).toBeGreaterThan(0);
});

test("Upcoming-meeting alert: re-clamps its position back into the viewport after the window shrinks", async ({
  page,
}) => {
  await setLang(page, "en");
  const m = await insertImminentMeeting({
    titleAr: `${TEST_TAG} clamp AR`,
    titleEn: `${TEST_TAG} clamp EN`,
    deltaMins: 3,
  });
  await loginViaUi(page, "admin", "admin123");

  // Pre-seed a stale localStorage position that lives at the right edge
  // of a wide viewport so a later resize *must* push the panel back.
  await page.setViewportSize({ width: 1400, height: 900 });
  await page.addInitScript(() => {
    localStorage.setItem(
      "txos.upcomingMeetingAlert.position",
      JSON.stringify({ x: 1300, y: 80 }),
    );
  });
  await page.goto("/");
  await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
    timeout: 15_000,
  });

  // Now shrink to a phone-ish viewport and assert the panel's right edge
  // stays inside the new viewport (with the 8-px safety margin).
  await page.setViewportSize({ width: 420, height: 720 });
  await expect
    .poll(async () => {
      const box = await page
        .getByTestId("upcoming-meeting-alert")
        .boundingBox();
      if (!box) return false;
      return box.x >= 0 && box.x + box.width <= 420;
    }, { timeout: 5_000 })
    .toBe(true);

  // Restore a normal viewport so subsequent tests in the same worker
  // don't inherit the phone-sized layout (form inputs in later tests
  // depend on a desktop-width viewport).
  await page.setViewportSize({ width: 1280, height: 720 });

  // Move this seeded meeting *out of the 5-minute window* so the alert
  // it produced doesn't preempt the next test's freshly-inserted meeting
  // (the alert always picks the nearest unacknowledged future meeting,
  // and ack rows are per-user — DB-side relocation is the only reliable
  // way to evict it from the global picker for any user the next test
  // logs in as).
  await pool.query(
    `UPDATE executive_meetings
        SET start_time = (start_time::time + interval '2 hours')::time,
            end_time   = (end_time::time   + interval '2 hours')::time
      WHERE id = $1`,
    [m.id],
  );
});

test("Upcoming-meeting alert: Reschedule to a different day moves the meeting and clears the alert", async ({
  page,
}) => {
  await setLang(page, "en");
  const m = await insertImminentMeeting({
    titleAr: `${TEST_TAG} reschedule AR`,
    titleEn: `${TEST_TAG} reschedule EN`,
    deltaMins: 3,
  });
  // Pick tomorrow (local).
  const t1 = new Date(Date.now() + 24 * 60 * 60 * 1000);
  const tomorrow = `${t1.getFullYear()}-${String(t1.getMonth() + 1).padStart(2, "0")}-${String(t1.getDate()).padStart(2, "0")}`;

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/");
  await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
    timeout: 15_000,
  });
  await page.getByTestId("alert-postpone").click();
  await expect(page.getByTestId("postpone-dialog")).toBeVisible();
  // #275: reschedule controls now live in their own tab.
  await page.getByTestId("postpone-tab-reschedule").click();
  await expect(page.getByTestId("postpone-reschedule-panel")).toBeVisible();

  await page.getByTestId("reschedule-date").fill(tomorrow);
  await page.getByTestId("reschedule-start").fill("10:30");
  await page.getByTestId("reschedule-end").fill("11:00");
  await page.getByTestId("reschedule-apply").click();

  // The meeting now belongs to tomorrow, so today's alert disappears.
  await expect.poll(async () => {
    const { rows } = await pool.query(
      `SELECT meeting_date::text AS d, start_time, end_time FROM executive_meetings WHERE id = $1`,
      [m.id],
    );
    return rows[0]?.d === tomorrow;
  }, { timeout: 10_000 }).toBe(true);
  await expect
    .poll(() => audits(m.id, "meeting_rescheduled"), { timeout: 5_000 })
    .toBeGreaterThanOrEqual(1);
  await expect(page.getByTestId("upcoming-meeting-alert")).toBeHidden({
    timeout: 10_000,
  });
});

test("Upcoming-meeting alert: Cancel removes the meeting from today's schedule view and renumbers the remaining rows", async ({
  page,
}) => {
  await setLang(page, "en");
  // Two non-cancelled meetings exist on today; the alert covers the
  // imminent one. Cancelling it should make it disappear from the
  // schedule view, and the survivor should be renumbered to #1.
  const earlier = await insertImminentMeeting({
    titleAr: `${TEST_TAG} renum-A AR`,
    titleEn: `${TEST_TAG} renum-A EN`,
    deltaMins: 3,
  });
  const later = await insertImminentMeeting({
    titleAr: `${TEST_TAG} renum-B AR`,
    titleEn: `${TEST_TAG} renum-B EN`,
    deltaMins: 90,
  });

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/");
  await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
    timeout: 15_000,
  });
  await page.getByTestId("alert-postpone").click();
  await expect(page.getByTestId("postpone-dialog")).toBeVisible();
  // #275: cancel controls live in their own tab.
  await page.getByTestId("postpone-tab-cancel").click();
  await page.getByTestId("cancel-meeting").click();
  await page.getByTestId("cancel-meeting-confirm").click();

  // DB: the cancelled meeting is renumbered to the tail (its daily_number
  // becomes greater than the survivor's), and the survivor stays active.
  // We do not pin to "#1" because earlier tests in the same run can leave
  // their own meetings on today's date.
  await expect
    .poll(async () => {
      const { rows } = await pool.query(
        `SELECT id, daily_number, status FROM executive_meetings
          WHERE id = ANY($1::int[]) ORDER BY id`,
        [[earlier.id, later.id]],
      );
      const a = rows.find((r) => r.id === earlier.id);
      const b = rows.find((r) => r.id === later.id);
      return (
        a?.status === "cancelled" &&
        b?.status !== "cancelled" &&
        a?.daily_number > b?.daily_number
      );
    }, { timeout: 10_000 })
    .toBe(true);

  // Schedule view: cancelled meeting must disappear from today's list.
  // Match both EN and AR variants of the seeded titles because the
  // admin user's preferredLanguage may flip i18n away from the
  // localStorage hint we set at start of test (the schedule cell shows
  // titleAr when isRtl, titleEn otherwise).
  await page.goto("/executive-meetings");
  await expect(
    page.getByText(new RegExp(`${TEST_TAG} renum-B (EN|AR)`)),
  ).toBeVisible({ timeout: 10_000 });
  await expect(
    page.getByText(new RegExp(`${TEST_TAG} renum-A (EN|AR)`)),
  ).toBeHidden();
});

test("Upcoming-meeting alert: Arabic locale renders the RTL alert with Arabic title", async ({
  page,
}) => {
  await setLang(page, "ar");
  const m = await insertImminentMeeting({
    titleAr: `${TEST_TAG} عربي`,
    titleEn: `${TEST_TAG} ar EN`,
    deltaMins: 3,
  });
  await loginViaUi(page, "admin", "admin123");
  await page.goto("/");

  const alert = page.getByTestId("upcoming-meeting-alert");
  await expect(alert).toBeVisible({ timeout: 15_000 });
  await expect(alert).toHaveAttribute("dir", "rtl");
  await expect(page.getByTestId("alert-meeting-title")).toContainText(
    `${TEST_TAG} عربي`,
  );

  // Cleanup: dismiss so it doesn't bleed into other tests' polling.
  await page.getByTestId("alert-close").click();
  await expect(alert).toBeHidden({ timeout: 5_000 });
  // Mark used so the afterAll cleanup picks it up via createdMeetingIds.
  expect(m.id).toBeGreaterThan(0);
});

// #282: Postpone modal must not be covered by the alert panel.
test("Upcoming-meeting alert: opening Postpone hides the alert panel so the form is fully visible, and the panel returns when the modal closes", async ({
  page,
}) => {
  await setLang(page, "en");
  const m = await insertImminentMeeting({
    titleAr: `${TEST_TAG} 282 AR`,
    titleEn: `${TEST_TAG} 282 EN`,
    deltaMins: 4,
  });
  await loginViaUi(page, "admin", "admin123");
  await page.goto("/");

  const alert = page.getByTestId("upcoming-meeting-alert");
  await expect(alert).toBeVisible({ timeout: 15_000 });

  await page.getByTestId("alert-postpone").click();
  const dialog = page.getByTestId("postpone-dialog");
  await expect(dialog).toBeVisible();

  // While the postpone dialog is open the alert panel is collapsed
  // (display:none) so the form is the clear focus and nothing covers
  // its controls. The dialog itself stays interactable.
  await expect(alert).toBeHidden();
  await expect(page.getByTestId("postpone-minutes-panel")).toBeVisible();
  await expect(page.getByTestId("postpone-chip-10")).toBeVisible();
  // Sanity: the form is actually hit-testable, not just rendered behind
  // an overlay.
  await page.getByTestId("postpone-chip-10").click();
  await expect(page.getByTestId("postpone-confirm-block")).toBeVisible();

  // Close the dialog (Escape) → the alert panel returns to view since
  // the meeting is still in the upcoming-window.
  await page.keyboard.press("Escape");
  await expect(dialog).toBeHidden();
  await expect(alert).toBeVisible({ timeout: 5_000 });

  expect(m.id).toBeGreaterThan(0);
});

// #277: Realtime push tests.
//
// The 5-min upcoming-meeting alert state is per-user. The server emits
// `executive_meeting_alert_state_changed` ONLY to the acting user's
// `user:${userId}` socket room so:
//   (a) all of *that* user's open tabs / devices clear within ~1s, and
//   (b) other users' alerts are unaffected.
// These two browser-level tests verify both invariants without waiting
// on the 30s polling fallback.

test("Upcoming-meeting alert: realtime — same user, second tab clears within ~3s of Done in the first tab", async ({
  browser,
}) => {
  const m = await insertImminentMeeting({
    titleAr: `${TEST_TAG} rt-cross-tab AR`,
    titleEn: `${TEST_TAG} rt-cross-tab EN`,
    deltaMins: 3,
  });

  // One browser context (= one logged-in user) with two tabs.
  const ctx = await browser.newContext();
  try {
    const tabA = await ctx.newPage();
    const tabB = await ctx.newPage();
    await setLang(tabA, "en");
    await setLang(tabB, "en");

    // Login once on tabA — the session cookie is shared across the
    // context so tabB inherits the same user without a second login.
    await loginViaUi(tabA, "admin", "admin123");
    await Promise.all([tabA.goto("/"), tabB.goto("/")]);

    const alertA = tabA.getByTestId("upcoming-meeting-alert");
    const alertB = tabB.getByTestId("upcoming-meeting-alert");
    await expect(alertA).toBeVisible({ timeout: 15_000 });
    await expect(alertB).toBeVisible({ timeout: 15_000 });

    // Acknowledge in tabA. The server emits the per-user event to
    // user:${admin.id}, which both tabs receive — but tabB has *not*
    // polled yet (poll is 30s) so any sub-30s hide on tabB proves the
    // socket invalidation worked.
    await tabA.getByTestId("alert-done").click();
    await expect(alertA).toBeHidden({ timeout: 5_000 });
    await expect(alertB).toBeHidden({ timeout: 5_000 });
  } finally {
    await ctx.close();
  }

  expect(m.id).toBeGreaterThan(0);
});

test("Upcoming-meeting alert: realtime — same user, Dismiss in tab A clears tab B within ~5s", async ({
  browser,
}) => {
  // Mirrors the Done cross-tab test for the Dismiss action so both
  // per-user state transitions (acknowledged + dismissed) are proven
  // to push to the user's other tabs without waiting for the 30s poll.
  const m = await insertImminentMeeting({
    titleAr: `${TEST_TAG} rt-dismiss AR`,
    titleEn: `${TEST_TAG} rt-dismiss EN`,
    deltaMins: 3,
  });

  const ctx = await browser.newContext();
  try {
    const tabA = await ctx.newPage();
    const tabB = await ctx.newPage();
    await setLang(tabA, "en");
    await setLang(tabB, "en");
    await loginViaUi(tabA, "admin", "admin123");
    await Promise.all([tabA.goto("/"), tabB.goto("/")]);

    const alertA = tabA.getByTestId("upcoming-meeting-alert");
    const alertB = tabB.getByTestId("upcoming-meeting-alert");
    await expect(alertA).toBeVisible({ timeout: 15_000 });
    await expect(alertB).toBeVisible({ timeout: 15_000 });

    // Dismiss in tabA → server records dismissed=true → emits the
    // per-user event → tabB's query is invalidated and the alert hides.
    await tabA.getByTestId("alert-close").click();
    await expect(alertA).toBeHidden({ timeout: 5_000 });
    await expect(alertB).toBeHidden({ timeout: 5_000 });
  } finally {
    await ctx.close();
  }

  expect(m.id).toBeGreaterThan(0);
});

test("Upcoming-meeting alert: realtime — Done by user A does NOT hide user B's alert", async ({
  browser,
}) => {
  const m = await insertImminentMeeting({
    titleAr: `${TEST_TAG} rt-isolation AR`,
    titleEn: `${TEST_TAG} rt-isolation EN`,
    deltaMins: 3,
  });

  // Provision a second admin user via the admin API. Using a unique
  // username per test run keeps reruns clean even if afterAll cleanup
  // is preempted by a failure.
  const otherUsername = `${TEST_TAG.toLowerCase()}_rt_other_${Date.now()}`;
  const otherPassword = "Other-User-123!";
  const adminCtx = await browser.newContext();
  let otherUserId = null;
  try {
    const adminPage = await adminCtx.newPage();
    await loginViaUi(adminPage, "admin", "admin123");

    const createRes = await adminCtx.request.post("/api/users", {
      data: {
        username: otherUsername,
        password: otherPassword,
        email: `${otherUsername}@example.test`,
        displayNameEn: "Realtime Isolation User",
        displayNameAr: "مستخدم اختبار العزل",
        preferredLanguage: "en",
      },
    });
    expect(createRes.ok()).toBeTruthy();
    const createBody = await createRes.json();
    otherUserId = createBody?.id;
    expect(typeof otherUserId).toBe("number");

    // Grant the admin role (which is in EXECUTIVE_READ_ROLES) so
    // requireExecutiveAccess passes for this synthetic user.
    const grantRes = await adminCtx.request.post(
      `/api/users/${otherUserId}/roles`,
      { data: { roleName: "admin" } },
    );
    expect(grantRes.ok()).toBeTruthy();
  } finally {
    await adminCtx.close();
  }

  // Two fresh contexts: one for `admin`, one for the just-created user.
  const ctxA = await browser.newContext();
  const ctxB = await browser.newContext();
  try {
    const pageA = await ctxA.newPage();
    const pageB = await ctxB.newPage();
    await setLang(pageA, "en");
    await setLang(pageB, "en");

    await loginViaUi(pageA, "admin", "admin123");
    await loginViaUi(pageB, otherUsername, otherPassword);

    await Promise.all([pageA.goto("/"), pageB.goto("/")]);

    const alertA = pageA.getByTestId("upcoming-meeting-alert");
    const alertB = pageB.getByTestId("upcoming-meeting-alert");
    await expect(alertA).toBeVisible({ timeout: 15_000 });
    await expect(alertB).toBeVisible({ timeout: 15_000 });

    // User A acks. Their alert should clear within ~5s.
    await pageA.getByTestId("alert-done").click();
    await expect(alertA).toBeHidden({ timeout: 5_000 });

    // User B must NOT receive the per-user event. We give the socket
    // round-trip a generous head start, then assert B's alert is still
    // visible. (The 30s poll wouldn't fire in this window either, so a
    // hide here would mean the server leaked the event globally.)
    await pageB.waitForTimeout(3_000);
    await expect(alertB).toBeVisible();
  } finally {
    await ctxA.close();
    await ctxB.close();
    if (otherUserId != null) {
      // Best-effort cleanup. The synthetic user touches several tables
      // (role grant, group membership via Everyone, alert ack, audit
      // logs from user creation). We delete in FK-safe order and just
      // deactivate the user if FK rows remain — the user is namespaced
      // by Date.now() so leftovers don't collide between runs.
      try {
        await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [
          otherUserId,
        ]);
        await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [
          otherUserId,
        ]);
        await pool.query(
          `DELETE FROM executive_meeting_alert_state WHERE user_id = $1`,
          [otherUserId],
        );
        await pool.query(
          `DELETE FROM audit_logs WHERE actor_user_id = $1`,
          [otherUserId],
        );
        await pool.query(`DELETE FROM users WHERE id = $1`, [otherUserId]);
      } catch {
        await pool
          .query(`UPDATE users SET is_active = false WHERE id = $1`, [
            otherUserId,
          ])
          .catch(() => {});
      }
    }
  }

  expect(m.id).toBeGreaterThan(0);
});

// #302: e2e for the cascade prompt itself. Seeds two meetings on a
// FUTURE date (so we can choose a meetingDate where this suite owns
// every row, no pollution from other test runs) and exercises the
// reschedule path which forwards-shifts the primary by N minutes on
// the same day. Asserts that (a) the cascade prompt appears with the
// neighbour as a follower, (b) clicking "Shift following" shifts both
// rows by the same delta server-side, and (c) writes the
// meeting_cascade_shift audit for the neighbour.
test("Reschedule cascade: prompt shifts later same-day meetings server-side", async ({
  page,
}) => {
  await setLang(page, "en");

  // Use a fixed far-future meetingDate that no other test touches so
  // the cascade-preview only sees the rows we created here. Pick a
  // year that's clearly out of range for any other suite's seed.
  const futureDate = "2050-08-15";
  const primaryStart = "10:00:00";
  const primaryEnd = "10:30:00";
  const neighbourStart = "11:00:00";
  const neighbourEnd = "11:30:00";

  const insertOn = async (titleSuffix, startTime, endTime) => {
    const dn = await nextDailyNumberForToday(futureDate);
    const { rows } = await pool.query(
      `INSERT INTO executive_meetings
         (daily_number, title_ar, title_en, meeting_date, start_time, end_time, status)
       VALUES ($1, $2, $3, $4, $5, $6, 'scheduled')
       RETURNING id`,
      [
        dn,
        `${TEST_TAG} cascade ${titleSuffix} AR`,
        `${TEST_TAG} cascade ${titleSuffix} EN`,
        futureDate,
        startTime,
        endTime,
      ],
    );
    const id = rows[0].id;
    createdMeetingIds.push(id);
    return id;
  };
  const primaryId = await insertOn("primary", primaryStart, primaryEnd);
  const neighbourId = await insertOn("follower", neighbourStart, neighbourEnd);

  await loginViaUi(page, "admin", "admin123");
  // Reschedule is invoked through the meeting-actions API directly via
  // the UI on the schedule page. The simplest way to get to the
  // reschedule form for a non-imminent meeting is via the upcoming
  // alert dialog opened against this specific id; but the alert only
  // shows for imminent meetings. To keep this test focused on the
  // cascade *backend contract*, we drive it through the API and only
  // assert the resulting shifts + audit row + realtime emit.
  // (UI rendering of CascadePromptBlock is exercised separately by
  //  the unit-level visual test through the testing skill.)
  const csrf = await page.request
    .get("/api/auth/me")
    .then((r) => r.headers()["x-csrf-token"] || null);
  const previewRes = await page.request.post(
    `/api/executive-meetings/${primaryId}/cascade-preview`,
    {
      data: { deltaMinutes: 15 },
      headers: csrf ? { "x-csrf-token": csrf } : undefined,
    },
  );
  expect(previewRes.status()).toBe(200);
  const preview = await previewRes.json();
  expect(preview.blockedBy).toBeNull();
  expect(Array.isArray(preview.followers)).toBe(true);
  expect(preview.followers.find((f) => f.id === neighbourId)).toBeTruthy();

  const submitRes = await page.request.post(
    `/api/executive-meetings/${primaryId}/reschedule`,
    {
      data: {
        meetingDate: futureDate,
        startTime: "10:15:00",
        endTime: "10:45:00",
        cascadeFollowing: true,
      },
      headers: csrf ? { "x-csrf-token": csrf } : undefined,
    },
  );
  expect(submitRes.status()).toBe(200);
  const submit = await submitRes.json();
  expect(Array.isArray(submit.cascadedIds)).toBe(true);
  expect(submit.cascadedIds).toContain(neighbourId);

  const after = await pool.query(
    `SELECT id, start_time, end_time FROM executive_meetings WHERE id = ANY($1::int[])`,
    [[primaryId, neighbourId]],
  );
  const rowsById = Object.fromEntries(after.rows.map((r) => [r.id, r]));
  expect(String(rowsById[primaryId].start_time).slice(0, 5)).toBe("10:15");
  expect(String(rowsById[neighbourId].start_time).slice(0, 5)).toBe("11:15");

  await expect
    .poll(() => audits(neighbourId, "meeting_cascade_shift"), {
      timeout: 5_000,
    })
    .toBeGreaterThanOrEqual(1);
});

// #302 (UI): focused dialog test — open the postpone confirm, click
// Yes, see the cascade prompt list our seeded follower, then click
// "Shift following meetings" and verify (via request interception)
// that the actual /postpone-minutes call carries cascadeFollowing:
// true. This exercises the CascadePromptBlock UI itself, not just
// the backend contract. Uses today's date because UpcomingMeetingAlert
// only renders for imminent meetings; pollution-tolerant because we
// only assert OUR follower is among the listed ones, and intercept
// the mutation before it actually fires so other tests' rows are
// not collaterally shifted by Shift's broadcast cascade.
test("Cascade prompt UI: Shift sends cascadeFollowing=true; Keep sends false", async ({
  page,
}) => {
  await setLang(page, "en");

  // Imminent primary + a same-day later meeting (well past primary's
  // endTime so it qualifies as a "follower" for cascade-preview).
  const primary = await insertImminentMeeting({
    titleAr: `${TEST_TAG} cascade-ui-primary AR`,
    titleEn: `${TEST_TAG} cascade-ui-primary EN`,
    deltaMins: 3,
  });
  const follower = await insertImminentMeeting({
    titleAr: `${TEST_TAG} cascade-ui-follower AR`,
    titleEn: `${TEST_TAG} cascade-ui-follower EN`,
    // +60 ⇒ start_time is well past primary.endTime (= +33), so
    // cascade-preview will include this row in `followers`.
    deltaMins: 60,
  });

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/");
  await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
    timeout: 15_000,
  });

  // Intercept cascade-preview to return a deterministic single-
  // follower response so the test does not depend on whatever else
  // exists in today's schedule (pollution from earlier tests, or a
  // late-night seed that would otherwise push the cascade past
  // midnight and trigger the blocked variant). The mutation routes
  // are still intercepted later so no real DB shift happens.
  // Wildcard id: the upcoming alert may surface a different
  // earliest-imminent meeting from earlier-test pollution; we don't
  // care which id it uses — only that the response shape forces the
  // cascade-prompt with our deterministic follower row.
  await page.route(
    /\/api\/executive-meetings\/\d+\/cascade-preview$/,
    async (route) =>
      route.fulfill({
        status: 200,
        contentType: "application/json",
        body: JSON.stringify({
          followers: [
            {
              id: follower.id,
              titleAr: `${TEST_TAG} cascade-ui-follower AR`,
              titleEn: `${TEST_TAG} cascade-ui-follower EN`,
              oldStart: "11:00",
              oldEnd: "11:30",
              newStart: "11:10",
              newEnd: "11:40",
            },
          ],
          blockedBy: null,
        }),
      }),
  );

  // ── Path A: Shift following — assert cascadeFollowing=true ──────
  await page.getByTestId("alert-postpone").click();
  await expect(page.getByTestId("postpone-dialog")).toBeVisible();
  await page.getByTestId("postpone-chip-10").click();
  await expect(page.getByTestId("postpone-confirm-block")).toBeVisible();
  await page.getByTestId("postpone-confirm-yes").click();

  // Cascade prompt must show up because the mocked preview returned
  // a follower.
  await expect(page.getByTestId("cascade-prompt")).toBeVisible({
    timeout: 8_000,
  });
  // Our follower row must appear in the list with its testid.
  const followerRow = page.getByTestId(`cascade-follower-${follower.id}`);
  await expect(followerRow).toBeVisible();
  // #481: the row is rendered as a 3-column table (Meeting #, Title,
  // From → To). The leading "#" index column was dropped because the
  // dailyNumber already gives the user a stable identifier.
  const followerCells = followerRow.locator("td");
  await expect(followerCells).toHaveCount(3);
  await expect(followerCells.nth(0)).toHaveText(String(follower.dailyNumber));
  await expect(followerCells.nth(1)).toContainText(`${TEST_TAG} cascade-ui-follower`);
  // #481: times render as localized 12-hour with AM/PM (EN) or ص/م (AR).
  await expect(followerCells.nth(2)).toContainText(/AM|PM|ص|م/);

  // Intercept the mutation so we can read its JSON body without
  // letting it actually fire (which would side-effect every other
  // same-day row via the cascade). Fulfill with a successful empty
  // response so the UI completes its handler cleanly.
  let shiftBody = null;
  await page.route(
    /\/api\/executive-meetings\/\d+\/postpone-minutes$/,
    async (route) => {
      shiftBody = JSON.parse(route.request().postData() || "{}");
      await route.fulfill({
        status: 200,
        contentType: "application/json",
        body: JSON.stringify({ ok: true, cascadedIds: [follower.id] }),
      });
    },
  );
  await page.getByTestId("cascade-shift-following").click();
  await expect.poll(() => shiftBody, { timeout: 5_000 }).not.toBeNull();
  expect(shiftBody.cascadeFollowing).toBe(true);
  expect(shiftBody.minutes).toBe(10);
  await page.unroute(/\/api\/executive-meetings\/\d+\/postpone-minutes$/);

  // Reset UI back to the alert so we can exercise the Keep path on
  // the same primary (the prompt clears after a successful submit).
  await page.reload();
  await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
    timeout: 15_000,
  });

  // ── Path B: Keep their times — assert cascadeFollowing=false ────
  await page.getByTestId("alert-postpone").click();
  await expect(page.getByTestId("postpone-dialog")).toBeVisible();
  await page.getByTestId("postpone-chip-10").click();
  await expect(page.getByTestId("postpone-confirm-block")).toBeVisible();
  await page.getByTestId("postpone-confirm-yes").click();

  // Either the regular prompt or the blocked-by-midnight variant is
  // acceptable here — both render cascade-keep-times. We only need
  // the keep button to be present so we can click it.
  await page
    .getByTestId("cascade-keep-times")
    .waitFor({ state: "visible", timeout: 8_000 });

  let keepBody = null;
  await page.route(
    /\/api\/executive-meetings\/\d+\/postpone-minutes$/,
    async (route) => {
      keepBody = JSON.parse(route.request().postData() || "{}");
      await route.fulfill({
        status: 200,
        contentType: "application/json",
        body: JSON.stringify({ ok: true, cascadedIds: [] }),
      });
    },
  );
  await page.getByTestId("cascade-keep-times").click();
  await expect.poll(() => keepBody, { timeout: 5_000 }).not.toBeNull();
  expect(keepBody.cascadeFollowing).toBe(false);
  expect(keepBody.minutes).toBe(10);
  await page.unroute(/\/api\/executive-meetings\/\d+\/postpone-minutes$/);
  await page.unroute(/\/api\/executive-meetings\/\d+\/cascade-preview$/);
});

// #302 (UI): lock in the "no followers => cascade prompt is skipped"
// branch. Mocks cascade-preview to return zero followers and asserts
// that the UI silently falls through to a single-meeting submit
// (cascade-prompt is never rendered) and that the postpone-minutes
// payload carries cascadeFollowing=false.
test("Cascade prompt UI: no followers => prompt skipped, direct submit with cascadeFollowing=false", async ({
  page,
}) => {
  await setLang(page, "en");

  const primary = await insertImminentMeeting({
    titleAr: `${TEST_TAG} cascade-noflw-primary AR`,
    titleEn: `${TEST_TAG} cascade-noflw-primary EN`,
    deltaMins: 3,
  });

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/");
  await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
    timeout: 15_000,
  });

  // Mock the preview to definitively return zero followers regardless
  // of any same-day pollution. Use a wildcard id because the upcoming
  // alert shows the earliest upcoming meeting which may belong to a
  // different test's leftover seed, not the one we just inserted.
  await page.route(
    /\/api\/executive-meetings\/\d+\/cascade-preview$/,
    async (route) =>
      route.fulfill({
        status: 200,
        contentType: "application/json",
        body: JSON.stringify({ followers: [], blockedBy: null }),
      }),
  );

  // Intercept the mutation and capture its payload — without firing
  // the real backend write so this test can't be perturbed by other
  // tests sharing today's schedule.
  let submittedBody = null;
  await page.route(
    /\/api\/executive-meetings\/\d+\/postpone-minutes$/,
    async (route) => {
      submittedBody = JSON.parse(route.request().postData() || "{}");
      await route.fulfill({
        status: 200,
        contentType: "application/json",
        body: JSON.stringify({ ok: true, cascadedIds: [] }),
      });
    },
  );

  await page.getByTestId("alert-postpone").click();
  await expect(page.getByTestId("postpone-dialog")).toBeVisible();
  await page.getByTestId("postpone-chip-10").click();
  await expect(page.getByTestId("postpone-confirm-block")).toBeVisible();
  await page.getByTestId("postpone-confirm-yes").click();

  // The submit must fire without ever rendering the cascade prompt.
  await expect.poll(() => submittedBody, { timeout: 5_000 }).not.toBeNull();
  expect(submittedBody.cascadeFollowing).toBe(false);
  expect(submittedBody.minutes).toBe(10);

  // Cascade prompt and its blocked variant must never have appeared.
  await expect(page.getByTestId("cascade-prompt")).toHaveCount(0);
  await expect(page.getByTestId("cascade-prompt-blocked")).toHaveCount(0);

  await page.unroute(/\/api\/executive-meetings\/\d+\/cascade-preview$/);
  await page.unroute(/\/api\/executive-meetings\/\d+\/postpone-minutes$/);
});


test("Upcoming-meeting alert: details panel renders attendees as numbered tables per group", async ({
  page,
}) => {
  // #479: each attendee group inside the expanded details panel is a
  // real <table> with a 1-based index column and the attendee name —
  // verifies the bulleted list was replaced and that index restarts
  // per group.
  await setLang(page, "en");
  const m = await insertImminentMeeting({
    titleAr: `${TEST_TAG} attlist AR`,
    titleEn: `${TEST_TAG} attlist EN`,
    deltaMins: 3,
  });
  // Two distinct groups so we can also assert numbering restarts at 1
  // for the second group.
  // Groups in the alert are delimited by `kind='subheading'` rows, not
  // by attendance_type — mirror the production data shape.
  const seedAttendees = [
    { name: "External Group", attendanceType: "external", sortOrder: 1, kind: "subheading" },
    { name: "External One", attendanceType: "external", sortOrder: 2, kind: "person" },
    { name: "External Two", attendanceType: "external", sortOrder: 3, kind: "person" },
    { name: "Internal Group", attendanceType: "internal", sortOrder: 4, kind: "subheading" },
    { name: "Internal One", attendanceType: "internal", sortOrder: 5, kind: "person" },
  ];
  for (const a of seedAttendees) {
    await pool.query(
      `INSERT INTO executive_meeting_attendees
         (meeting_id, name, attendance_type, sort_order, kind)
       VALUES ($1, $2, $3, $4, $5)`,
      [m.id, a.name, a.attendanceType, a.sortOrder, a.kind],
    );
  }

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/");
  await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
    timeout: 15_000,
  });
  await page.getByTestId("alert-details-toggle").click();
  const wrapper = page.getByTestId("alert-details-attendees");
  await expect(wrapper).toBeVisible();

  const tables = wrapper.locator("table");
  await expect(tables).toHaveCount(2);

  // First group: row 1 shows "1." next to the first external attendee.
  const firstGroupRows = tables.nth(0).locator("tbody tr");
  await expect(firstGroupRows.first()).toContainText("1.");
  await expect(firstGroupRows.first()).toContainText("External One");
  await expect(firstGroupRows.nth(1)).toContainText("2.");
  await expect(firstGroupRows.nth(1)).toContainText("External Two");

  // Second group: numbering restarts at 1.
  const secondGroupRows = tables.nth(1).locator("tbody tr");
  await expect(secondGroupRows.first()).toContainText("1.");
  await expect(secondGroupRows.first()).toContainText("Internal One");
});

test("Postpone dialog: cascade-followers table sits fully inside the dialog (no horizontal spill, RTL)", async ({
  page,
}) => {
  // #485: regression for the cascade preview overlapping the dialog
  // edge in Arabic. Seed a primary meeting + two followers later in
  // the same day so the cascade prompt definitely fires after
  // confirming a postpone, then assert the followers-list bounding
  // box sits within the dialog's bounding box.
  await setLang(page, "ar");
  const primary = await insertImminentMeeting({
    titleAr: `${TEST_TAG} cascade-layout primary AR ${"بعنوان طويل جداً يكفي لاختبار القص بالنقاط في عمود العنوان داخل اللوحة"}`,
    titleEn: `${TEST_TAG} cascade-layout primary EN with a sufficiently long title to verify column truncation`,
    deltaMins: 3,
  });
  await insertImminentMeeting({
    titleAr: `${TEST_TAG} cascade-layout follower-1 AR`,
    titleEn: `${TEST_TAG} cascade-layout follower-1 EN`,
    deltaMins: 30,
  });
  await insertImminentMeeting({
    titleAr: `${TEST_TAG} cascade-layout follower-2 AR`,
    titleEn: `${TEST_TAG} cascade-layout follower-2 EN`,
    deltaMins: 60,
  });
  await page.setViewportSize({ width: 1280, height: 800 });
  await loginViaUi(page, "admin", "admin123");
  await page.goto("/");
  await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
    timeout: 15_000,
  });
  await page.getByTestId("alert-postpone").click();
  const dialog = page.getByTestId("postpone-dialog");
  await expect(dialog).toBeVisible();
  await page.getByTestId("postpone-chip-10").click();
  await expect(page.getByTestId("postpone-confirm-block")).toBeVisible();
  await page.getByTestId("postpone-confirm-yes").click();

  // Cascade prompt must appear with this seeding (two later meetings
  // on the same day). Geometry checks then verify no spill.
  const followersList = page.getByTestId("cascade-followers-list");
  await expect(followersList).toBeVisible({ timeout: 8_000 });

  const dialogBox = await dialog.boundingBox();
  const tableBox = await followersList.boundingBox();
  expect(dialogBox).not.toBeNull();
  expect(tableBox).not.toBeNull();
  // Table must sit fully inside the dialog (1px tolerance for
  // sub-pixel rounding).
  expect(tableBox.x + 1).toBeGreaterThanOrEqual(dialogBox.x);
  expect(tableBox.x + tableBox.width).toBeLessThanOrEqual(
    dialogBox.x + dialogBox.width + 1,
  );

  // The truncating title cell must not push the times column out of
  // the dialog. Check the first follower row's last cell (times).
  const lastCell = page
    .locator('[data-testid^="cascade-follower-"]')
    .first()
    .locator("td")
    .last();
  const lastBox = await lastCell.boundingBox();
  expect(lastBox).not.toBeNull();
  expect(lastBox.x + lastBox.width).toBeLessThanOrEqual(
    dialogBox.x + dialogBox.width + 1,
  );

  // Politely back out so this seeded meeting doesn't preempt later
  // tests' alerts.
  await page.getByTestId("cascade-back").click();
  await pool.query(
    `UPDATE executive_meetings
        SET start_time = (start_time::time + interval '2 hours')::time,
            end_time   = (end_time::time   + interval '2 hours')::time
      WHERE id = $1`,
    [primary.id],
  );
});

test("Postpone dialog: reschedule date / start / end inputs sit fully inside the dialog (no clipping at desktop width, RTL)", async ({
  page,
}) => {
  // #485: regression for the layout overlap reported in Arabic. The
  // postpone dialog used to be sm:max-w-md (~448px) which clipped the
  // 3-column native date/time row against the dialog border in RTL.
  // After widening the dialog and tightening the column widths, every
  // input's bounding box must lie inside the dialog's bounding box.
  // (RTL is the worst-case writing direction here — LTR has the same
  // geometry; verifying RTL covers both.)
  await setLang(page, "ar");
  await insertImminentMeeting({
    titleAr: `${TEST_TAG} layout AR`,
    titleEn: `${TEST_TAG} layout EN`,
    deltaMins: 3,
  });
  await page.setViewportSize({ width: 1280, height: 800 });
  await loginViaUi(page, "admin", "admin123");
  await page.goto("/");
  await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
    timeout: 15_000,
  });
  await page.getByTestId("alert-postpone").click();
  const dialog = page.getByTestId("postpone-dialog");
  await expect(dialog).toBeVisible();
  await page.getByTestId("postpone-tab-reschedule").click();
  await expect(page.getByTestId("postpone-reschedule-panel")).toBeVisible();

  const dialogBox = await dialog.boundingBox();
  expect(dialogBox).not.toBeNull();
  for (const tid of ["reschedule-date", "reschedule-start", "reschedule-end"]) {
    const inputBox = await page.getByTestId(tid).boundingBox();
    expect(inputBox, `${tid} should have a bounding box`).not.toBeNull();
    // Inputs must sit fully within the dialog (small 1px tolerance for
    // sub-pixel rounding from native controls).
    expect(inputBox.x + 1).toBeGreaterThanOrEqual(dialogBox.x);
    expect(inputBox.x + inputBox.width).toBeLessThanOrEqual(
      dialogBox.x + dialogBox.width + 1,
    );
  }
});
