// on touch devices (iPad / phone), tapping a button on the
// floating note popup MUST work — even when other floating UI is on
// screen at the same time. The previous root cause was a full-viewport
// `fixed inset-0` wrapper at z-[110] that intermittently swallowed
// taps on iOS Safari. This test emulates a touch viewport, sends a
// note across two contexts, and verifies a real `touchscreen.tap` on
// the Reply button navigates the recipient to the thread.
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 this UI test");
}

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

const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdUserIds = [];
const createdNoteIds = [];
const createdMeetingIds = [];

function uniq() {
  return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}

function todayLocal() {
  const d = new Date();
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function timePlusMinutes(deltaMins) {
  const d = new Date(Date.now() + deltaMins * 60_000);
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:00`;
}
async function insertImminentMeeting(titleEn, deltaMins = 3) {
  const date = todayLocal();
  const startTime = timePlusMinutes(deltaMins);
  const endTime = timePlusMinutes(deltaMins + 30);
  const { rows: dnRows } = await pool.query(
    `SELECT COALESCE(MAX(daily_number), 0) + 1 AS n FROM executive_meetings WHERE meeting_date = $1`,
    [date],
  );
  const dn = dnRows[0].n;
  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, `${titleEn} AR`, titleEn, date, startTime, endTime],
  );
  const id = rows[0].id;
  createdMeetingIds.push(id);
  return id;
}

async function createUser(prefix, displayEn) {
  const username = `${prefix}_${uniq()}`;
  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, displayEn],
  );
  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 = 'user'`,
    [id],
  );
  return { id, username, displayEn };
}

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],
    );
  }
  if (createdNoteIds.length > 0) {
    await pool.query(
      `DELETE FROM note_replies WHERE note_id = ANY($1::int[])`,
      [createdNoteIds],
    );
    await pool.query(
      `DELETE FROM note_recipients WHERE note_id = ANY($1::int[])`,
      [createdNoteIds],
    );
    await pool.query(`DELETE FROM notes WHERE id = ANY($1::int[])`, [
      createdNoteIds,
    ]);
  }
  if (createdUserIds.length > 0) {
    await pool.query(`DELETE FROM notes WHERE user_id = ANY($1::int[])`, [
      createdUserIds,
    ]);
    await pool.query(
      `DELETE FROM notifications WHERE user_id = ANY($1::int[])`,
      [createdUserIds],
    );
    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();
});

async function loginViaUi(page, username) {
  await page.goto("/login");
  await page.locator("#username").fill(username);
  await page.locator("#password").fill(TEST_PASSWORD);
  await Promise.all([
    page.waitForURL((url) => !url.pathname.endsWith("/login"), {
      timeout: 15_000,
    }),
    page.locator('form button[type="submit"]').click(),
  ]);
}

test("recipient on a touch device can tap Reply on the floating note popup", async ({
  browser,
}) => {
  test.setTimeout(120_000);
  const sender = await createUser("popup_touch_sender", "Touch Sender");
  const recipient = await createUser("popup_touch_recipient", "Touch Recipient");

  // Touch-emulating context for the recipient — chromium honours
  // hasTouch + isMobile so `(hover: none)` and `pointerType==='touch'`
  // both report correctly. We avoid devices["iPad …"] because that
  // pins to webkit, which isn't installed in this environment.
  const recipientCtx = await browser.newContext({
    hasTouch: true,
    isMobile: true,
    viewport: { width: 820, height: 1180 },
  });
  const senderCtx = await browser.newContext();
  const recipientPage = await recipientCtx.newPage();
  const senderPage = await senderCtx.newPage();

  try {
    await loginViaUi(recipientPage, recipient.username);
    await recipientPage.goto("/");
    // Sanity: the touch-only CSS path is what we depend on.
    expect(
      await recipientPage.evaluate(
        () => window.matchMedia("(hover: none)").matches,
      ),
    ).toBe(true);
    // Wait out the socket warmup window so events fanout cleanly.
    await recipientPage.waitForTimeout(3500);

    await loginViaUi(senderPage, sender.username);
    await senderPage.goto("/notes");
    await expect(senderPage.getByTestId("notes-page")).toBeVisible();

    const noteContent = `Tap reply on this card. ${uniq()}`;
    await senderPage.getByTestId("notes-composer-open").click();
    await senderPage.getByTestId("notes-composer-content").fill(noteContent);
    const createPromise = senderPage.waitForResponse(
      (r) =>
        new URL(r.url()).pathname === "/api/notes" &&
        r.request().method() === "POST" &&
        r.status() >= 200 &&
        r.status() < 300,
      { timeout: 15_000 },
    );
    await senderPage.getByTestId("notes-composer-save").click();
    const createdNote = await (await createPromise).json();
    createdNoteIds.push(createdNote.id);

    const card = senderPage.getByTestId(`note-card-${createdNote.id}`);
    await expect(card).toBeVisible();
    await card.hover();
    await senderPage.getByTestId(`note-card-send-${createdNote.id}`).click();
    await expect(senderPage.getByTestId("send-note-dialog")).toBeVisible();
    await senderPage.getByTestId("send-note-search").fill(recipient.displayEn);
    await senderPage
      .getByTestId(`send-recipient-option-${recipient.id}`)
      .click();
    const sendPromise = senderPage.waitForResponse(
      (r) =>
        new URL(r.url()).pathname === `/api/notes/${createdNote.id}/send` &&
        r.request().method() === "POST" &&
        r.status() >= 200 &&
        r.status() < 300,
      { timeout: 15_000 },
    );
    await senderPage.getByTestId("send-note-submit").click();
    await expect(senderPage.getByTestId("send-note-confirm")).toBeVisible();
    await senderPage.getByTestId("send-note-confirm-submit").click();
    await sendPromise;

    // ---- Recipient sees the floating popup ----
    const popup = recipientPage.getByTestId("incoming-note-popup");
    await expect(popup).toBeVisible({ timeout: 5_000 });

    // The popup must NOT be a full-viewport overlay. Before the fix
    // it was wrapped in a `fixed inset-0` div whose child captured
    // pointer events; after the fix the rendered popup root is
    // sized to the card itself. We verify the bounding box is well
    // smaller than the viewport in BOTH dimensions — the regression
    // would re-introduce a viewport-sized box.
    const box = await popup.boundingBox();
    expect(box).not.toBeNull();
    expect(box.width).toBeLessThan(500); // card is ~384 + ring/padding
    expect(box.height).toBeLessThan(800);

    // The redundant toast must NOT also surface — when the popup is
    // shown the toast is suppressed (#427) so we don't pile two
    // floating layers on top of each other on touch.
    await expect(
      recipientPage.locator('[data-state="open"][role="status"]'),
    ).toHaveCount(0);

    // ---- The actual regression: a TOUCH tap on Reply must work ----
    const replyBtn = recipientPage.getByTestId("incoming-note-popup-reply");
    await expect(replyBtn).toBeVisible();
    // `locator.tap()` requires `hasTouch: true` and fires a real
    // touchstart/touchend → synthesized click sequence — the same path
    // a finger tap takes on iOS Safari, which is what the user
    // reported hung.
    await replyBtn.tap();

    // Reply navigates to /notes and dismisses the popup. If the tap
    // were swallowed (the original hang) neither would happen.
    // (We don't assert the querystring here — wouter v3's `setLocation`
    // strips it; that's an unrelated pre-existing behaviour outside
    // the scope of #427.)
    await recipientPage.waitForURL(
      (url) => url.pathname === "/notes",
      { timeout: 5_000 },
    );
    await expect(popup).toBeHidden();
  } finally {
    await senderCtx.close();
    await recipientCtx.close();
  }
});

// Stacked-layer reproduction (#427): the user reported the hang
// specifically when the floating note popup AND the 5-min upcoming
// meeting alert are on screen at the same time. This test seeds an
// imminent meeting, has admin (the "recipient") log in on a touch
// viewport so the meeting alert appears, then triggers a real
// cross-user note delivery so the floating popup stacks on top of
// the alert. We then tap the popup's Reply button — the same gesture
// the user reported as hung — and assert it works.
test("popup + meeting alert simultaneously: tap on popup still works", async ({
  browser,
}) => {
  test.setTimeout(150_000);
  const sender = await createUser("popup_stack_sender", "Stack Sender");

  const meetingTitle = `STACK_TEST ${uniq()}`;
  await insertImminentMeeting(meetingTitle, 3);

  // Admin user (seeded by migrations) sees the upcoming meeting alert
  // on every page since the alert is global. We use admin as the
  // touch recipient so we don't need to wire up permissions for a
  // throwaway user.
  const recipientCtx = await browser.newContext({
    hasTouch: true,
    isMobile: true,
    viewport: { width: 820, height: 1180 },
  });
  const senderCtx = await browser.newContext();
  const recipientPage = await recipientCtx.newPage();
  const senderPage = await senderCtx.newPage();

  try {
    // Recipient logs in via the existing admin account.
    await recipientPage.goto("/login");
    await recipientPage.locator("#username").fill("admin");
    await recipientPage.locator("#password").fill("admin123");
    await Promise.all([
      recipientPage.waitForURL(
        (url) => !url.pathname.endsWith("/login"),
        { timeout: 15_000 },
      ),
      recipientPage.locator('form button[type="submit"]').click(),
    ]);
    await recipientPage.goto("/");

    // Wait for the meeting alert to surface (poll runs every 30s but
    // the initial fetch fires on mount).
    const alert = recipientPage.getByTestId("upcoming-meeting-alert");
    await expect(alert).toBeVisible({ timeout: 20_000 });

    // Look up admin's userId so the sender can target them.
    const { rows: adminRows } = await pool.query(
      `SELECT id FROM users WHERE username = 'admin' LIMIT 1`,
    );
    const adminId = adminRows[0].id;

    // Sender composes + sends a note to admin.
    await loginViaUi(senderPage, sender.username);
    await senderPage.goto("/notes");
    await expect(senderPage.getByTestId("notes-page")).toBeVisible();

    await senderPage.getByTestId("notes-composer-open").click();
    await senderPage
      .getByTestId("notes-composer-content")
      .fill(`Tap reply with the meeting alert visible too. ${uniq()}`);
    const createPromise = senderPage.waitForResponse(
      (r) =>
        new URL(r.url()).pathname === "/api/notes" &&
        r.request().method() === "POST" &&
        r.status() >= 200 &&
        r.status() < 300,
      { timeout: 15_000 },
    );
    await senderPage.getByTestId("notes-composer-save").click();
    const createdNote = await (await createPromise).json();
    createdNoteIds.push(createdNote.id);

    const card = senderPage.getByTestId(`note-card-${createdNote.id}`);
    await expect(card).toBeVisible();
    await card.hover();
    await senderPage.getByTestId(`note-card-send-${createdNote.id}`).click();
    await expect(senderPage.getByTestId("send-note-dialog")).toBeVisible();
    await senderPage.getByTestId("send-note-search").fill("admin");
    await senderPage
      .getByTestId(`send-recipient-option-${adminId}`)
      .click();
    const sendPromise = senderPage.waitForResponse(
      (r) =>
        new URL(r.url()).pathname === `/api/notes/${createdNote.id}/send` &&
        r.request().method() === "POST" &&
        r.status() >= 200 &&
        r.status() < 300,
      { timeout: 15_000 },
    );
    await senderPage.getByTestId("send-note-submit").click();
    await expect(senderPage.getByTestId("send-note-confirm")).toBeVisible();
    await senderPage.getByTestId("send-note-confirm-submit").click();
    await sendPromise;

    // ---- Both layers must be visible at the same time ----
    const popup = recipientPage.getByTestId("incoming-note-popup");
    await expect(popup).toBeVisible({ timeout: 10_000 });
    await expect(alert).toBeVisible();

    // The actual reproduction: tap Reply on the popup while the
    // meeting alert is also on screen. Pre-fix this would intermittently
    // hang on iOS Safari because the popup's `fixed inset-0` wrapper
    // sat above the alert and swallowed the tap.
    const replyBtn = recipientPage.getByTestId("incoming-note-popup-reply");
    await expect(replyBtn).toBeVisible();
    await replyBtn.tap();

    await recipientPage.waitForURL(
      (url) => url.pathname === "/notes",
      { timeout: 5_000 },
    );
    await expect(popup).toBeHidden();
    // The meeting alert should still be present after dismissing the
    // popup — they are independent floating layers.
    await expect(alert).toBeVisible();
  } finally {
    await senderCtx.close();
    await recipientCtx.close();
  }
});
