import { test, expect } from "@playwright/test";
import pg from "pg";

// E2E coverage for the four "interactive schedule" features that
// previously only had backend integration tests:
//   1. Tiptap rich-text title editing (bold + color, persisted as HTML)
//   2. Drag-to-reorder rows (dnd-kit) — daily numbers + start times swap
//   3. Custom highlight color from the customize popover — applied as
//      an inset box-shadow ring on the current meeting row
//   4. Non-mutate user (executive_viewer role) sees no grip handle
//
// Each scenario seeds its own meetings via direct DB inserts on a
// far-future date (or today, for the highlight scenario), so we never
// collide with real schedule data and afterAll cleanup leaves the DB
// untouched.

const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
  throw new Error(
    "DATABASE_URL must be set to run the executive-meetings schedule UI tests",
  );
}

const pool = new pg.Pool({ connectionString: DATABASE_URL });

// IDs we created so the afterAll cleanup can DELETE them.
const createdMeetingIds = [];
// Track temporary executive_viewer role assignments so we can revoke
// them on cleanup (the "ahmed" demo user must end the run with the
// same role set he started with).
const grantedRoleAssignments = []; // [{ userId, roleId }]

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(),
  ]);
}

// Wipe the schedule's persisted UI prefs so each test starts from a
// known baseline (view mode off, default highlight color, default
// columns, no per-row tints). Must be called AFTER login because the
// edit-mode storage key is namespaced by userId.
async function resetSchedulePrefs(page) {
  await page.evaluate(() => {
    try {
      const keys = Object.keys(window.localStorage);
      for (const k of keys) {
        if (
          k.startsWith("em-schedule-edit-mode-v1") ||
          k === "em-schedule-cols-v1" ||
          k === "em-schedule-row-colors-v1" ||
          k === "em-current-meeting-highlight-v1"
        ) {
          window.localStorage.removeItem(k);
        }
      }
    } catch {
      /* ignore */
    }
  });
}

async function setLangEn(page) {
  await page.addInitScript(() => {
    try {
      window.localStorage.setItem("tx-lang", "en");
    } catch {
      /* localStorage may not be ready before navigation */
    }
  });
}

// Insert a single attendee row directly. Used by the attendee-name
// editing scenario so we don't have to drive the manage-dialog flow
// just to seed initial state.
async function insertAttendee({
  meetingId,
  name,
  attendanceType,
  sortOrder,
}) {
  await pool.query(
    `INSERT INTO executive_meeting_attendees
       (meeting_id, name, attendance_type, sort_order)
     VALUES ($1, $2, $3, $4)`,
    [meetingId, name, attendanceType, sortOrder],
  );
}

// Insert a meeting row directly. Returns the new id.
async function insertMeeting({
  meetingDate,
  dailyNumber,
  titleEn,
  titleAr,
  startTime,
  endTime,
}) {
  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`,
    [
      dailyNumber,
      titleAr,
      titleEn,
      meetingDate,
      startTime,
      endTime,
    ],
  );
  const id = rows[0].id;
  createdMeetingIds.push(id);
  return id;
}

// Pick a meeting date that won't collide with any real schedule data
// OR with stale rows leftover from a previously-failed run. We jitter
// the year-out offset by a per-process random base so reruns within
// the same minute land on different days.
const RANDOM_DATE_BASE_DAYS =
  365 + Math.floor(Math.random() * 1000) + ((Date.now() / 1000) | 0) % 500;
function uniqueFutureDate(offsetDays) {
  const d = new Date();
  d.setUTCDate(d.getUTCDate() + RANDOM_DATE_BASE_DAYS + offsetDays * 7);
  return d.toISOString().slice(0, 10);
}

function todayIso() {
  return new Date().toISOString().slice(0, 10);
}

// HH:MM:SS string for a wall-clock time `minutesOffset` minutes from
// the local "now". Used to seed a meeting whose [startTime, endTime)
// window contains the moment the highlight test runs.
function localTimeWithOffset(minutesOffset) {
  const d = new Date();
  d.setMinutes(d.getMinutes() + minutesOffset);
  const hh = String(d.getHours()).padStart(2, "0");
  const mm = String(d.getMinutes()).padStart(2, "0");
  return `${hh}:${mm}:00`;
}

test.afterAll(async () => {
  if (createdMeetingIds.length > 0) {
    await pool.query(
      `DELETE FROM executive_meeting_attendees 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_meetings WHERE id = ANY($1::int[])`,
      [createdMeetingIds],
    );
  }
  for (const { userId, roleId } of grantedRoleAssignments) {
    await pool.query(
      `DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2`,
      [userId, roleId],
    );
  }
  await pool.end();
});

test("Schedule: editing a title with bold + color via the Tiptap toolbar persists after reload", async ({
  page,
}) => {
  await setLangEn(page);

  const date = uniqueFutureDate(1);
  const uniq = `${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 6)}`;
  const originalTitle = `RichEdit Original ${uniq}`;
  const meetingId = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: originalTitle,
    titleAr: originalTitle,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/executive-meetings");
  await resetSchedulePrefs(page);
  await page.reload();

  // Jump to the seeded date.
  const dateInput = page.locator('input[type="date"]').first();
  await dateInput.fill(date);

  // Wait for the row we just inserted.
  const row = page.getByTestId(`em-row-${meetingId}`);
  await expect(row).toBeVisible({ timeout: 10_000 });

  // Flip on edit mode so the EditableCell becomes interactive.
  const toggle = page.getByTestId("em-edit-mode-toggle");
  await toggle.click();
  await expect(toggle).toHaveAttribute("aria-pressed", "true");

  // Open the title cell editor and apply bold + a red color to ALL of
  // the existing text, then save via the toolbar's check button. We
  // deliberately keep the original text — the assertion is that the
  // toolbar persists *formatting*, not that typing also works (typing
  // is covered by the existing "edit toggle" tests).
  const titleCell = page.getByTestId(`em-edit-title-${meetingId}`);
  await titleCell.click();

  const toolbar = page.getByTestId("em-edit-toolbar");
  await expect(toolbar).toBeVisible();

  // Wait for the contenteditable inside the editor to be ready, then
  // select the entire title so the bold + color marks apply to every
  // character. The editor itself opens with the cursor at the end of
  // the existing text, so a simple ControlOrMeta+a is enough.
  const editorContent = titleCell.locator('[contenteditable="true"]');
  await expect(editorContent).toBeVisible();
  await page.keyboard.press("ControlOrMeta+a");
  await page.getByTestId("em-edit-bold").click();
  await page.getByTestId("em-edit-color-red").click();

  // Watch for the PATCH that saves the title before clicking save.
  const savePromise = page.waitForResponse(
    (resp) => {
      const u = new URL(resp.url());
      return (
        u.pathname === `/api/executive-meetings/${meetingId}` &&
        resp.request().method() === "PATCH" &&
        resp.ok()
      );
    },
    { timeout: 15_000 },
  );
  await page.getByTestId("em-edit-save").click();
  await savePromise;
  await expect(toolbar).toBeHidden();

  // Reload the page to confirm the formatting wasn't just sitting in
  // a Tiptap document — it must round-trip via the server.
  await page.reload();
  await dateInput.fill(date);
  const rowAfterReload = page.getByTestId(`em-row-${meetingId}`);
  await expect(rowAfterReload).toBeVisible({ timeout: 10_000 });

  const titleAfterReload = page.getByTestId(`em-edit-title-${meetingId}`);
  await expect(titleAfterReload).toContainText(originalTitle);

  // Inspect the rendered HTML in the cell. The server-sanitized output
  // must keep the bold tag and the inline color span. We accept either
  // <strong> (StarterKit's default tag) or <b> (some sanitizers
  // collapse to <b>) so the assertion stays robust against the
  // server's exact allowlist serialization.
  const html = await titleAfterReload.innerHTML();
  expect(html.toLowerCase()).toMatch(/<(strong|b)[\s>]/);
  // The Tiptap "red" swatch is #dc2626. Browsers serialize inline
  // styles in either hex or rgb form; accept both.
  expect(html.toLowerCase()).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/);

  // And confirm the DB itself stored the formatted HTML, not the plain
  // text — this catches any frontend-only "looks formatted" regression.
  // The page may render in either Arabic or English depending on the
  // signed-in user's preferredLanguage, so we check whichever column
  // the editor wrote to (the EditableCell saves to title_ar when RTL,
  // title_en otherwise).
  const { rows: dbRows } = await pool.query(
    `SELECT title_ar, title_en FROM executive_meetings WHERE id = $1`,
    [meetingId],
  );
  expect(dbRows.length).toBe(1);
  const editedColumn = String(
    /<(strong|b)[\s>]/i.test(String(dbRows[0].title_ar))
      ? dbRows[0].title_ar
      : dbRows[0].title_en,
  ).toLowerCase();
  expect(editedColumn).toMatch(/<(strong|b)[\s>]/);
  expect(editedColumn).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/);
});

test("Schedule: dragging row 2 above row 1 swaps daily numbers and start times", async ({
  page,
}) => {
  await setLangEn(page);

  const date = uniqueFutureDate(2);
  const uniq = `${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 6)}`;
  const idA = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: `Drag A ${uniq}`,
    titleAr: `سحب أ ${uniq}`,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });
  const idB = await insertMeeting({
    meetingDate: date,
    dailyNumber: 2,
    titleEn: `Drag B ${uniq}`,
    titleAr: `سحب ب ${uniq}`,
    startTime: "11:00:00",
    endTime: "12:00:00",
  });

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/executive-meetings");
  await resetSchedulePrefs(page);
  await page.reload();

  await page.locator('input[type="date"]').first().fill(date);

  await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
    timeout: 10_000,
  });
  await expect(page.getByTestId(`em-row-${idB}`)).toBeVisible();

  // Edit mode must be on for the grip handle to render.
  const toggle = page.getByTestId("em-edit-mode-toggle");
  await toggle.click();
  await expect(toggle).toHaveAttribute("aria-pressed", "true");

  // #489: the dedicated GripVertical handle was retired. The whole
  // <tr> is now the drag handle, gated by `effectiveCanMutate =
  // canMutate && editMode`. We drag from the # cell (the first <td>)
  // because it contains only the daily-number <span> — no skip-listed
  // descendants (buttons, inputs, em-edit-* / em-time-*) that
  // safeRowDragListeners filters out.
  const numberCellB = page
    .locator(`[data-testid="em-row-${idB}"] td`)
    .first();
  const fromBox = await numberCellB.boundingBox();
  const toBox = await page
    .locator(`[data-testid="em-row-${idA}"]`)
    .boundingBox();
  expect(fromBox).not.toBeNull();
  expect(toBox).not.toBeNull();

  // dnd-kit's PointerSensor activates only after a 6px move from the
  // pointerdown position, so we issue a small "warm-up" move before
  // the long move to the target. Using `steps` keeps the synthetic
  // pointer events spread across enough frames for dnd-kit to pick
  // them up reliably.
  const startX = fromBox.x + fromBox.width / 2;
  const startY = fromBox.y + fromBox.height / 2;
  const endX = startX;
  // Aim slightly ABOVE the target row's vertical center so dnd-kit
  // inserts the dragged row before the target instead of after.
  const endY = toBox.y + 4;

  // Drag-row reorder now goes through /rotate-content (#489).
  const reorderPromise = page.waitForResponse(
    (resp) => {
      const u = new URL(resp.url());
      return (
        u.pathname === "/api/executive-meetings/rotate-content" &&
        resp.request().method() === "POST" &&
        resp.ok()
      );
    },
    { timeout: 15_000 },
  );

  await page.mouse.move(startX, startY);
  await page.mouse.down();
  await page.mouse.move(startX, startY + 12, { steps: 5 });
  await page.mouse.move(endX, endY, { steps: 15 });
  await page.mouse.up();

  await reorderPromise;

  // rotate-content keeps each chronological slot's (start_time,
  // end_time, daily_number) anchored and rotates only the meeting
  // CONTENT into the new positions. After moving B above A, B
  // inherits slot 1 (09:00–10:00, dn=1) and A inherits slot 2
  // (11:00–12:00, dn=2) — same observable end-state as the legacy
  // /reorder API for this two-row case.
  const { rows } = await pool.query(
    `SELECT id, daily_number, start_time, end_time
       FROM executive_meetings WHERE id = ANY($1::int[]) ORDER BY id`,
    [[idA, idB]],
  );
  const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
  expect(byId[idB].daily_number).toBe(1);
  expect(String(byId[idB].start_time)).toBe("09:00:00");
  expect(String(byId[idB].end_time)).toBe("10:00:00");
  expect(byId[idA].daily_number).toBe(2);
  expect(String(byId[idA].start_time)).toBe("11:00:00");
  expect(String(byId[idA].end_time)).toBe("12:00:00");

  // A reload should reflect the new visual order in the table.
  await page.reload();
  await page.locator('input[type="date"]').first().fill(date);
  await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
    timeout: 10_000,
  });
  const rowsInOrder = await page
    .locator('tr[data-testid^="em-row-"]')
    .evaluateAll((els) => els.map((el) => el.getAttribute("data-testid")));
  // Drop any rows from other dates (defensive — the schedule is
  // date-scoped so there shouldn't be any, but just in case).
  const orderedIds = rowsInOrder
    .map((tid) => Number(tid?.replace("em-row-", "")))
    .filter((n) => n === idA || n === idB);
  expect(orderedIds).toEqual([idB, idA]);
});

test("Schedule: choosing a custom highlight color paints the current meeting's box-shadow ring", async ({
  page,
}) => {
  await setLangEn(page);

  // The "current meeting" detection requires the row to be on TODAY
  // and for now() to fall inside [startTime, endTime). Build a 60-min
  // window centered on now so the test is robust to a few seconds of
  // drift between page load and the highlight tick.
  const date = todayIso();
  const startTime = localTimeWithOffset(-30);
  const endTime = localTimeWithOffset(+30);

  // Use a dailyNumber high enough not to collide with anything seeded
  // elsewhere in the test suite for today. The DB has a UNIQUE
  // (meeting_date, daily_number) index so we pick something out of
  // the way and recover with a single retry on collision.
  let meetingId = null;
  for (const dailyNumber of [991, 992, 993, 994, 995]) {
    try {
      meetingId = await insertMeeting({
        meetingDate: date,
        dailyNumber,
        titleEn: `Highlight Now ${dailyNumber}`,
        titleAr: `إبراز الآن ${dailyNumber}`,
        startTime,
        endTime,
      });
      break;
    } catch (err) {
      if (!/duplicate/i.test(String(err.message))) throw err;
    }
  }
  expect(meetingId).not.toBeNull();

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/executive-meetings");
  await resetSchedulePrefs(page);
  await page.reload();

  // Today is the default date, but we set it explicitly to make the
  // test robust to any future change of the page's initial state.
  await page.locator('input[type="date"]').first().fill(date);

  const row = page.getByTestId(`em-row-${meetingId}`);
  await expect(row).toBeVisible({ timeout: 10_000 });

  // The seeded row should be flagged as the current meeting via the
  // data attribute. Wait for it — the highlight scheduling tick can
  // need a frame to land after the data arrives.
  await expect(row).toHaveAttribute("data-current-meeting", "true", {
    timeout: 5_000,
  });

  // Default highlight color is green (#16a34a). The ring is rendered
  // as `inset 0 0 0 2px <color>` via inline box-shadow on the <tr>.
  const initialShadow = await row.evaluate(
    (el) => getComputedStyle(el).boxShadow,
  );
  // Computed style normalizes hex to rgb; #16a34a → rgb(22, 163, 74).
  expect(initialShadow).toMatch(/22,\s*163,\s*74/);

  // #265: Highlight controls now live in the unified Settings tab.
  // Hop over, flip the swatch, hop back to the schedule.
  await page.getByTestId("em-nav-settings").click();
  const highlightToggle = page.getByTestId("em-highlight-toggle");
  await expect(highlightToggle).toBeVisible();
  // Make sure the highlight is enabled (the default is on, but assert
  // it explicitly so the test isn't sensitive to future default flips).
  const isOn = await highlightToggle.getAttribute("aria-checked");
  if (isOn !== "true") {
    await highlightToggle.click();
    await expect(highlightToggle).toHaveAttribute("aria-checked", "true");
  }

  await page.getByTestId("em-highlight-color-#dc2626").click();
  await page.getByTestId("em-nav-schedule").click();

  // The ring should now reflect the new color: #dc2626 → rgb(220, 38, 38).
  await expect
    .poll(
      async () => row.evaluate((el) => getComputedStyle(el).boxShadow),
      { timeout: 5_000 },
    )
    .toMatch(/220,\s*38,\s*38/);
});

test("Schedule: a non-mutate user (executive_viewer) sees no grip handle and no edit toggle", async ({
  page,
}) => {
  await setLangEn(page);

  // Seed a meeting so there's something to (try to) drag.
  const date = uniqueFutureDate(3);
  const uniq = `${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 6)}`;
  const meetingId = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: `Viewer Only ${uniq}`,
    titleAr: `مشاهد فقط ${uniq}`,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });

  // Grant the demo "ahmed" user the executive_viewer role (read-only).
  // We track the assignment so afterAll can revoke it. The role is
  // already seeded by scripts/src/seed.ts.
  const { rows: ahmedRows } = await pool.query(
    `SELECT id FROM users WHERE username = 'ahmed'`,
  );
  expect(ahmedRows.length).toBe(1);
  const ahmedId = ahmedRows[0].id;

  const { rows: roleRows } = await pool.query(
    `SELECT id FROM roles WHERE name = 'executive_viewer'`,
  );
  expect(roleRows.length).toBe(1);
  const viewerRoleId = roleRows[0].id;

  const { rowCount } = await pool.query(
    `INSERT INTO user_roles (user_id, role_id)
       VALUES ($1, $2)
       ON CONFLICT DO NOTHING`,
    [ahmedId, viewerRoleId],
  );
  // Only schedule cleanup if WE were the ones who added the row —
  // otherwise we'd revoke a role ahmed had legitimately.
  if (rowCount === 1) {
    grantedRoleAssignments.push({ userId: ahmedId, roleId: viewerRoleId });
  }

  await loginViaUi(page, "ahmed", "user123");
  await page.goto("/executive-meetings");
  await resetSchedulePrefs(page);
  await page.reload();

  await page.locator('input[type="date"]').first().fill(date);

  // The seeded row must be visible (read access works).
  const row = page.getByTestId(`em-row-${meetingId}`);
  await expect(row).toBeVisible({ timeout: 10_000 });

  // No grip handles anywhere — they only render when the row prop
  // canMutate is true, which equals me.canMutate && editMode.
  await expect(
    page.locator('[data-testid^="em-row-grip-"]'),
  ).toHaveCount(0);

  // The view/edit toggle is rendered ONLY for users who have the
  // mutate capability, so a viewer should never see it.
  await expect(page.getByTestId("em-edit-mode-toggle")).toHaveCount(0);

  // Sanity: the customize popover is still available (it's purely a
  // presentation control and does not require mutate access), but the
  // row-color trigger and merge trigger — both edit-mode-only — must
  // not be present.
  await expect(page.getByTestId("em-row-color-trigger")).toHaveCount(0);
  await expect(
    page.locator('[data-testid^="em-merge-trigger-"]'),
  ).toHaveCount(0);

  // Reorder API must reject a viewer's POST as well — defense in depth.
  const reorderResp = await page.request.post(
    "/api/executive-meetings/reorder",
    {
      data: { meetingDate: date, orderedIds: [meetingId] },
    },
  );
  expect(reorderResp.status()).toBeGreaterThanOrEqual(400);
});

test("Schedule: editing an attendee name with bold + color via the Tiptap toolbar persists after reload", async ({
  page,
}) => {
  await setLangEn(page);

  // Seed a meeting with one attendee whose name is plain text. The
  // attendee row uses the SAME EditableCell + Tiptap toolbar as titles
  // (parent), so applying bold + a color swatch and saving
  // via the toolbar's check button must round-trip through the PUT
  // attendees endpoint and survive a full reload, just like titles do.
  const date = uniqueFutureDate(4);
  const uniq = `${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 6)}`;
  const originalName = `RichAttendee Original ${uniq}`;
  const meetingId = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: `Attendee Host ${uniq}`,
    titleAr: `مضيف ${uniq}`,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });
  await insertAttendee({
    meetingId,
    name: originalName,
    attendanceType: "internal",
    sortOrder: 0,
  });

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/executive-meetings");
  await resetSchedulePrefs(page);
  await page.reload();

  // Jump to the seeded date and wait for the row.
  const dateInput = page.locator('input[type="date"]').first();
  await dateInput.fill(date);

  const row = page.getByTestId(`em-row-${meetingId}`);
  await expect(row).toBeVisible({ timeout: 10_000 });

  // Flip on edit mode so the attendee EditableCell becomes interactive.
  const toggle = page.getByTestId("em-edit-mode-toggle");
  await toggle.click();
  await expect(toggle).toHaveAttribute("aria-pressed", "true");

  // The testid uses the original index in meeting.attendees (0). Scope
  // under the seeded row so the selector stays unambiguous.
  const attendeeCell = row.getByTestId(`em-edit-attendee-0`);
  await expect(attendeeCell).toBeVisible();
  await expect(attendeeCell).toContainText(originalName);

  // Click to enter edit mode, then select the entire name and apply
  // bold + the red color swatch. Same flow as the title test above —
  // we keep the original text so the assertion is purely about the
  // toolbar persisting *formatting*, not typing.
  await attendeeCell.click();

  const toolbar = page.getByTestId("em-edit-toolbar");
  await expect(toolbar).toBeVisible();

  const editorContent = attendeeCell.locator('[contenteditable="true"]');
  await expect(editorContent).toBeVisible();
  await page.keyboard.press("ControlOrMeta+a");
  await page.getByTestId("em-edit-bold").click();
  await page.getByTestId("em-edit-color-red").click();

  // Watch for the PUT that saves the attendees array before clicking
  // save. Inline attendee edits go through PUT /attendees, NOT the
  // meeting-level PATCH used by titles.
  const savePromise = page.waitForResponse(
    (resp) => {
      const u = new URL(resp.url());
      return (
        u.pathname === `/api/executive-meetings/${meetingId}/attendees` &&
        resp.request().method() === "PUT" &&
        resp.ok()
      );
    },
    { timeout: 15_000 },
  );
  await page.getByTestId("em-edit-save").click();
  await savePromise;
  await expect(toolbar).toBeHidden();

  // Reload to confirm the formatting wasn't just sitting in the local
  // Tiptap document — it must round-trip via the server.
  await page.reload();
  await dateInput.fill(date);
  const rowAfterReload = page.getByTestId(`em-row-${meetingId}`);
  await expect(rowAfterReload).toBeVisible({ timeout: 10_000 });

  const attendeeAfterReload = rowAfterReload.getByTestId(`em-edit-attendee-0`);
  await expect(attendeeAfterReload).toContainText(originalName);

  // Inspect the rendered HTML in the cell. The server-sanitized output
  // must keep the bold tag and the inline color span. Accept either
  // <strong> (StarterKit's default tag) or <b> (some sanitizers
  // collapse to <b>) so the assertion stays robust against the
  // server's exact allowlist serialization.
  const html = await attendeeAfterReload.innerHTML();
  expect(html.toLowerCase()).toMatch(/<(strong|b)[\s>]/);
  // The Tiptap "red" swatch is #dc2626. Browsers serialize inline
  // styles in either hex or rgb form; accept both.
  expect(html.toLowerCase()).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/);

  // And confirm the DB itself stored the formatted HTML, not the plain
  // text — this catches any frontend-only "looks formatted" regression.
  // Attendees have a single `name` column (no AR/EN split), so the
  // assertion is simpler than the title case.
  const { rows: dbRows } = await pool.query(
    `SELECT name FROM executive_meeting_attendees
       WHERE meeting_id = $1 ORDER BY sort_order`,
    [meetingId],
  );
  expect(dbRows.length).toBe(1);
  const storedName = String(dbRows[0].name).toLowerCase();
  expect(storedName).toMatch(/<(strong|b)[\s>]/);
  expect(storedName).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/);
  // The original plain text is still readable inside the formatted markup.
  expect(storedName).toContain(originalName.toLowerCase());
});

test("Schedule: dragging the attendees column header above the meeting column reorders headers and persists across reload", async ({
  page,
}) => {
  await setLangEn(page);

  // Seed a single meeting so the schedule actually renders rows. The
  // floating sticky thead (which carries the interactive
  // SortableHeader cells) is rendered regardless, but dragging into
  // an empty grid is brittle — having a row keeps the layout stable.
  const date = uniqueFutureDate(5);
  const uniq = `${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 6)}`;
  const meetingId = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: `ColReorder ${uniq}`,
    titleAr: `إعادة ترتيب ${uniq}`,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/executive-meetings");
  await resetSchedulePrefs(page);
  await page.reload();

  await page.locator('input[type="date"]').first().fill(date);
  await expect(page.getByTestId(`em-row-${meetingId}`)).toBeVisible({
    timeout: 10_000,
  });

  // Sanity: the column customizer panel is still surfaced from the
  // Settings tab (#265 moved it out of a popover into Settings). We
  // hop over to confirm it exists, then bounce back to the schedule
  // where the actual header drag happens — column reordering is wired
  // to the SortableHeader cells inside the schedule's floating thead,
  // not to chips inside the customizer panel.
  await page.getByTestId("em-nav-settings").click();
  await expect(page.getByTestId("em-customize-columns-panel")).toBeVisible();
  await page.getByTestId("em-nav-schedule").click();

  // Edit mode must be on for SortableHeader to register dnd-kit
  // attributes/listeners (dragEnabled === effectiveCanMutate).
  const toggle = page.getByTestId("em-edit-mode-toggle");
  await toggle.click();
  await expect(toggle).toHaveAttribute("aria-pressed", "true");

  // Read the initial header order. DEFAULT_COLUMNS is
  // [number, meeting, attendees, time].
  const readHeaderOrder = () =>
    page
      .locator('th[data-testid^="em-col-header-"]')
      .evaluateAll((els) => els.map((el) => el.getAttribute("data-testid")));

  expect(await readHeaderOrder()).toEqual([
    "em-col-header-number",
    "em-col-header-meeting",
    "em-col-header-attendees",
    "em-col-header-time",
  ]);

  const attendeesHeader = page.getByTestId("em-col-header-attendees");
  const meetingHeader = page.getByTestId("em-col-header-meeting");
  await expect(attendeesHeader).toBeVisible();
  await expect(meetingHeader).toBeVisible();

  const fromBox = await attendeesHeader.boundingBox();
  const toBox = await meetingHeader.boundingBox();
  expect(fromBox).not.toBeNull();
  expect(toBox).not.toBeNull();

  // dnd-kit's PointerSensor activates only after a 6px move from the
  // pointerdown position, so we issue a small "warm-up" move before
  // the long move to the target. SortableContext uses
  // horizontalListSortingStrategy here, so we drop the pointer
  // slightly LEFT of the target's horizontal center to insert the
  // dragged header BEFORE the target instead of after.
  const startX = fromBox.x + fromBox.width / 2;
  const startY = fromBox.y + fromBox.height / 2;
  const endX = toBox.x + 4;
  const endY = toBox.y + toBox.height / 2;

  await page.mouse.move(startX, startY);
  await page.mouse.down();
  await page.mouse.move(startX - 12, startY, { steps: 5 });
  await page.mouse.move(endX, endY, { steps: 15 });
  await page.mouse.up();

  // After the drag, the headers should reflect the new order:
  // attendees moves above meeting.
  const expectedOrder = [
    "em-col-header-number",
    "em-col-header-attendees",
    "em-col-header-meeting",
    "em-col-header-time",
  ];
  await expect.poll(readHeaderOrder, { timeout: 5_000 }).toEqual(expectedOrder);

  // The new order must be persisted to localStorage under the key
  // documented in the task (em-schedule-cols-v1).
  const stored = await page.evaluate(() =>
    window.localStorage.getItem("em-schedule-cols-v1"),
  );
  expect(stored).not.toBeNull();
  const storedIds = JSON.parse(stored).map((c) => c.id);
  expect(storedIds).toEqual(["number", "attendees", "meeting", "time"]);

  // Reload to confirm persistence — the order must restore from
  // localStorage rather than reverting to DEFAULT_COLUMNS.
  await page.reload();
  await page.locator('input[type="date"]').first().fill(date);
  await expect(page.getByTestId(`em-row-${meetingId}`)).toBeVisible({
    timeout: 10_000,
  });

  expect(await readHeaderOrder()).toEqual(expectedOrder);
});

test("Schedule: when the reorder API rejects a row drag, the UI rolls back to the original order and surfaces an error toast", async ({
  page,
}) => {
  await setLangEn(page);

  // Seed two meetings on a unique future date so we own the order
  // entirely. The optimistic UI flow we exercise here:
  //   1. Drag row B above row A → setOptimisticOrder([B, A])
  //   2. POST /api/executive-meetings/reorder
  //   3. Server responds 500 → setOptimisticOrder(null) + error toast
  //   4. DOM order returns to [A, B]
  // We assert both the rollback and the user-facing error toast.
  // Use offset 6 — offsets 1..5 are already claimed by other tests in
  // this file. The afterAll cleanup is file-level, so two tests sharing
  // a date + daily_number would hit the UNIQUE (meeting_date,
  // daily_number) index. Picking a free offset keeps the seed
  // collision-proof without needing duplicate-retry logic.
  const date = uniqueFutureDate(6);
  const uniq = `${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 6)}`;
  const idA = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: `Rollback A ${uniq}`,
    titleAr: `تراجع أ ${uniq}`,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });
  const idB = await insertMeeting({
    meetingDate: date,
    dailyNumber: 2,
    titleEn: `Rollback B ${uniq}`,
    titleAr: `تراجع ب ${uniq}`,
    startTime: "11:00:00",
    endTime: "12:00:00",
  });

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/executive-meetings");
  await resetSchedulePrefs(page);
  await page.reload();

  await page.locator('input[type="date"]').first().fill(date);

  await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
    timeout: 10_000,
  });
  await expect(page.getByTestId(`em-row-${idB}`)).toBeVisible();

  // Capture the DOM order BEFORE we install the route override. This
  // is the order we expect to see again after the rollback.
  const readDomOrder = async () => {
    const rowsInOrder = await page
      .locator('tr[data-testid^="em-row-"]')
      .evaluateAll((els) => els.map((el) => el.getAttribute("data-testid")));
    return rowsInOrder
      .map((tid) => Number(tid?.replace("em-row-", "")))
      .filter((n) => n === idA || n === idB);
  };
  expect(await readDomOrder()).toEqual([idA, idB]);

  // Edit mode must be on for the grip handle to render.
  const toggle = page.getByTestId("em-edit-mode-toggle");
  await toggle.click();
  await expect(toggle).toHaveAttribute("aria-pressed", "true");

  // Intercept the rotate-content POST (#489 replaced /reorder for
  // drag-driven reorders) and respond with a 500 so the mutation
  // throws and triggers the onError rollback branch (re-set query
  // cache + destructive toast). We track the hits so the assertion
  // below can prove the request really fired (otherwise a missed
  // drag would silently pass the rollback check).
  let reorderHits = 0;
  await page.route(
    "**/api/executive-meetings/rotate-content",
    async (route) => {
      reorderHits += 1;
      await route.fulfill({
        status: 500,
        contentType: "application/json",
        // apiJson() reads `data.error` for the thrown message, so use
        // `error` (not `message`) to make the description deterministic.
        body: JSON.stringify({ error: "Simulated reorder failure" }),
      });
    },
  );

  // #489: drag from the # cell of row B (the dedicated grip is gone).
  const numberCellB = page
    .locator(`[data-testid="em-row-${idB}"] td`)
    .first();
  const fromBox = await numberCellB.boundingBox();
  const toBox = await page
    .locator(`[data-testid="em-row-${idA}"]`)
    .boundingBox();
  expect(fromBox).not.toBeNull();
  expect(toBox).not.toBeNull();

  // Same drag mechanics as the success-path reorder test above:
  // dnd-kit's PointerSensor needs a small warm-up move past its 6px
  // activation distance, then a stepped move to the target. Aim
  // slightly above the target row's vertical center so dnd-kit
  // inserts B before A.
  const startX = fromBox.x + fromBox.width / 2;
  const startY = fromBox.y + fromBox.height / 2;
  const endX = startX;
  const endY = toBox.y + 4;

  const failedReorderPromise = page.waitForResponse(
    (resp) => {
      const u = new URL(resp.url());
      return (
        u.pathname === "/api/executive-meetings/rotate-content" &&
        resp.status() === 500
      );
    },
    { timeout: 15_000 },
  );

  await page.mouse.move(startX, startY);
  await page.mouse.down();
  await page.mouse.move(startX, startY + 12, { steps: 5 });
  await page.mouse.move(endX, endY, { steps: 15 });
  await page.mouse.up();

  await failedReorderPromise;
  expect(reorderHits).toBeGreaterThanOrEqual(1);

  // The page renders a destructive toast with title = t("common.error")
  // ("An error occurred" / "حدث خطأ" depending on the signed-in user's
  // preferred language) and description = the thrown error message.
  // We assert against the description because it comes straight from
  // our route override's response body and is therefore independent of
  // the UI language. Wait for it before asserting the rollback, since
  // the toast and the optimistic-order reset both happen synchronously
  // in the onError branch — once the toast is visible we know the
  // rollback has already run.
  const errorToastDesc = page.getByText("Simulated reorder failure", {
    exact: true,
  });
  await expect(errorToastDesc).toBeVisible({ timeout: 5_000 });

  // Also verify the title is one of the localized "error" strings so
  // we catch a regression that drops the title entirely.
  const errorToastTitle = page.getByText(/^(An error occurred|حدث خطأ)$/);
  await expect(errorToastTitle.first()).toBeVisible();

  // The DOM order must have returned to the original [A, B]. Use
  // expect.poll so we tolerate a render frame between the
  // setOptimisticOrder(null) call and React flushing the new order.
  await expect
    .poll(readDomOrder, { timeout: 5_000 })
    .toEqual([idA, idB]);

  // Defense in depth: the database must be untouched — neither row's
  // daily_number, start_time, nor end_time should have moved, since
  // the server "rejected" the request and the optimistic UI was rolled
  // back. (The route override prevented any real PATCH from running,
  // but we assert against the DB anyway so a future regression that
  // somehow fires the request through a different code path can't
  // sneak past.)
  const { rows } = await pool.query(
    `SELECT id, daily_number, start_time, end_time
       FROM executive_meetings WHERE id = ANY($1::int[]) ORDER BY id`,
    [[idA, idB]],
  );
  const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
  expect(byId[idA].daily_number).toBe(1);
  expect(String(byId[idA].start_time)).toBe("09:00:00");
  expect(String(byId[idA].end_time)).toBe("10:00:00");
  expect(byId[idB].daily_number).toBe(2);
  expect(String(byId[idB].start_time)).toBe("11:00:00");
  expect(String(byId[idB].end_time)).toBe("12:00:00");

  await page.unroute("**/api/executive-meetings/rotate-content");
});
