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

// E2E coverage for the iPad / touch path of drag-to-reorder on the
// Executive Meetings daily schedule. Task #141 fixed the touch path by
// adding dnd-kit's TouchSensor with a long-press activation
// (delay: 200ms, tolerance: 8px) plus `touch-action: none` on the row
// grip handle. The desktop reorder test in
// `executive-meetings-schedule-features.spec.mjs` only exercises the
// PointerSensor (mouse) path, so a regression that breaks touch
// activation would be silent on CI but break every iPad user. This
// spec runs in a touch-enabled mobile viewport and dispatches real
// CDP touch events so the TouchSensor is the sensor that actually
// activates — exactly mirroring an iPad user's gesture.

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

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

// Configure this whole spec to use an iPad-shaped, touch-enabled
// browser context. `isMobile` enables Chromium's mobile emulation
// (which makes the browser actually emit TouchEvents in addition to
// pointer/mouse), and `hasTouch` enables the touch input pipeline so
// CDP `Input.dispatchTouchEvent` calls are honored. Both are required
// for dnd-kit's TouchSensor to receive `touchstart`/`touchmove`/
// `touchend` and activate on long-press.
test.use({
  viewport: { width: 768, height: 1024 },
  hasTouch: true,
  isMobile: true,
});

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 any persisted schedule UI prefs so this test starts from the
// same baseline regardless of what previous runs (or other specs that
// share the demo `admin` user) left behind. Must be called after
// login because the edit-mode key is namespaced by user id.
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;
}

// Pick a meeting date that won't collide with real schedule data OR
// with stale rows leftover from a previously-failed run. Mirrors the
// jittering strategy used by the desktop schedule spec.
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);
}

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("Touch: long-press on the row grip and drag down past the next row reorders via the TouchSensor (iPad)", async ({
  page,
}) => {
  await setLangEn(page);

  const date = uniqueFutureDate(1);
  const uniq = `${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 6)}`;
  const idA = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: `Touch Drag A ${uniq}`,
    titleAr: `سحب لمسي أ ${uniq}`,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });
  const idB = await insertMeeting({
    meetingDate: date,
    dailyNumber: 2,
    titleEn: `Touch 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();

  // Jump to the seeded date.
  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 is required 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 rowB = page.getByTestId(`em-row-${idB}`);
  await expect(gripA).toBeVisible();
  await expect(rowB).toBeVisible();

  // Scroll the grip into view BEFORE measuring bounding boxes — on the
  // narrow iPad viewport the grip can sit above the fold, and any
  // post-measurement scroll would invalidate the coordinates we use to
  // dispatch touch events.
  await gripA.scrollIntoViewIfNeeded();

  const gripBox = await gripA.boundingBox();
  const targetBox = await rowB.boundingBox();
  expect(gripBox).not.toBeNull();
  expect(targetBox).not.toBeNull();

  const startX = gripBox.x + gripBox.width / 2;
  const startY = gripBox.y + gripBox.height / 2;
  // Aim BELOW the second row's vertical center so dnd-kit places A
  // after B and the resulting order becomes [B, A].
  const endX = targetBox.x + targetBox.width / 2;
  const endY = targetBox.y + targetBox.height - 4;

  // We drive touches through CDP because Playwright's high-level
  // `page.touchscreen` API only exposes `tap()` — no long-press +
  // drag primitive — and dnd-kit's TouchSensor specifically listens
  // for native touchstart/touchmove/touchend events.
  const cdp = await page.context().newCDPSession(page);

  const reorderRequestPromise = page.waitForRequest(
    (req) => {
      const u = new URL(req.url());
      return (
        u.pathname === "/api/executive-meetings/reorder" &&
        req.method() === "POST"
      );
    },
    { timeout: 15_000 },
  );
  const reorderResponsePromise = 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 },
  );

  // 1. Press down on the grip and HOLD without moving so the
  //    TouchSensor's 200ms activation delay can elapse. We deliberately
  //    wait > 250ms (the task's spec) and well above dnd-kit's 200ms
  //    threshold to avoid timing flakiness on slow CI machines.
  await cdp.send("Input.dispatchTouchEvent", {
    type: "touchStart",
    touchPoints: [{ x: startX, y: startY, id: 1 }],
  });
  // Hold still — staying inside the 8px tolerance window is what tells
  // the TouchSensor "this is a long-press, not a scroll".
  await page.waitForTimeout(350);

  // 2. Now drag down toward the target row in several intermediate
  //    steps so dnd-kit sees enough touchmove events to compute the
  //    overlap with the row below and emit a sortable change.
  const steps = 12;
  for (let i = 1; i <= steps; i++) {
    const x = startX + ((endX - startX) * i) / steps;
    const y = startY + ((endY - startY) * i) / steps;
    await cdp.send("Input.dispatchTouchEvent", {
      type: "touchMove",
      touchPoints: [{ x, y, id: 1 }],
    });
    // A short wait between moves lets React/dnd-kit run their
    // collision-detection ticks between samples.
    await page.waitForTimeout(20);
  }

  // 3. Release.
  await cdp.send("Input.dispatchTouchEvent", {
    type: "touchEnd",
    touchPoints: [],
  });

  // The reorder POST must fire AND succeed.
  const reorderRequest = await reorderRequestPromise;
  await reorderResponsePromise;

  // Inspect the request body to confirm the client sent the new
  // order (B before A) — this is what proves the TouchSensor
  // actually activated and dnd-kit re-emitted onDragEnd. A no-op
  // drag would either send no request or send the original order.
  const requestPayload = reorderRequest.postDataJSON();
  expect(requestPayload).toMatchObject({ meetingDate: date });
  expect(Array.isArray(requestPayload.orderedIds)).toBe(true);
  expect(requestPayload.orderedIds).toEqual([idB, idA]);

  // The DB must reflect the swap: B owns daily_number 1 and the
  // earlier 09:00-10:00 slot, A drops to 2 and the 11:00-12:00 slot.
  // This mirrors the assertion in the desktop reorder test and
  // catches any client-side-only "looks reordered" regression.
  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");

  // The DOM order in the visible table must show the swap as well —
  // this is the assertion that fails if the touch path silently
  // regressed (e.g. someone removed TouchSensor or the touch-action
  // CSS, so the gesture turns into a vertical scroll instead of a
  // drag).
  const orderedIdsAfterDrag = await page
    .locator('tr[data-testid^="em-row-"]')
    .evaluateAll((els) =>
      els
        .map((el) => el.getAttribute("data-testid"))
        .map((tid) => Number(tid?.replace("em-row-", ""))),
    );
  const swappedOrder = orderedIdsAfterDrag.filter(
    (n) => n === idA || n === idB,
  );
  expect(swappedOrder).toEqual([idB, idA]);

  // Reload — the persisted order must come back the same way, which
  // proves the server actually accepted and stored the new sequence
  // (not just a transient optimistic update on the client).
  await page.reload();
  await page.locator('input[type="date"]').first().fill(date);
  await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
    timeout: 10_000,
  });
  const orderedIdsAfterReload = await page
    .locator('tr[data-testid^="em-row-"]')
    .evaluateAll((els) =>
      els
        .map((el) => el.getAttribute("data-testid"))
        .map((tid) => Number(tid?.replace("em-row-", ""))),
    );
  const persistedOrder = orderedIdsAfterReload.filter(
    (n) => n === idA || n === idB,
  );
  expect(persistedOrder).toEqual([idB, idA]);
});
