// E2E test for the user-defined Notes Folders feature:
//   - Create a folder from the rail
//   - Drag a note from "Unfiled" into the new folder
//   - Filter by selected folder shows only notes in that folder
//   - Drag the note from one folder into another
//   - Rename + delete a folder; deleting moves its notes back to Unfiled
//   - Verifies bilingual rendering: re-runs the rail title check in Arabic
//     so the RTL label is exercised end-to-end.
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 };
}

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

// Drive a real pointer drag so @dnd-kit's PointerSensor activates. The
// sensor's activation constraint is `distance: 8`, so we first nudge the
// pointer past the threshold from the source, then move to the target in
// steps (so over-targets are detected on the way), then release.
async function dragNoteToFolderTarget(page, noteId, dropTargetTestId) {
  const src = page.getByTestId(`note-card-${noteId}`).first();
  const dst = page.getByTestId(dropTargetTestId).first();
  // Scroll the destination first; source typically remains in view (notes
  // grid sits beside or below the folder area).  Then measure BOTH boxes
  // after scrolling, otherwise the source coordinates can be stale.
  await dst.scrollIntoViewIfNeeded();
  await src.scrollIntoViewIfNeeded();
  await dst.scrollIntoViewIfNeeded();
  const sBox = await src.boundingBox();
  const dBox = await dst.boundingBox();
  if (!sBox) throw new Error(`source note-card-${noteId} has no bounding box`);
  if (!dBox) throw new Error(`target ${dropTargetTestId} has no bounding box`);
  const sx = sBox.x + sBox.width / 2;
  const sy = sBox.y + sBox.height / 2;
  const dx = dBox.x + dBox.width / 2;
  const dy = dBox.y + dBox.height / 2;
  await page.mouse.move(sx, sy);
  await page.mouse.down();
  // First nudge — must exceed PointerSensor's 8px activation distance.
  await page.mouse.move(sx + 12, sy + 12, { steps: 5 });
  // Then walk to the destination so dnd-kit tracks the over-target.
  await page.mouse.move(dx, dy, { steps: 10 });
  // Hold briefly so dnd-kit's collision detection settles before drop.
  await page.waitForTimeout(50);
  await page.mouse.up();
}

async function createNoteViaApi(page, title) {
  const resp = await page.request.post("/api/notes", {
    data: { title, content: "body of " + title },
  });
  expect(resp.ok()).toBeTruthy();
  const note = await resp.json();
  createdNoteIds.push(note.id);
  return note;
}

test("create folder, drag note in, filter, move between folders, rename, delete", async ({
  page,
}) => {
  const user = await createUser("notes_folders_e2e", "Folder Tester");
  await loginViaUi(page, user.username);

  // Seed two notes via the API so the test focuses on folder UX.
  const note1 = await createNoteViaApi(page, `Folder Note 1 ${uniq()}`);
  const note2 = await createNoteViaApi(page, `Folder Note 2 ${uniq()}`);

  await page.goto("/notes");
  await expect(page.getByTestId("notes-page")).toBeVisible();

  // Folders rail is visible on the My Notes tab.
  const rail = page.getByTestId("notes-folders-rail");
  await expect(rail).toBeVisible();
  await expect(page.getByTestId("folder-drop-all")).toBeVisible();
  await expect(page.getByTestId("folder-drop-unfiled")).toBeVisible();

  // Both new notes should be unfiled.
  await expect(page.getByTestId("folder-drop-unfiled-count")).toHaveText(/^2$/);

  // Create folder "Projects".
  await page.getByTestId("folder-create-open").click();
  await page.getByTestId("folder-create-input").fill("Projects");
  const createProjectsResp = page.waitForResponse(
    (r) =>
      new URL(r.url()).pathname === "/api/note-folders" &&
      r.request().method() === "POST" &&
      r.status() >= 200 &&
      r.status() < 300,
  );
  await page.getByTestId("folder-create-submit").click();
  const projectsResp = await createProjectsResp;
  const projects = await projectsResp.json();

  // Create folder "Personal".
  await page.getByTestId("folder-create-open").click();
  await page.getByTestId("folder-create-input").fill("Personal");
  const createPersonalResp = page.waitForResponse(
    (r) =>
      new URL(r.url()).pathname === "/api/note-folders" &&
      r.request().method() === "POST" &&
      r.status() >= 200 &&
      r.status() < 300,
  );
  await page.getByTestId("folder-create-submit").click();
  const personalResp = await createPersonalResp;
  const personal = await personalResp.json();

  // Both folders show up with count 0.
  await expect(page.getByTestId(`folder-select-${projects.id}`)).toBeVisible();
  await expect(page.getByTestId(`folder-select-${personal.id}`)).toBeVisible();
  await expect(
    page.getByTestId(`folder-drop-${projects.id}-count`),
  ).toHaveText(/^0$/);

  // Newly created folder is auto-selected — Personal was the most recent
  // create, so its row should be the active selection (button has the
  // selected styling: bg-foreground text-background).
  await expect(
    page.getByTestId(`folder-drop-${personal.id}`),
  ).toHaveClass(/bg-foreground/);
  // Reset selection to "All" so the rest of the flow filters correctly.
  await page.getByTestId("folder-drop-all").click();

  // ---- Drag note1 into Projects ----
  const movePromise1 = page.waitForResponse(
    (r) =>
      new URL(r.url()).pathname === `/api/notes/${note1.id}` &&
      r.request().method() === "PATCH" &&
      r.status() >= 200 &&
      r.status() < 300,
  );
  await dragNoteToFolderTarget(page, note1.id, `folder-drop-${projects.id}`);
  await movePromise1;

  // Counts update.
  await expect(
    page.getByTestId(`folder-drop-${projects.id}-count`),
  ).toHaveText(/^1$/);
  await expect(page.getByTestId("folder-drop-unfiled-count")).toHaveText(/^1$/);

  // ---- Persistence after a full page reload ----
  await page.reload();
  await expect(page.getByTestId("notes-page")).toBeVisible();
  await expect(
    page.getByTestId(`folder-drop-${projects.id}-count`),
  ).toHaveText(/^1$/);
  await page.getByTestId(`folder-select-${projects.id}`).click();
  await expect(page.getByTestId(`note-card-${note1.id}`)).toBeVisible();
  await expect(page.getByTestId(`note-card-${note2.id}`)).toHaveCount(0);
  await page.getByTestId("folder-drop-all").click();

  // ---- Filter by Projects: only note1 visible ----
  await page.getByTestId(`folder-select-${projects.id}`).click();
  await expect(page.getByTestId(`note-card-${note1.id}`)).toBeVisible();
  await expect(page.getByTestId(`note-card-${note2.id}`)).toHaveCount(0);

  // ---- Filter Unfiled: only note2 visible ----
  await page.getByTestId("folder-drop-unfiled").click();
  await expect(page.getByTestId(`note-card-${note2.id}`)).toBeVisible();
  await expect(page.getByTestId(`note-card-${note1.id}`)).toHaveCount(0);

  // ---- Drag note2 into Personal, then move it from Personal to Projects ----
  await page.getByTestId("folder-drop-all").click();
  const movePromise2 = page.waitForResponse(
    (r) =>
      new URL(r.url()).pathname === `/api/notes/${note2.id}` &&
      r.request().method() === "PATCH" &&
      r.status() >= 200 &&
      r.status() < 300,
  );
  await dragNoteToFolderTarget(page, note2.id, `folder-drop-${personal.id}`);
  await movePromise2;
  await expect(
    page.getByTestId(`folder-drop-${personal.id}-count`),
  ).toHaveText(/^1$/);

  const movePromise3 = page.waitForResponse(
    (r) =>
      new URL(r.url()).pathname === `/api/notes/${note2.id}` &&
      r.request().method() === "PATCH" &&
      r.status() >= 200 &&
      r.status() < 300,
  );
  await dragNoteToFolderTarget(page, note2.id, `folder-drop-${projects.id}`);
  await movePromise3;
  await expect(
    page.getByTestId(`folder-drop-${projects.id}-count`),
  ).toHaveText(/^2$/);
  await expect(
    page.getByTestId(`folder-drop-${personal.id}-count`),
  ).toHaveText(/^0$/);

  // ---- Rename Projects -> "Work" ----
  await page.getByTestId(`folder-menu-${projects.id}`).click();
  await page.getByTestId(`folder-rename-${projects.id}`).click();
  const renameInput = page.getByTestId(`folder-rename-input-${projects.id}`);
  await renameInput.fill("Work");
  const renamePromise = page.waitForResponse(
    (r) =>
      new URL(r.url()).pathname === `/api/note-folders/${projects.id}` &&
      r.request().method() === "PATCH" &&
      r.status() >= 200 &&
      r.status() < 300,
  );
  await page.getByTestId(`folder-rename-save-${projects.id}`).click();
  await renamePromise;
  await expect(page.getByTestId(`folder-select-${projects.id}`)).toContainText(
    "Work",
  );

  // ---- Move note1 INTO Personal so the folder is non-empty, then delete it
  // and verify the note returns to Unfiled (FK ON DELETE SET NULL). ----
  const movePromiseToPersonal = page.waitForResponse(
    (r) =>
      new URL(r.url()).pathname === `/api/notes/${note1.id}` &&
      r.request().method() === "PATCH" &&
      r.status() >= 200 &&
      r.status() < 300,
  );
  await dragNoteToFolderTarget(page, note1.id, `folder-drop-${personal.id}`);
  await movePromiseToPersonal;
  await expect(
    page.getByTestId(`folder-drop-${personal.id}-count`),
  ).toHaveText(/^1$/);

  const deletePromise = page.waitForResponse(
    (r) =>
      new URL(r.url()).pathname === `/api/note-folders/${personal.id}` &&
      r.request().method() === "DELETE" &&
      r.status() >= 200 &&
      r.status() < 300,
  );
  await page.getByTestId(`folder-menu-${personal.id}`).click();
  await page.getByTestId(`folder-delete-${personal.id}`).click();
  // In-app AlertDialog (replacing native window.confirm) — click confirm.
  await page.getByTestId(`folder-delete-confirm-${personal.id}`).click();
  await deletePromise;
  await expect(
    page.getByTestId(`folder-delete-dialog-${personal.id}`),
  ).toHaveCount(0);
  await expect(page.getByTestId(`folder-select-${personal.id}`)).toHaveCount(0);

  // The orphaned note should have folderId reset to null via FK SET NULL,
  // so it should now appear under "Unfiled".
  const allNotes = await (await page.request.get(`/api/notes`)).json();
  const note1AfterDelete = allNotes.find((n) => n.id === note1.id);
  expect(note1AfterDelete).toBeDefined();
  expect(note1AfterDelete.folderId).toBeNull();
  await expect(page.getByTestId("folder-drop-unfiled-count")).toHaveText(/^1$/);

  // ---- Cross-user safety: another user's folder cannot be assigned. ----
  const otherUser = await createUser("notes_folders_other", "Other User");
  const { rows: otherFolderRows } = await pool.query(
    `INSERT INTO note_folders (user_id, name) VALUES ($1, $2) RETURNING id`,
    [otherUser.id, `OtherFolder_${uniq()}`],
  );
  const otherFolderId = otherFolderRows[0].id;
  const crossResp = await page.request.patch(`/api/notes/${note2.id}`, {
    data: { folderId: otherFolderId },
  });
  expect(crossResp.status()).toBe(400);
  await pool.query(`DELETE FROM note_folders WHERE id = $1`, [otherFolderId]);

  await page.getByTestId("folder-drop-all").click();

  // ---- Bilingual smoke check: flip the user to Arabic, reload, and verify
  // the rail renders RTL with the Arabic copy. ----
  await pool.query(
    `UPDATE users SET preferred_language = 'ar' WHERE id = $1`,
    [user.id],
  );
  await page.evaluate(() => {
    localStorage.setItem("tx-lang", "ar");
  });
  await page.reload();
  await expect(page.getByTestId("notes-page")).toHaveAttribute("dir", "rtl");
  await expect(page.getByTestId("notes-folders-rail")).toBeVisible();
  await expect(page.getByTestId(`folder-select-${projects.id}`)).toContainText(
    "Work",
  );
  await expect(page.getByTestId("folder-drop-unfiled")).toContainText(
    "بدون مجلد",
  );
});

// Mobile / touch fallback: long-press on a note card then drag the finger to
// a folder row should move the note via the touch drop pipeline (no HTML5
// drag events). Also verifies the rail renders as a horizontal chip row at a
// narrow viewport.
test("touch long-press drags a note into a folder on small viewport", async ({
  browser,
}) => {
  // Narrow viewport with touch capability. We deliberately leave `isMobile`
  // false so Playwright's `page.mouse` API still fires pointer events that
  // @dnd-kit's PointerSensor can pick up — the same code path an iPad would
  // hit (PointerSensor handles touch-derived pointer events identically to
  // mouse-derived ones; the TouchSensor is just a long-press fallback).
  const context = await browser.newContext({
    viewport: { width: 414, height: 800 },
    hasTouch: true,
  });
  const page = await context.newPage();
  const user = await createUser("notes_folders_touch", "Touch Tester");
  await loginViaUi(page, user.username);
  const note = await createNoteViaApi(page, `Touch Note ${uniq()}`);

  await page.goto("/notes");
  await expect(page.getByTestId("notes-page")).toBeVisible();
  const rail = page.getByTestId("notes-folders-rail");
  await expect(rail).toBeVisible();

  // Folder rail must be horizontally scrollable on mobile (flex layout, not a
  // fixed-width sidebar).
  const railLayout = await rail.evaluate((el) => {
    const cs = getComputedStyle(el);
    return { display: cs.display, overflowX: cs.overflowX };
  });
  expect(railLayout.display).toBe("flex");
  expect(["auto", "scroll"]).toContain(railLayout.overflowX);

  // Create a folder via API to keep the test focused on the touch drop path.
  const fResp = await page.request.post("/api/note-folders", {
    data: { name: `TouchFolder_${uniq()}` },
  });
  expect(fResp.ok()).toBeTruthy();
  const folder = await fResp.json();
  await page.reload();
  await expect(page.getByTestId(`folder-drop-${folder.id}`)).toBeVisible();

  const movePromise = page.waitForResponse(
    (r) =>
      new URL(r.url()).pathname === `/api/notes/${note.id}` &&
      r.request().method() === "PATCH" &&
      r.status() >= 200 &&
      r.status() < 300,
  );
  await dragNoteToFolderTarget(page, note.id, `folder-drop-${folder.id}`);
  await movePromise;

  await expect(
    page.getByTestId(`folder-drop-${folder.id}-count`),
  ).toHaveText(/^1$/);

  await context.close();
});
