// E2E test for: when a sender sends a note, the recipient (in
// another browser context) sees the note pop up as a centered modal on
// their screen — wherever they are in the app — within ~seconds.
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 = [];

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

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 (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 sees a popup modal when a note arrives in real time", async ({
  browser,
}) => {
  // Two-context flow with socket warmup + login UI + send confirmation
  // routinely runs ~25-35s; default 30s leaves no margin.
  test.setTimeout(90_000);
  const sender = await createUser("popup_sender", "Popup Sender");
  const recipient = await createUser("popup_recipient", "Popup Recipient");

  // Two isolated browser contexts (separate cookies → separate sessions).
  const senderCtx = await browser.newContext();
  const recipientCtx = await browser.newContext();
  const senderPage = await senderCtx.newPage();
  const recipientPage = await recipientCtx.newPage();

  try {
    // Recipient signs in and sits on the home page (NOT /notes), to
    // prove the popup surfaces wherever they are in the app.
    await loginViaUi(recipientPage, recipient.username);
    await recipientPage.goto("/");
    // Reset the audio play counter exposed by NotificationPlayer so we can
    // assert exactly-one chime fires for this incoming note.
    await recipientPage.evaluate(() => {
      window.__txosNotifPlayCount = 0;
      window.__txosNotifLastSound = undefined;
    });
    // Give the socket time to connect + warm up.
    await recipientPage.waitForTimeout(3500);

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

    const noteContent = `Drop everything and read this. ${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 createResp = await createPromise;
    const createdNote = await createResp.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;
    const sendCompletedAt = Date.now();

    // ---- Recipient should now see the floating popup card ----
    // Real-time SLA: socket-fanout popup must surface within ~3s of the
    // send returning. Anything slower defeats the "can't miss it" goal.
    const popup = recipientPage.getByTestId("incoming-note-popup");
    await expect(popup).toBeVisible({ timeout: 3_000 });
    expect(Date.now() - sendCompletedAt).toBeLessThan(3_000);
    await expect(popup).toContainText(noteContent);
    await expect(popup).toContainText(sender.displayEn);
    // Floating card — NOT a modal: must not render as an alertdialog
    // and must not block clicks behind it (no full-viewport overlay).
    await expect(popup).toHaveAttribute("data-popup-kind", "note");
    await expect(recipientPage.locator('[role="alertdialog"]')).toHaveCount(0);
    // The drag handle is the only pointer-event surface that initiates
    // a drag; presence is enough to confirm the floating-card affordance.
    await expect(
      recipientPage.getByTestId("incoming-note-popup-drag-handle"),
    ).toBeVisible();

    // Sender does NOT see their own popup.
    await expect(senderPage.getByTestId("incoming-note-popup")).toHaveCount(0);

    // Attention sound: the per-note chime should have fired exactly once
    // on the recipient (deduped by noteId, post-warmup) — and the sender
    // should NOT have heard anything for their own outgoing note.
    await expect
      .poll(
        async () =>
          recipientPage.evaluate(() => window.__txosNotifPlayCount ?? 0),
        { timeout: 5_000 },
      )
      .toBeGreaterThanOrEqual(1);
    const lastSound = await recipientPage.evaluate(
      () => window.__txosNotifLastSound,
    );
    expect(lastSound).toBe("knock");
    const senderPlayCount = await senderPage.evaluate(
      () => window.__txosNotifPlayCount ?? 0,
    );
    expect(senderPlayCount).toBe(0);

    // Persistent visual indicator: while the popup is queued, the Notes
    // app tile pulses (queue-length backed) so the user can't miss it
    // even after navigating away — covered by `app-icon-pulse-StickyNote`
    // (Notes' iconName). The pulse element is present on the home grid.
    // We don't strictly require Notes to be on the visible page (the
    // recipient sits on /), so we just check at least one pulse exists.
    await expect(
      recipientPage.locator('[data-testid^="app-icon-pulse-"]').first(),
    ).toBeVisible();

    // Dismissing acknowledges (closes) the popup.
    await recipientPage
      .getByTestId("incoming-note-popup-close")
      .click();
    await expect(popup).toBeHidden();

    // After dismissal, queueLength drops to 0 → pulse goes away.
    await expect(
      recipientPage.locator('[data-testid^="app-icon-pulse-"]'),
    ).toHaveCount(0);
  } finally {
    await senderCtx.close();
    await recipientCtx.close();
  }
});

// when the recipient replies on a note, the *original sender*
// (note owner) should see the same floating-card popup variant — pre-
// filled with the reply text + replier name — wherever they are in the
// app, with the same chime/vibration profile as the new-note alert.
test("sender sees a floating reply card when the recipient replies", async ({
  browser,
}) => {
  // Even longer than the new-note test: TWO send flows + reply submit +
  // socket warmups across two contexts.
  test.setTimeout(120_000);
  const sender = await createUser("reply_sender", "Reply Sender");
  const recipient = await createUser("reply_recipient", "Reply Recipient");

  const senderCtx = await browser.newContext();
  const recipientCtx = await browser.newContext();
  const senderPage = await senderCtx.newPage();
  const recipientPage = await recipientCtx.newPage();

  try {
    // Sender composes + sends the original note.
    await loginViaUi(senderPage, sender.username);
    await senderPage.goto("/notes");
    await expect(senderPage.getByTestId("notes-page")).toBeVisible();

    const noteContent = `Please reply with your thoughts. ${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 createResp = await createPromise;
    const createdNote = await createResp.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;

    // Sender navigates AWAY from /notes to prove the reply popup
    // surfaces wherever they are — same guarantee as the new-note popup.
    await senderPage.goto("/");
    await senderPage.evaluate(() => {
      window.__txosNotifPlayCount = 0;
      window.__txosNotifLastSound = undefined;
    });
    await senderPage.waitForTimeout(3500); // socket warmup window

    // Recipient signs in, opens the inbox, replies on the thread.
    await loginViaUi(recipientPage, recipient.username);
    await recipientPage.goto(`/notes?thread=${createdNote.id}&reply=1`);

    const replyText = `Reply body ${uniq()}`;
    const replyInput = recipientPage.getByTestId("thread-reply-input");
    await expect(replyInput).toBeVisible({ timeout: 10_000 });
    await replyInput.fill(replyText);
    const replyPromise = recipientPage.waitForResponse(
      (r) =>
        new URL(r.url()).pathname ===
          `/api/notes/${createdNote.id}/reply` &&
        r.request().method() === "POST" &&
        r.status() >= 200 &&
        r.status() < 300,
      { timeout: 15_000 },
    );
    await recipientPage.getByTestId("thread-reply-submit").click();
    await replyPromise;
    const replySentAt = Date.now();

    // ---- Original sender sees the floating REPLY card ----
    const popup = senderPage.getByTestId("incoming-note-popup");
    await expect(popup).toBeVisible({ timeout: 5_000 });
    expect(Date.now() - replySentAt).toBeLessThan(5_000);
    await expect(popup).toHaveAttribute("data-popup-kind", "reply");
    await expect(popup).toContainText(replyText);
    await expect(popup).toContainText(recipient.displayEn);
    // Same floating-card guarantees: no modal backdrop, drag handle present.
    await expect(senderPage.locator('[role="alertdialog"]')).toHaveCount(0);
    await expect(
      senderPage.getByTestId("incoming-note-popup-drag-handle"),
    ).toBeVisible();
    // Reply card now uses the same three-button action row as the
    // note variant — Done, Open note, Reply — so all three are
    // present. The header X is also a dismiss path. Done on the reply
    // variant only dismisses (markRead is a no-op for the note owner).
    await expect(
      senderPage.getByTestId("incoming-note-popup-mark-read"),
    ).toBeVisible();
    await expect(
      senderPage.getByTestId("incoming-note-popup-open"),
    ).toBeVisible();
    await expect(
      senderPage.getByTestId("incoming-note-popup-reply"),
    ).toBeVisible();

    // Same chime profile as new-note alert: notes channel, "knock" sound,
    // exactly one play (deduped by replyId).
    await expect
      .poll(
        async () =>
          senderPage.evaluate(() => window.__txosNotifPlayCount ?? 0),
        { timeout: 5_000 },
      )
      .toBeGreaterThanOrEqual(1);
    const lastSound = await senderPage.evaluate(
      () => window.__txosNotifLastSound,
    );
    expect(lastSound).toBe("knock");

    // The replier (recipient) does NOT see their own outgoing reply card.
    await expect(
      recipientPage.getByTestId("incoming-note-popup"),
    ).toHaveCount(0);

    // Header X closes the floating card.
    await senderPage.getByTestId("incoming-note-popup-close").click();
    await expect(popup).toBeHidden();
  } finally {
    await senderCtx.close();
    await recipientCtx.close();
  }
});

// a checklist note must render the inner sticky-note "as a
// real note" — i.e. the popup body shows a read-only checklist preview
// (not the plain text body), proving `noteKind` + `items` are forwarded
// from the backend through the socket payload into the popup.
test("recipient popup renders checklist items for a checklist note", async ({
  browser,
}) => {
  test.setTimeout(90_000);
  const sender = await createUser("popup_cl_sender", "Popup CL Sender");
  const recipient = await createUser("popup_cl_recipient", "Popup CL Recipient");

  const senderCtx = await browser.newContext();
  const recipientCtx = await browser.newContext();
  const senderPage = await senderCtx.newPage();
  const recipientPage = await recipientCtx.newPage();

  try {
    await loginViaUi(recipientPage, recipient.username);
    await recipientPage.goto("/");
    await recipientPage.waitForTimeout(3500);

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

    await senderPage.getByTestId("notes-composer-open").click();
    // Switch composer to checklist mode and add two items.
    await senderPage.getByTestId("notes-composer-kind-toggle").click();
    const list = senderPage.getByTestId("notes-composer-checklist-list");
    await expect(list).toBeVisible();
    // List starts empty — click "Add item" to insert the first row,
    // fill it, press Enter to spawn a second row, fill that one too.
    await senderPage.getByRole("button", { name: "Add item" }).click();
    // The ChecklistEditor uses shadcn `Input` (no explicit `type=text`),
    // so match any non-checkbox input inside the list container.
    const itemInputs = list.locator("input:not([type=checkbox])");
    await expect(itemInputs.first()).toBeVisible();
    await itemInputs.first().fill("Buy milk");
    await itemInputs.first().press("Enter");
    await expect(itemInputs.nth(1)).toBeVisible();
    await itemInputs.nth(1).fill("Walk dog");
    await itemInputs.nth(1).blur();

    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 createResp = await createPromise;
    const createdNote = await createResp.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;

    const popup = recipientPage.getByTestId("incoming-note-popup");
    await expect(popup).toBeVisible({ timeout: 5_000 });
    // Checklist branch: dedicated container is rendered, with each item
    // as a row, and the plain text content branch is NOT used.
    const checklist = recipientPage.getByTestId(
      "incoming-note-popup-checklist",
    );
    await expect(checklist).toBeVisible();
    await expect(checklist).toContainText("Buy milk");
    await expect(checklist).toContainText("Walk dog");

    await recipientPage.getByTestId("incoming-note-popup-close").click();
    await expect(popup).toBeHidden();
  } finally {
    await senderCtx.close();
    await recipientCtx.close();
  }
});
