// E2E test: the note thread dialog must cap its height instead of growing
// to fill the entire viewport when the conversation is long. Regression
// guard for (user reported the dialog covering the whole page).
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) {
  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, 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 = 'user'`,
    [id],
  );
  return { id, username };
}

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 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("thread dialog does not exceed viewport height when conversation is long", async ({
  page,
}) => {
  const VIEWPORT_HEIGHT = 720;
  await page.setViewportSize({ width: 1280, height: VIEWPORT_HEIGHT });

  const owner = await createUser("thread_dialog_owner");
  const r1 = await createUser("thread_dialog_recip1");
  const r2 = await createUser("thread_dialog_recip2");

  await loginViaUi(page, owner.username);

  // Create the note as the owner.
  const noteResp = await page.request.post("/api/notes", {
    data: { title: `Long Thread ${uniq()}`, content: "kickoff message" },
  });
  expect(noteResp.ok()).toBeTruthy();
  const note = await noteResp.json();
  createdNoteIds.push(note.id);

  // Snapshot-deliver to two recipients via SQL (mirrors what /send does).
  for (const rcp of [r1, r2]) {
    await pool.query(
      `INSERT INTO note_recipients
         (note_id, sender_user_id, recipient_user_id, title, content, color, status)
       VALUES ($1, $2, $3, $4, $5, 'default', 'replied')`,
      [note.id, owner.id, rcp.id, note.title, note.content],
    );
  }

  // Insert a long conversation alternating between owner and each recipient
  // (~15 replies per recipient → 30 total) so the OWNER thread view renders
  // many GroupedReplies bubbles.
  for (const rcp of [r1, r2]) {
    for (let i = 0; i < 15; i++) {
      const fromOwner = i % 2 === 0;
      const sender = fromOwner ? owner.id : rcp.id;
      const recipient = fromOwner ? rcp.id : owner.id;
      await pool.query(
        `INSERT INTO note_replies (note_id, sender_user_id, recipient_user_id, content)
         VALUES ($1, $2, $3, $4)`,
        [note.id, sender, recipient, `Reply #${i + 1} in this conversation`],
      );
    }
  }

  // Deep-link opens the thread dialog regardless of which tab is active.
  await page.goto(`/notes?thread=${note.id}`);
  const dialog = page.getByTestId("note-thread-dialog");
  await expect(dialog).toBeVisible();

  // Wait for the thread payload to land so the GroupedReplies block renders.
  await expect(page.getByTestId("note-thread-scroll")).toBeVisible();
  await expect(
    dialog.getByTestId(`thread-conversation-${r1.id}`),
  ).toBeVisible();

  // The dialog must stay capped at ~85vh and never exceed 90vh, regardless
  // of how many replies it contains. The composer + header should still be
  // reachable (composer stays pinned outside the scroll region).
  const box = await dialog.boundingBox();
  expect(box).not.toBeNull();
  expect(box.height).toBeLessThanOrEqual(VIEWPORT_HEIGHT * 0.9);

  // The internal scroll container should actually overflow (proving content
  // is being scrolled, not truncated).
  const scroller = page.getByTestId("note-thread-scroll");
  const scrollInfo = await scroller.evaluate((el) => ({
    scrollHeight: el.scrollHeight,
    clientHeight: el.clientHeight,
  }));
  expect(scrollInfo.scrollHeight).toBeGreaterThan(scrollInfo.clientHeight);

  // Recipient chips and composer must stay pinned while replies scroll —
  // both should remain in the viewport even after we scroll the replies
  // region all the way down.
  const r1Chip = dialog.getByTestId(`thread-recipient-${r1.id}`);
  const r2Chip = dialog.getByTestId(`thread-recipient-${r2.id}`);
  const composer = page.getByTestId("thread-reply-input");
  await expect(r1Chip).toBeInViewport();
  await expect(r2Chip).toBeInViewport();
  await expect(composer).toBeInViewport();

  await scroller.evaluate((el) => {
    el.scrollTop = el.scrollHeight;
  });

  await expect(r1Chip).toBeInViewport();
  await expect(r2Chip).toBeInViewport();
  await expect(composer).toBeInViewport();
});
