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

// E2E coverage for keyboard editing in the executive-meetings schedule.
// These tests verify the keyboard contract for the three inline-edit
// cells (time range, title, attendee name):
//
//   - Tab navigation reaches the cell (it is keyboard-reachable via
//     tabIndex=0).
//   - Pressing Enter on the focused display opens edit mode.
//   - Pressing Enter inside the editor saves (PATCH/PUT succeeds,
//     editor collapses to display mode).
//   - Pressing Escape cancels — original value is restored, NO PATCH/PUT
//     is sent, editor collapses.
//   - Focus does not get stranded on an unmounted node after save/cancel
//     (it returns to body or the row, not into the void).
//
// Each scenario seeds its own meeting row on a far-future date so we
// never collide with real schedule data, and afterAll cleanup deletes
// the rows we created.

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

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

const createdMeetingIds = [];

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. 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 */
    }
  });
}

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;
}

async function insertAttendee({ meetingId, name, attendanceType, sortOrder }) {
  await pool.query(
    `INSERT INTO executive_meeting_attendees
       (meeting_id, name, attendance_type, sort_order)
     VALUES ($1, $2, $3, $4)`,
    [meetingId, name, attendanceType, sortOrder],
  );
}

const RANDOM_DATE_BASE_DAYS =
  730 + 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);
}

// Switch the schedule into edit mode so the inline editors are
// interactive. Idempotent — clicks the toggle only when off.
async function ensureEditMode(page) {
  const toggle = page.getByTestId("em-edit-mode-toggle");
  await expect(toggle).toBeVisible();
  const pressed = await toggle.getAttribute("aria-pressed");
  if (pressed !== "true") {
    await toggle.click();
    await expect(toggle).toHaveAttribute("aria-pressed", "true");
  }
}

// Common setup: log in, navigate to /executive-meetings, reset prefs,
// reload, jump to the seeded date, wait for the row, flip on edit mode.
async function gotoMeetingRow(page, meetingId, date) {
  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);
  const row = page.getByTestId(`em-row-${meetingId}`);
  await expect(row).toBeVisible({ timeout: 10_000 });
  await ensureEditMode(page);
  return row;
}

// Returns the data-testid attribute of the currently-focused element,
// or null when focus is on body / has no testid. Useful for asserting
// keyboard navigation lands on the right cell without depending on a
// fragile Tab count.
async function activeTestId(page) {
  return await page.evaluate(() => {
    const el = document.activeElement;
    if (!el || el === document.body) return null;
    return el.getAttribute("data-testid");
  });
}

// Tab forward up to `maxSteps` times until the focused element has the
// given test id. Throws via expect() if we never reach it. We use this
// instead of a hard-coded number of Tab presses so the test stays
// stable against small DOM changes (e.g. extra row-overlay buttons).
async function tabUntilFocused(page, targetTestId, maxSteps = 25) {
  for (let i = 0; i < maxSteps; i++) {
    const cur = await activeTestId(page);
    if (cur === targetTestId) return;
    await page.keyboard.press("Tab");
  }
  const cur = await activeTestId(page);
  expect(
    cur,
    `expected focus to reach ${targetTestId} within ${maxSteps} Tab presses`,
  ).toBe(targetTestId);
}

// Wait for the schedule's day-level GET refetch to land. After every
// inline save the page calls qc.invalidateQueries({ queryKey: [
// "/api/executive-meetings", date ] }), which fires a fresh GET. The
// cell re-renders only AFTER that GET resolves, so polling for the new
// text without first awaiting the refetch can race.
function waitForDayRefetch(page, date) {
  return page.waitForResponse(
    (resp) => {
      const u = new URL(resp.url());
      return (
        u.pathname === "/api/executive-meetings" &&
        u.searchParams.get("date") === date &&
        resp.request().method() === "GET" &&
        resp.ok()
      );
    },
    { timeout: 15_000 },
  );
}

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],
    );
  }
  await pool.end();
});

test("Schedule keyboard: Tab into time cell, Enter opens edit, valid times save via Enter and PATCH succeeds", async ({
  page,
}) => {
  await setLangEn(page);

  const date = uniqueFutureDate(1);
  const meetingId = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: `Keyboard Time ${Date.now().toString(36)}`,
    titleAr: `وقت لوحة المفاتيح ${Date.now().toString(36)}`,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });

  await gotoMeetingRow(page, meetingId, date);

  const timeCell = page.getByTestId(`em-time-${meetingId}`);
  await expect(timeCell).toBeVisible();
  // Display variant is rendered as <div role="button" tabindex="0">.
  // Confirm the cell is keyboard-reachable: focus it via the DOM (a
  // raw Tab walk is also covered by a separate tab-order test below;
  // the exact step count from the start of the page would depend on
  // how many overlay buttons render in this row, which is not the
  // target of this assertion).
  await timeCell.evaluate((el) => el.focus());
  expect(await activeTestId(page)).toBe(`em-time-${meetingId}`);

  // Enter on the focused display opens edit mode.
  await page.keyboard.press("Enter");
  const startInput = page.getByTestId(`em-time-start-${meetingId}`);
  const endInput = page.getByTestId(`em-time-end-${meetingId}`);
  await expect(startInput).toBeVisible();
  await expect(endInput).toBeVisible();
  // The start input auto-focuses on enter-edit so the user can begin
  // typing immediately.
  await expect(startInput).toBeFocused();

  // Type valid new times.
  await startInput.fill("13:30");
  await endInput.focus();
  await endInput.fill("14:45");

  // Watch for the PATCH that the Enter keypress should trigger AND
  // for the day-level GET refetch the page kicks off afterwards. The
  // displayed cell only updates once the refetch resolves.
  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 },
  );
  const refetchPromise = waitForDayRefetch(page, date);
  await page.keyboard.press("Enter");
  await Promise.all([savePromise, refetchPromise]);

  // Editor must collapse back to display mode and show the new values.
  await expect(startInput).toHaveCount(0);
  await expect(endInput).toHaveCount(0);
  await expect(timeCell).toHaveText(/13:30\s*[\u2013\-]\s*14:45/, {
    timeout: 10_000,
  });

  // The DB must reflect the PATCH (server is the source of truth).
  const { rows } = await pool.query(
    `SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
    [meetingId],
  );
  expect(String(rows[0].start_time)).toBe("13:30:00");
  expect(String(rows[0].end_time)).toBe("14:45:00");

  // Focus must return to a sensible element — either the (re-rendered)
  // display cell or the document body. What it MUST NOT do is land on
  // an unmounted input or escape the page entirely.
  const focused = await activeTestId(page);
  expect(focused === null || focused === `em-time-${meetingId}`).toBe(true);
});

test("Schedule keyboard: time cell Esc restores original times and sends no PATCH", async ({
  page,
}) => {
  await setLangEn(page);

  const date = uniqueFutureDate(2);
  const meetingId = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: `Keyboard Time Esc ${Date.now().toString(36)}`,
    titleAr: `إلغاء الوقت ${Date.now().toString(36)}`,
    startTime: "08:15:00",
    endTime: "09:45:00",
  });

  await gotoMeetingRow(page, meetingId, date);

  const timeCell = page.getByTestId(`em-time-${meetingId}`);
  await expect(timeCell).toHaveText(/08:15\s*[\u2013\-]\s*09:45/);

  // Spy on any PATCH to this meeting — Escape must not trigger one.
  let patchSeen = false;
  page.on("response", (resp) => {
    try {
      const u = new URL(resp.url());
      if (
        u.pathname === `/api/executive-meetings/${meetingId}` &&
        resp.request().method() === "PATCH"
      ) {
        patchSeen = true;
      }
    } catch {
      /* ignore */
    }
  });

  await timeCell.evaluate((el) => el.focus());
  await page.keyboard.press("Enter");
  const startInput = page.getByTestId(`em-time-start-${meetingId}`);
  const endInput = page.getByTestId(`em-time-end-${meetingId}`);
  await expect(startInput).toBeFocused();

  // Type something different from the saved value so that, if Escape
  // failed to cancel, we'd see a real change in the cell.
  await startInput.fill("17:00");
  await endInput.focus();
  await endInput.fill("18:30");

  // Esc cancels — editor closes, original values restored.
  await page.keyboard.press("Escape");
  await expect(startInput).toHaveCount(0);
  await expect(endInput).toHaveCount(0);
  await expect(timeCell).toHaveText(/08:15\s*[\u2013\-]\s*09:45/);

  // Give the page a beat to make sure no late PATCH fires from a
  // delayed blur path before we assert.
  await page.waitForTimeout(400);
  expect(patchSeen).toBe(false);

  // DB must still hold the originals.
  const { rows } = await pool.query(
    `SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
    [meetingId],
  );
  expect(String(rows[0].start_time)).toBe("08:15:00");
  expect(String(rows[0].end_time)).toBe("09:45:00");

  // Focus is sensible after cancel (cell or body, not on a removed input).
  const focused = await activeTestId(page);
  expect(focused === null || focused === `em-time-${meetingId}`).toBe(true);
});

test("Schedule keyboard: title cell Enter opens edit and Enter saves the new value via PATCH", async ({
  page,
}) => {
  await setLangEn(page);

  const date = uniqueFutureDate(3);
  const uniq = Date.now().toString(36);
  const originalTitle = `KbdTitle Original ${uniq}`;
  // We append (instead of select-all + replace) because typing-as-append
  // exercises the same Enter-saves contract without depending on the
  // ProseMirror selectAll keymap (which can be flaky to drive from
  // outside the editor in headless Chromium).
  const appended = ` SAVED-${uniq}`;
  const meetingId = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: originalTitle,
    titleAr: originalTitle,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });

  await gotoMeetingRow(page, meetingId, date);

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

  // Confirm the title cell is keyboard-reachable (tabIndex=0) by
  // focusing it directly, then press Enter to enter edit mode.
  await titleCell.evaluate((el) => el.focus());
  expect(await activeTestId(page)).toBe(`em-edit-title-${meetingId}`);
  await page.keyboard.press("Enter");

  const toolbar = page.getByTestId("em-edit-toolbar");
  await expect(toolbar).toBeVisible();
  const editorContent = titleCell.locator('[contenteditable="true"]');
  await expect(editorContent).toBeVisible();
  await expect(editorContent).toBeFocused();

  // The editor opens with the cursor at the end of the existing text
  // (commands.focus("end")), so plain typing appends.
  await page.keyboard.type(appended);

  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 },
  );
  const refetchPromise = waitForDayRefetch(page, date);
  // Single-line title (multiline=false): Enter (no Shift) saves via
  // the EditableCell keydown handler.
  await page.keyboard.press("Enter");
  await Promise.all([savePromise, refetchPromise]);

  const expectedTitle = originalTitle + appended;
  await expect(toolbar).toBeHidden();
  await expect(titleCell).toContainText(expectedTitle, { timeout: 10_000 });

  // DB confirms the save round-tripped. The EditableCell saves to
  // title_ar when the page is RTL (admin's preferredLanguage drives
  // this) and title_en when LTR — so check whichever column actually
  // received the saved HTML, exactly like the schedule-features
  // formatting test does.
  const { rows } = await pool.query(
    `SELECT title_ar, title_en FROM executive_meetings WHERE id = $1`,
    [meetingId],
  );
  const savedTitle = String(rows[0].title_ar).includes(appended)
    ? String(rows[0].title_ar)
    : String(rows[0].title_en);
  expect(savedTitle).toContain(originalTitle);
  expect(savedTitle).toContain(appended);

  const focused = await activeTestId(page);
  expect(focused === null || focused === `em-edit-title-${meetingId}`).toBe(
    true,
  );
});

test("Schedule keyboard: title cell Esc closes editor, restores the original, and sends no PATCH", async ({
  page,
}) => {
  await setLangEn(page);

  const date = uniqueFutureDate(4);
  const uniq = Date.now().toString(36);
  const originalTitle = `KbdTitleEsc Original ${uniq}`;
  const meetingId = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: originalTitle,
    titleAr: originalTitle,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });

  await gotoMeetingRow(page, meetingId, date);

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

  let patchSeen = false;
  page.on("response", (resp) => {
    try {
      const u = new URL(resp.url());
      if (
        u.pathname === `/api/executive-meetings/${meetingId}` &&
        resp.request().method() === "PATCH"
      ) {
        patchSeen = true;
      }
    } catch {
      /* ignore */
    }
  });

  await titleCell.evaluate((el) => el.focus());
  await page.keyboard.press("Enter");

  const toolbar = page.getByTestId("em-edit-toolbar");
  await expect(toolbar).toBeVisible();
  const editorContent = titleCell.locator('[contenteditable="true"]');
  await expect(editorContent).toBeFocused();

  // Type a draft change that we expect Esc to throw away.
  await page.keyboard.type(" DRAFT-NEVER-SAVED");
  await page.keyboard.press("Escape");
  await expect(toolbar).toBeHidden();
  await expect(titleCell).toContainText(originalTitle);
  await expect(titleCell).not.toContainText("DRAFT-NEVER-SAVED");
  await page.waitForTimeout(400);
  expect(patchSeen).toBe(false);

  // DB still holds the original.
  const { rows } = await pool.query(
    `SELECT title_en FROM executive_meetings WHERE id = $1`,
    [meetingId],
  );
  expect(String(rows[0].title_en)).toBe(originalTitle);

  const focused = await activeTestId(page);
  expect(focused === null || focused === `em-edit-title-${meetingId}`).toBe(
    true,
  );
});

test("Schedule keyboard: attendee cell Enter opens edit and Enter saves the new name via PUT", async ({
  page,
}) => {
  await setLangEn(page);

  const date = uniqueFutureDate(5);
  const uniq = Date.now().toString(36);
  const originalName = `KbdAtt Original ${uniq}`;
  // Append a suffix rather than replace, for the same reason as the
  // title test above: it exercises the same Enter-saves contract
  // without depending on an external select-all keymap.
  const appended = ` SAVED-${uniq}`;
  const meetingId = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: `Att Host ${uniq}`,
    titleAr: `مضيف ${uniq}`,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });
  await insertAttendee({
    meetingId,
    name: originalName,
    attendanceType: "internal",
    sortOrder: 0,
  });

  const row = await gotoMeetingRow(page, meetingId, date);

  // The testid uses the original index in meeting.attendees (0).
  // Scope under the seeded row so the selector stays unambiguous even
  // if a future test ever shares the same date.
  const attendeeCell = row.getByTestId(`em-edit-attendee-0`);
  await expect(attendeeCell).toBeVisible();
  await expect(attendeeCell).toContainText(originalName);

  await attendeeCell.evaluate((el) => el.focus());
  expect(await activeTestId(page)).toBe(`em-edit-attendee-0`);
  await page.keyboard.press("Enter");

  const toolbar = page.getByTestId("em-edit-toolbar");
  await expect(toolbar).toBeVisible();
  const editorContent = attendeeCell.locator('[contenteditable="true"]');
  await expect(editorContent).toBeVisible();
  await expect(editorContent).toBeFocused();

  // Cursor opens at end (focus("end")), so typing appends.
  await page.keyboard.type(appended);

  const savePromise = page.waitForResponse(
    (resp) => {
      const u = new URL(resp.url());
      return (
        u.pathname === `/api/executive-meetings/${meetingId}/attendees` &&
        resp.request().method() === "PUT" &&
        resp.ok()
      );
    },
    { timeout: 15_000 },
  );
  const refetchPromise = waitForDayRefetch(page, date);
  await page.keyboard.press("Enter");
  await Promise.all([savePromise, refetchPromise]);

  const expectedName = originalName + appended;
  await expect(toolbar).toBeHidden();
  await expect(attendeeCell).toContainText(expectedName, { timeout: 10_000 });

  const { rows } = await pool.query(
    `SELECT name FROM executive_meeting_attendees
       WHERE meeting_id = $1 ORDER BY sort_order`,
    [meetingId],
  );
  expect(rows.length).toBe(1);
  expect(String(rows[0].name)).toContain(originalName);
  expect(String(rows[0].name)).toContain(appended);

  const focused = await activeTestId(page);
  expect(focused === null || focused === `em-edit-attendee-0`).toBe(true);
});

test("Schedule keyboard: attendee cell Esc closes editor, restores the original, and sends no PUT", async ({
  page,
}) => {
  await setLangEn(page);

  const date = uniqueFutureDate(6);
  const uniq = Date.now().toString(36);
  const originalName = `KbdAttEsc Original ${uniq}`;
  const meetingId = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: `Att Host Esc ${uniq}`,
    titleAr: `مضيف إلغاء ${uniq}`,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });
  await insertAttendee({
    meetingId,
    name: originalName,
    attendanceType: "internal",
    sortOrder: 0,
  });

  const row = await gotoMeetingRow(page, meetingId, date);

  const attendeeCell = row.getByTestId(`em-edit-attendee-0`);
  await expect(attendeeCell).toContainText(originalName);

  let putSeen = false;
  page.on("response", (resp) => {
    try {
      const u = new URL(resp.url());
      if (
        u.pathname === `/api/executive-meetings/${meetingId}/attendees` &&
        resp.request().method() === "PUT"
      ) {
        putSeen = true;
      }
    } catch {
      /* ignore */
    }
  });

  await attendeeCell.evaluate((el) => el.focus());
  await page.keyboard.press("Enter");

  const toolbar = page.getByTestId("em-edit-toolbar");
  await expect(toolbar).toBeVisible();
  const editorContent = attendeeCell.locator('[contenteditable="true"]');
  await expect(editorContent).toBeFocused();

  await page.keyboard.type(" DRAFT-NEVER-SAVED");
  await page.keyboard.press("Escape");
  await expect(toolbar).toBeHidden();
  await expect(attendeeCell).toContainText(originalName);
  await expect(attendeeCell).not.toContainText("DRAFT-NEVER-SAVED");
  await page.waitForTimeout(400);
  expect(putSeen).toBe(false);

  // DB unchanged.
  const { rows } = await pool.query(
    `SELECT name FROM executive_meeting_attendees
       WHERE meeting_id = $1 ORDER BY sort_order`,
    [meetingId],
  );
  expect(rows.length).toBe(1);
  expect(String(rows[0].name)).toBe(originalName);

  const focused = await activeTestId(page);
  expect(focused === null || focused === `em-edit-attendee-0`).toBe(true);
});

test("Schedule keyboard: Tab from the title cell reaches the time cell in the same row", async ({
  page,
}) => {
  // Covers the keyboard-reachability requirement ("Tab into a time
  // cell"). We seed a row with NO attendees so the tab path between
  // the title and the time cell is short and stable.
  await setLangEn(page);

  const date = uniqueFutureDate(7);
  const meetingId = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: `Tab Order ${Date.now().toString(36)}`,
    titleAr: `ترتيب التبويب ${Date.now().toString(36)}`,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });

  await gotoMeetingRow(page, meetingId, date);

  const titleCell = page.getByTestId(`em-edit-title-${meetingId}`);
  await titleCell.evaluate((el) => el.focus());
  expect(await activeTestId(page)).toBe(`em-edit-title-${meetingId}`);

  // Walk forward with Tab until we land on the time cell. Bounded so a
  // future regression that breaks tab order fails fast instead of
  // tabbing forever.
  await tabUntilFocused(page, `em-time-${meetingId}`, 30);

  // Pressing Enter on the focused time display opens edit mode.
  await page.keyboard.press("Enter");
  await expect(page.getByTestId(`em-time-start-${meetingId}`)).toBeFocused();
});
