// #486 → #489 → #491: e2e for the Executive Meetings schedule row
// quick-actions surface. After #489, the Move up / Move down buttons
// are gone (the user drags the whole row instead — see
// executive-meetings-row-drag.spec.mjs). After #491, the surface is a
// centered Dialog (Radix portal) rather than a row-anchored Popover,
// and the originating row carries a coloured frame while the surface
// is open. The surface still exposes only the Postpone action, and
// the gating + skip rules around interactive row affordances stay the
// same.
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 row-quick-actions test",
  );
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
const createdUserIds = [];

const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
  "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";

async function createUserWithRole(prefix, roleName) {
  const username = `${prefix}_${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 8)}`;
  const { rows } = await pool.query(
    `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
     VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
    [username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix],
  );
  const id = rows[0].id;
  createdUserIds.push(id);
  await pool.query(
    `INSERT INTO user_roles (user_id, role_id)
       SELECT $1, id FROM roles WHERE name = $2`,
    [id, roleName],
  );
  return { id, username };
}

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

function pickFutureDate(offsetDays) {
  const d = new Date(Date.now() + offsetDays * 86_400_000);
  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}`;
}

const DATE_POSTPONE = pickFutureDate(32);
const DATE_SKIP = pickFutureDate(33);
const DATE_VIEWER = pickFutureDate(34);
const DATE_NO_MOVE_BUTTONS = pickFutureDate(35);

async function gotoTestDate(page, date) {
  await page.goto("/executive-meetings");
  const dateInput = page.locator('input[type="date"]').first();
  await dateInput.waitFor({ state: "visible", timeout: 10_000 });
  await dateInput.fill(date);
  await dateInput.dispatchEvent("change");
}

async function nextDailyNumberForDate(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 insertMeeting({ date, titleEn, startTime, endTime }) {
  const dn = await nextDailyNumberForDate(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, $2, $3, $4, $5, 'scheduled')
     RETURNING id`,
    [dn, titleEn, date, startTime, endTime],
  );
  const id = rows[0].id;
  createdMeetingIds.push(id);
  return id;
}

async function readMeeting(id) {
  const { rows } = await pool.query(
    `SELECT start_time, end_time, daily_number FROM executive_meetings WHERE id = $1`,
    [id],
  );
  return rows[0];
}

test.afterAll(async () => {
  if (createdMeetingIds.length > 0) {
    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],
    );
  }
  if (createdUserIds.length > 0) {
    await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
      createdUserIds,
    ]);
    await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
      createdUserIds,
    ]);
  }
  await pool.end();
});

test("Postpone quick-action surface is a centered Dialog and runs a 5-minute postpone end-to-end", async ({
  page,
}) => {
  const id = await insertMeeting({
    date: DATE_POSTPONE,
    titleEn: "QA Postpone Target",
    startTime: "13:00:00",
    endTime: "13:30:00",
  });

  await loginViaUi(page, "admin", "admin123");
  await gotoTestDate(page, DATE_POSTPONE);
  await expect(page.locator(`[data-testid="em-row-${id}"]`)).toBeVisible({
    timeout: 10_000,
  });

  const row = page.locator(`[data-testid="em-row-${id}"]`);
  await row.click();
  const surface = page.locator(`[data-testid="em-row-quick-${id}"]`);
  await expect(surface).toBeVisible();
  // #491: surface is now a centered Dialog (Radix portals it under
  // `[role='dialog']`), not a row-anchored Popover. While the surface
  // is open, the originating row carries `data-quick-open="true"` so
  // it shows the coloured frame.
  await expect(surface).toHaveAttribute("role", "dialog");
  await expect(row).toHaveAttribute("data-quick-open", "true");
  // Geometric centering: the surface's bounding box should sit close
  // to the viewport center on both axes. We allow a generous 25%
  // tolerance so the assertion is robust against the design-system
  // Dialog primitive's exact padding/widths but still catches a
  // regression to row-anchored placement (which would land far off
  // center, especially on tall lists).
  const sBox = await surface.boundingBox();
  const vp = page.viewportSize();
  expect(sBox).not.toBeNull();
  expect(vp).not.toBeNull();
  const sCenterX = sBox.x + sBox.width / 2;
  const sCenterY = sBox.y + sBox.height / 2;
  expect(Math.abs(sCenterX - vp.width / 2)).toBeLessThan(vp.width * 0.25);
  expect(Math.abs(sCenterY - vp.height / 2)).toBeLessThan(vp.height * 0.25);
  await surface.locator(`[data-testid="em-row-quick-postpone-${id}"]`).click();
  // After clicking Postpone the quick-actions surface closes and the
  // row's selected-frame marker disappears.
  await expect(row).not.toHaveAttribute("data-quick-open", "true");
  const dialog = page.locator('[data-testid="postpone-dialog"]');
  await expect(dialog).toBeVisible({ timeout: 5_000 });
  await dialog.locator('[data-testid="postpone-chip-5"]').click();
  await dialog.locator('[data-testid="postpone-confirm-yes"]').click();
  await expect.poll(async () => (await readMeeting(id)).start_time).toBe(
    "13:05:00",
  );
  const after = await readMeeting(id);
  expect(after.end_time).toBe("13:35:00");
});

test("Escape closes the centered surface AND clears the selected-row frame", async ({
  page,
}) => {
  const id = await insertMeeting({
    date: DATE_POSTPONE,
    titleEn: "QA Frame Clear",
    startTime: "16:00:00",
    endTime: "16:30:00",
  });
  await loginViaUi(page, "admin", "admin123");
  await gotoTestDate(page, DATE_POSTPONE);
  const row = page.locator(`[data-testid="em-row-${id}"]`);
  await expect(row).toBeVisible({ timeout: 10_000 });
  await row.click();
  const surface = page.locator(`[data-testid="em-row-quick-${id}"]`);
  await expect(surface).toBeVisible();
  await expect(row).toHaveAttribute("data-quick-open", "true");
  // Press Escape — Radix Dialog default close path.
  await page.keyboard.press("Escape");
  await expect(surface).toBeHidden();
  await expect(row).not.toHaveAttribute("data-quick-open", "true");

  // Re-open and verify outside-click (on the modal overlay) also
  // dismisses the surface and clears the row frame — the second
  // expected close path for a centered Dialog.
  await row.click();
  await expect(surface).toBeVisible();
  await expect(row).toHaveAttribute("data-quick-open", "true");
  // Radix renders an overlay above the page that intercepts pointer
  // events for outside-dismiss. Click near a corner so we don't land
  // on the centered DialogContent.
  const overlay = page.locator('[data-radix-dialog-overlay], [data-state="open"][class*="fixed inset-0"]').first();
  if (await overlay.count()) {
    await overlay.click({ position: { x: 5, y: 5 }, force: true });
  } else {
    await page.mouse.click(5, 5);
  }
  await expect(surface).toBeHidden();
  await expect(row).not.toHaveAttribute("data-quick-open", "true");
});

test("RTL (Arabic): quick-actions surface centers and frames row with Arabic label", async ({
  page,
}) => {
  // Force Arabic locale before navigation so the surface renders with
  // RTL direction and the Arabic Postpone label.
  await page.addInitScript(() => {
    try {
      window.localStorage.setItem("tx-lang", "ar");
    } catch {
      /* ignore */
    }
  });
  const date = pickFutureDate(36);
  const id = await insertMeeting({
    date,
    titleEn: "QA RTL Surface",
    startTime: "09:00:00",
    endTime: "09:30:00",
  });
  await loginViaUi(page, "admin", "admin123");
  await gotoTestDate(page, date);
  const row = page.locator(`[data-testid="em-row-${id}"]`);
  await expect(row).toBeVisible({ timeout: 10_000 });
  await row.click();
  const surface = page.locator(`[data-testid="em-row-quick-${id}"]`);
  await expect(surface).toBeVisible();
  // Surface mounts in RTL.
  await expect(surface).toHaveAttribute("dir", "rtl");
  // Row carries the selected-frame marker.
  await expect(row).toHaveAttribute("data-quick-open", "true");
  // Postpone button shows the Arabic label.
  const postponeBtn = surface.locator(
    `[data-testid="em-row-quick-postpone-${id}"]`,
  );
  await expect(postponeBtn).toContainText("تأجيل");
});

test("quick-actions surface only exposes Postpone — Move up/down buttons retired in #489", async ({
  page,
}) => {
  // Two rows so neighbours exist either way — confirms the popover
  // omits Move up/down even when there's a chronological neighbour.
  const id = await insertMeeting({
    date: DATE_NO_MOVE_BUTTONS,
    titleEn: "QA NoMove A",
    startTime: "10:00:00",
    endTime: "10:30:00",
  });
  await insertMeeting({
    date: DATE_NO_MOVE_BUTTONS,
    titleEn: "QA NoMove B",
    startTime: "11:00:00",
    endTime: "11:30:00",
  });
  await loginViaUi(page, "admin", "admin123");
  await gotoTestDate(page, DATE_NO_MOVE_BUTTONS);
  await expect(page.locator(`[data-testid="em-row-${id}"]`)).toBeVisible({
    timeout: 10_000,
  });
  await page.locator(`[data-testid="em-row-${id}"]`).click();
  const popover = page.locator(`[data-testid="em-row-quick-${id}"]`);
  await expect(popover).toBeVisible();
  const postponeBtn = popover.locator(
    `[data-testid="em-row-quick-postpone-${id}"]`,
  );
  await expect(postponeBtn).toBeVisible();
  // #490 polish: Postpone uses the design-system Button — guard the
  // tap target + min-width affordance so a future styling regression
  // doesn't silently revert it back to a plain text button on iPad.
  const cls = (await postponeBtn.getAttribute("class")) ?? "";
  expect(cls).toContain("h-10");
  expect(cls).toContain("min-w-[11rem]");
  // Icon + label both rendered (icon flips with locale via flex
  // direction so it stays adjacent to the label in RTL).
  await expect(postponeBtn.locator("svg")).toHaveCount(1);
  await expect(postponeBtn).toContainText(/postpone|تأجيل/i);
  // Hard guard: the old up/down buttons must no longer mount.
  await expect(
    popover.locator(`[data-testid="em-row-quick-up-${id}"]`),
  ).toHaveCount(0);
  await expect(
    popover.locator(`[data-testid="em-row-quick-down-${id}"]`),
  ).toHaveCount(0);
});

test("clicking row affordances (time cell, row-actions) does NOT open the quick-actions surface", async ({
  page,
}) => {
  const id = await insertMeeting({
    date: DATE_SKIP,
    titleEn: "QA Skip Surfaces",
    startTime: "14:00:00",
    endTime: "14:30:00",
  });
  await loginViaUi(page, "admin", "admin123");
  await gotoTestDate(page, DATE_SKIP);
  await expect(page.locator(`[data-testid="em-row-${id}"]`)).toBeVisible({
    timeout: 10_000,
  });
  const popover = page.locator(`[data-testid="em-row-quick-${id}"]`);

  // #489: the dedicated grip handle is gone; the whole row is the drag
  // handle. The row's own click handler still skips interactive cells.
  await expect(
    page.locator(`[data-testid="em-row-grip-${id}"]`),
  ).toHaveCount(0);

  // Time cell — clicking enters inline edit mode in TimeRangeCell.
  // Must NOT also open the quick-actions popover.
  const timeCell = page.locator(`[data-testid="em-time-${id}"]`);
  await timeCell.click();
  await expect(popover).toBeHidden();
  await page.keyboard.press("Escape");

  // Row-actions trigger — opens the row's own dropdown, not our popover.
  const rowActions = page.locator(`[data-testid="em-row-actions-${id}"]`);
  if (await rowActions.count()) {
    await rowActions.click();
    await expect(popover).toBeHidden();
    await page.keyboard.press("Escape");
  }

  // Sanity: clicking an empty cell of the row DOES open the popover.
  await page
    .locator(`[data-testid="em-row-${id}"]`)
    .click({ position: { x: 5, y: 5 } });
  await expect(popover).toBeVisible({ timeout: 5_000 });
});

test("non-mutator (executive_viewer) row click does NOT open quick-actions surface", async ({
  page,
}) => {
  const id = await insertMeeting({
    date: DATE_VIEWER,
    titleEn: "QA Viewer Click",
    startTime: "15:00:00",
    endTime: "15:30:00",
  });
  const viewer = await createUserWithRole("qa_viewer", "executive_viewer");
  await loginViaUi(page, viewer.username, TEST_PASSWORD);
  await gotoTestDate(page, DATE_VIEWER);
  await expect(page.locator(`[data-testid="em-row-${id}"]`)).toBeVisible({
    timeout: 10_000,
  });

  await page.locator(`[data-testid="em-row-${id}"]`).click();
  await page.waitForTimeout(750);
  await expect(
    page.locator(`[data-testid="em-row-quick-${id}"]`),
  ).toHaveCount(0);
});
