import { test, expect } from "@playwright/test";
import pg from "pg";

// E2E coverage for the four "interactive schedule" features that
// previously only had backend integration tests:
//   1. Tiptap rich-text title editing (bold + color, persisted as HTML)
//   2. Drag-to-reorder rows (dnd-kit) — daily numbers + start times swap
//   3. Custom highlight color from the customize popover — applied as
//      an inset box-shadow ring on the current meeting row
//   4. Non-mutate user (executive_viewer role) sees no grip handle
//
// Each scenario seeds its own meetings via direct DB inserts on a
// far-future date (or today, for the highlight scenario), so we never
// collide with real schedule data and afterAll cleanup leaves the DB
// untouched.

const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
  throw new Error(
    "DATABASE_URL must be set to run the executive-meetings schedule UI tests",
  );
}

const pool = new pg.Pool({ connectionString: DATABASE_URL });

// IDs we created so the afterAll cleanup can DELETE them.
const createdMeetingIds = [];
// Track temporary executive_viewer role assignments so we can revoke
// them on cleanup (the "ahmed" demo user must end the run with the
// same role set he started with).
const grantedRoleAssignments = []; // [{ userId, roleId }]

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

// Wipe the schedule's persisted UI prefs so each test starts from a
// known baseline (view mode off, default highlight color, default
// columns, no per-row tints). Must be called AFTER login because the
// edit-mode storage key is namespaced by userId.
async function resetSchedulePrefs(page) {
  await page.evaluate(() => {
    try {
      const keys = Object.keys(window.localStorage);
      for (const k of keys) {
        if (
          k.startsWith("em-schedule-edit-mode-v1") ||
          k === "em-schedule-cols-v1" ||
          k === "em-schedule-row-colors-v1" ||
          k === "em-current-meeting-highlight-v1"
        ) {
          window.localStorage.removeItem(k);
        }
      }
    } catch {
      /* ignore */
    }
  });
}

async function setLangEn(page) {
  await page.addInitScript(() => {
    try {
      window.localStorage.setItem("tx-lang", "en");
    } catch {
      /* localStorage may not be ready before navigation */
    }
  });
}

// Insert a meeting row directly. Returns the new id.
async function insertMeeting({
  meetingDate,
  dailyNumber,
  titleEn,
  titleAr,
  startTime,
  endTime,
}) {
  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`,
    [
      dailyNumber,
      titleAr,
      titleEn,
      meetingDate,
      startTime,
      endTime,
    ],
  );
  const id = rows[0].id;
  createdMeetingIds.push(id);
  return id;
}

// Pick a meeting date that won't collide with any real schedule data
// OR with stale rows leftover from a previously-failed run. We jitter
// the year-out offset by a per-process random base so reruns within
// the same minute land on different days.
const RANDOM_DATE_BASE_DAYS =
  365 + Math.floor(Math.random() * 1000) + ((Date.now() / 1000) | 0) % 500;
function uniqueFutureDate(offsetDays) {
  const d = new Date();
  d.setUTCDate(d.getUTCDate() + RANDOM_DATE_BASE_DAYS + offsetDays * 7);
  return d.toISOString().slice(0, 10);
}

function todayIso() {
  return new Date().toISOString().slice(0, 10);
}

// HH:MM:SS string for a wall-clock time `minutesOffset` minutes from
// the local "now". Used to seed a meeting whose [startTime, endTime)
// window contains the moment the highlight test runs.
function localTimeWithOffset(minutesOffset) {
  const d = new Date();
  d.setMinutes(d.getMinutes() + minutesOffset);
  const hh = String(d.getHours()).padStart(2, "0");
  const mm = String(d.getMinutes()).padStart(2, "0");
  return `${hh}:${mm}:00`;
}

test.afterAll(async () => {
  if (createdMeetingIds.length > 0) {
    await pool.query(
      `DELETE FROM executive_meeting_attendees 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_meetings WHERE id = ANY($1::int[])`,
      [createdMeetingIds],
    );
  }
  for (const { userId, roleId } of grantedRoleAssignments) {
    await pool.query(
      `DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2`,
      [userId, roleId],
    );
  }
  await pool.end();
});

test("Schedule: editing a title with bold + color via the Tiptap toolbar persists after reload", async ({
  page,
}) => {
  await setLangEn(page);

  const date = uniqueFutureDate(1);
  const uniq = `${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 6)}`;
  const originalTitle = `RichEdit Original ${uniq}`;
  const meetingId = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: originalTitle,
    titleAr: originalTitle,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/executive-meetings");
  await resetSchedulePrefs(page);
  await page.reload();

  // Jump to the seeded date.
  const dateInput = page.locator('input[type="date"]').first();
  await dateInput.fill(date);

  // Wait for the row we just inserted.
  const row = page.getByTestId(`em-row-${meetingId}`);
  await expect(row).toBeVisible({ timeout: 10_000 });

  // Flip on edit mode so the EditableCell becomes interactive.
  const toggle = page.getByTestId("em-edit-mode-toggle");
  await toggle.click();
  await expect(toggle).toHaveAttribute("aria-pressed", "true");

  // Open the title cell editor and apply bold + a red color to ALL of
  // the existing text, then save via the toolbar's check button. We
  // deliberately keep the original text — the assertion is that the
  // toolbar persists *formatting*, not that typing also works (typing
  // is covered by the existing "edit toggle" tests).
  const titleCell = page.getByTestId(`em-edit-title-${meetingId}`);
  await titleCell.click();

  const toolbar = page.getByTestId("em-edit-toolbar");
  await expect(toolbar).toBeVisible();

  // Wait for the contenteditable inside the editor to be ready, then
  // select the entire title so the bold + color marks apply to every
  // character. The editor itself opens with the cursor at the end of
  // the existing text, so a simple ControlOrMeta+a is enough.
  const editorContent = titleCell.locator('[contenteditable="true"]');
  await expect(editorContent).toBeVisible();
  await page.keyboard.press("ControlOrMeta+a");
  await page.getByTestId("em-edit-bold").click();
  await page.getByTestId("em-edit-color-red").click();

  // Watch for the PATCH that saves the title before clicking save.
  const savePromise = page.waitForResponse(
    (resp) => {
      const u = new URL(resp.url());
      return (
        u.pathname === `/api/executive-meetings/${meetingId}` &&
        resp.request().method() === "PATCH" &&
        resp.ok()
      );
    },
    { timeout: 15_000 },
  );
  await page.getByTestId("em-edit-save").click();
  await savePromise;
  await expect(toolbar).toBeHidden();

  // Reload the page to confirm the formatting wasn't just sitting in
  // a Tiptap document — it must round-trip via the server.
  await page.reload();
  await dateInput.fill(date);
  const rowAfterReload = page.getByTestId(`em-row-${meetingId}`);
  await expect(rowAfterReload).toBeVisible({ timeout: 10_000 });

  const titleAfterReload = page.getByTestId(`em-edit-title-${meetingId}`);
  await expect(titleAfterReload).toContainText(originalTitle);

  // Inspect the rendered HTML in the cell. The server-sanitized output
  // must keep the bold tag and the inline color span. We accept either
  // <strong> (StarterKit's default tag) or <b> (some sanitizers
  // collapse to <b>) so the assertion stays robust against the
  // server's exact allowlist serialization.
  const html = await titleAfterReload.innerHTML();
  expect(html.toLowerCase()).toMatch(/<(strong|b)[\s>]/);
  // The Tiptap "red" swatch is #dc2626. Browsers serialize inline
  // styles in either hex or rgb form; accept both.
  expect(html.toLowerCase()).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/);

  // And confirm the DB itself stored the formatted HTML, not the plain
  // text — this catches any frontend-only "looks formatted" regression.
  // The page may render in either Arabic or English depending on the
  // signed-in user's preferredLanguage, so we check whichever column
  // the editor wrote to (the EditableCell saves to title_ar when RTL,
  // title_en otherwise).
  const { rows: dbRows } = await pool.query(
    `SELECT title_ar, title_en FROM executive_meetings WHERE id = $1`,
    [meetingId],
  );
  expect(dbRows.length).toBe(1);
  const editedColumn = String(
    /<(strong|b)[\s>]/i.test(String(dbRows[0].title_ar))
      ? dbRows[0].title_ar
      : dbRows[0].title_en,
  ).toLowerCase();
  expect(editedColumn).toMatch(/<(strong|b)[\s>]/);
  expect(editedColumn).toMatch(/(#dc2626|rgb\(\s*220\s*,\s*38\s*,\s*38\s*\))/);
});

test("Schedule: dragging row 2 above row 1 swaps daily numbers and start times", async ({
  page,
}) => {
  await setLangEn(page);

  const date = uniqueFutureDate(2);
  const uniq = `${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 6)}`;
  const idA = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: `Drag A ${uniq}`,
    titleAr: `سحب أ ${uniq}`,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });
  const idB = await insertMeeting({
    meetingDate: date,
    dailyNumber: 2,
    titleEn: `Drag B ${uniq}`,
    titleAr: `سحب ب ${uniq}`,
    startTime: "11:00:00",
    endTime: "12:00:00",
  });

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/executive-meetings");
  await resetSchedulePrefs(page);
  await page.reload();

  await page.locator('input[type="date"]').first().fill(date);

  await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
    timeout: 10_000,
  });
  await expect(page.getByTestId(`em-row-${idB}`)).toBeVisible();

  // Edit mode must be on for the grip handle to render.
  const toggle = page.getByTestId("em-edit-mode-toggle");
  await toggle.click();
  await expect(toggle).toHaveAttribute("aria-pressed", "true");

  const gripA = page.getByTestId(`em-row-grip-${idA}`);
  const gripB = page.getByTestId(`em-row-grip-${idB}`);
  await expect(gripA).toBeVisible();
  await expect(gripB).toBeVisible();

  const fromBox = await gripB.boundingBox();
  const toBox = await gripA.boundingBox();
  expect(fromBox).not.toBeNull();
  expect(toBox).not.toBeNull();

  // dnd-kit's PointerSensor activates only after a 6px move from the
  // pointerdown position, so we issue a small "warm-up" move before
  // the long move to the target. Using `steps` keeps the synthetic
  // pointer events spread across enough frames for dnd-kit to pick
  // them up reliably.
  const startX = fromBox.x + fromBox.width / 2;
  const startY = fromBox.y + fromBox.height / 2;
  const endX = toBox.x + toBox.width / 2;
  // Aim slightly ABOVE the target row's vertical center so dnd-kit
  // inserts the dragged row before the target instead of after.
  const endY = toBox.y + 4;

  const reorderPromise = page.waitForResponse(
    (resp) => {
      const u = new URL(resp.url());
      return (
        u.pathname === "/api/executive-meetings/reorder" &&
        resp.request().method() === "POST" &&
        resp.ok()
      );
    },
    { timeout: 15_000 },
  );

  await page.mouse.move(startX, startY);
  await page.mouse.down();
  await page.mouse.move(startX, startY + 12, { steps: 5 });
  await page.mouse.move(endX, endY, { steps: 15 });
  await page.mouse.up();

  await reorderPromise;

  // The reorder API rewrites both daily_number and start_time/end_time
  // so that the requested order owns the chronologically-sorted slots.
  // After moving B above A, B should have daily_number=1 and the
  // earlier 09:00–10:00 slot, and A should fall to daily_number=2 with
  // the later 11:00–12:00 slot.
  const { rows } = await pool.query(
    `SELECT id, daily_number, start_time, end_time
       FROM executive_meetings WHERE id = ANY($1::int[]) ORDER BY id`,
    [[idA, idB]],
  );
  const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
  expect(byId[idB].daily_number).toBe(1);
  expect(String(byId[idB].start_time)).toBe("09:00:00");
  expect(String(byId[idB].end_time)).toBe("10:00:00");
  expect(byId[idA].daily_number).toBe(2);
  expect(String(byId[idA].start_time)).toBe("11:00:00");
  expect(String(byId[idA].end_time)).toBe("12:00:00");

  // A reload should reflect the new visual order in the table.
  await page.reload();
  await page.locator('input[type="date"]').first().fill(date);
  await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
    timeout: 10_000,
  });
  const rowsInOrder = await page
    .locator('tr[data-testid^="em-row-"]')
    .evaluateAll((els) => els.map((el) => el.getAttribute("data-testid")));
  // Drop any rows from other dates (defensive — the schedule is
  // date-scoped so there shouldn't be any, but just in case).
  const orderedIds = rowsInOrder
    .map((tid) => Number(tid?.replace("em-row-", "")))
    .filter((n) => n === idA || n === idB);
  expect(orderedIds).toEqual([idB, idA]);
});

test("Schedule: choosing a custom highlight color paints the current meeting's box-shadow ring", async ({
  page,
}) => {
  await setLangEn(page);

  // The "current meeting" detection requires the row to be on TODAY
  // and for now() to fall inside [startTime, endTime). Build a 60-min
  // window centered on now so the test is robust to a few seconds of
  // drift between page load and the highlight tick.
  const date = todayIso();
  const startTime = localTimeWithOffset(-30);
  const endTime = localTimeWithOffset(+30);

  // Use a dailyNumber high enough not to collide with anything seeded
  // elsewhere in the test suite for today. The DB has a UNIQUE
  // (meeting_date, daily_number) index so we pick something out of
  // the way and recover with a single retry on collision.
  let meetingId = null;
  for (const dailyNumber of [991, 992, 993, 994, 995]) {
    try {
      meetingId = await insertMeeting({
        meetingDate: date,
        dailyNumber,
        titleEn: `Highlight Now ${dailyNumber}`,
        titleAr: `إبراز الآن ${dailyNumber}`,
        startTime,
        endTime,
      });
      break;
    } catch (err) {
      if (!/duplicate/i.test(String(err.message))) throw err;
    }
  }
  expect(meetingId).not.toBeNull();

  await loginViaUi(page, "admin", "admin123");
  await page.goto("/executive-meetings");
  await resetSchedulePrefs(page);
  await page.reload();

  // Today is the default date, but we set it explicitly to make the
  // test robust to any future change of the page's initial state.
  await page.locator('input[type="date"]').first().fill(date);

  const row = page.getByTestId(`em-row-${meetingId}`);
  await expect(row).toBeVisible({ timeout: 10_000 });

  // The seeded row should be flagged as the current meeting via the
  // data attribute. Wait for it — the highlight scheduling tick can
  // need a frame to land after the data arrives.
  await expect(row).toHaveAttribute("data-current-meeting", "true", {
    timeout: 5_000,
  });

  // Default highlight color is green (#16a34a). The ring is rendered
  // as `inset 0 0 0 2px <color>` via inline box-shadow on the <tr>.
  const initialShadow = await row.evaluate(
    (el) => getComputedStyle(el).boxShadow,
  );
  // Computed style normalizes hex to rgb; #16a34a → rgb(22, 163, 74).
  expect(initialShadow).toMatch(/22,\s*163,\s*74/);

  // Now open the customize popover and pick the red swatch (#dc2626).
  await page.getByTestId("em-customize-columns-trigger").click();
  const highlightToggle = page.getByTestId("em-highlight-toggle");
  await expect(highlightToggle).toBeVisible();
  // Make sure the highlight is enabled (the default is on, but assert
  // it explicitly so the test isn't sensitive to future default flips).
  const isOn = await highlightToggle.getAttribute("aria-checked");
  if (isOn !== "true") {
    await highlightToggle.click();
    await expect(highlightToggle).toHaveAttribute("aria-checked", "true");
  }

  await page.getByTestId("em-highlight-color-#dc2626").click();
  // Close the popover so it doesn't overlap further assertions.
  await page.keyboard.press("Escape");

  // The ring should now reflect the new color: #dc2626 → rgb(220, 38, 38).
  await expect
    .poll(
      async () => row.evaluate((el) => getComputedStyle(el).boxShadow),
      { timeout: 5_000 },
    )
    .toMatch(/220,\s*38,\s*38/);
});

test("Schedule: a non-mutate user (executive_viewer) sees no grip handle and no edit toggle", async ({
  page,
}) => {
  await setLangEn(page);

  // Seed a meeting so there's something to (try to) drag.
  const date = uniqueFutureDate(3);
  const uniq = `${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 6)}`;
  const meetingId = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: `Viewer Only ${uniq}`,
    titleAr: `مشاهد فقط ${uniq}`,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });

  // Grant the demo "ahmed" user the executive_viewer role (read-only).
  // We track the assignment so afterAll can revoke it. The role is
  // already seeded by scripts/src/seed.ts.
  const { rows: ahmedRows } = await pool.query(
    `SELECT id FROM users WHERE username = 'ahmed'`,
  );
  expect(ahmedRows.length).toBe(1);
  const ahmedId = ahmedRows[0].id;

  const { rows: roleRows } = await pool.query(
    `SELECT id FROM roles WHERE name = 'executive_viewer'`,
  );
  expect(roleRows.length).toBe(1);
  const viewerRoleId = roleRows[0].id;

  const { rowCount } = await pool.query(
    `INSERT INTO user_roles (user_id, role_id)
       VALUES ($1, $2)
       ON CONFLICT DO NOTHING`,
    [ahmedId, viewerRoleId],
  );
  // Only schedule cleanup if WE were the ones who added the row —
  // otherwise we'd revoke a role ahmed had legitimately.
  if (rowCount === 1) {
    grantedRoleAssignments.push({ userId: ahmedId, roleId: viewerRoleId });
  }

  await loginViaUi(page, "ahmed", "user123");
  await page.goto("/executive-meetings");
  await resetSchedulePrefs(page);
  await page.reload();

  await page.locator('input[type="date"]').first().fill(date);

  // The seeded row must be visible (read access works).
  const row = page.getByTestId(`em-row-${meetingId}`);
  await expect(row).toBeVisible({ timeout: 10_000 });

  // No grip handles anywhere — they only render when the row prop
  // canMutate is true, which equals me.canMutate && editMode.
  await expect(
    page.locator('[data-testid^="em-row-grip-"]'),
  ).toHaveCount(0);

  // The view/edit toggle is rendered ONLY for users who have the
  // mutate capability, so a viewer should never see it.
  await expect(page.getByTestId("em-edit-mode-toggle")).toHaveCount(0);

  // Sanity: the customize popover is still available (it's purely a
  // presentation control and does not require mutate access), but the
  // row-color trigger and merge trigger — both edit-mode-only — must
  // not be present.
  await expect(page.getByTestId("em-row-color-trigger")).toHaveCount(0);
  await expect(
    page.locator('[data-testid^="em-merge-trigger-"]'),
  ).toHaveCount(0);

  // Reorder API must reject a viewer's POST as well — defense in depth.
  const reorderResp = await page.request.post(
    "/api/executive-meetings/reorder",
    {
      data: { meetingDate: date, orderedIds: [meetingId] },
    },
  );
  expect(reorderResp.status()).toBeGreaterThanOrEqual(400);
});
