// E2E + UI coverage for the cell-merge feature added by task #152.
//
// Two scenarios live here:
//
//   1. Two-tab realtime merge propagation. Two browser contexts open
//      the same day's schedule. Tab A applies a merge via its own
//      MergeMenu; tab B should pick up the new merge (rendered as a
//      single colSpan cell) without a manual refresh, driven by the
//      `executive_meetings_changed` Socket.IO event.
//
//   2. UI fallbacks for the merge trigger / Unmerge action under
//      column-customizer churn:
//        a. Hiding the leading "#" column doesn't strip the row-actions
//           kebab — the kebab re-anchors to the next visible cell so an
//           editor can still merge from a row whose # column is off.
//        b. After a column reorder makes a *stored* merge cover
//           non-contiguous visible columns (so we degrade to the
//           unmerged layout to avoid colSpan-ing the wrong cells), the
//           kebab still surfaces a working Unmerge action.
//
// All seeded meetings are inserted directly via pg on a far-future
// date so the tests don't collide with real schedule data, and an
// afterAll DELETE removes everything we created.

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 the executive-meetings merge 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 (view mode off, default columns, default highlight
// color, no per-row tints, etc.). 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 ensureEditModeOn(page) {
  const toggle = page.getByTestId("em-edit-mode-toggle");
  await expect(toggle).toBeVisible();
  if ((await toggle.getAttribute("aria-pressed")) === "false") {
    await toggle.click();
  }
  await expect(toggle).toHaveAttribute("aria-pressed", "true");
}

async function insertMeeting({
  meetingDate,
  dailyNumber,
  titleEn,
  titleAr,
  startTime,
  endTime,
  mergeStartColumn = null,
  mergeEndColumn = null,
  mergeText = null,
}) {
  const { rows } = await pool.query(
    `INSERT INTO executive_meetings
       (daily_number, title_ar, title_en, meeting_date, start_time, end_time,
        status, merge_start_column, merge_end_column, merge_text)
     VALUES ($1, $2, $3, $4, $5, $6, 'scheduled', $7, $8, $9)
     RETURNING id`,
    [
      dailyNumber,
      titleAr,
      titleEn,
      meetingDate,
      startTime,
      endTime,
      mergeStartColumn,
      mergeEndColumn,
      mergeText,
    ],
  );
  const id = rows[0].id;
  createdMeetingIds.push(id);
  return id;
}

// Pick a meeting date well past any seeded data and jitter the base
// per-process so concurrent reruns don't trip the daily_number unique
// index.
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("Schedule: a merge applied in one tab propagates to a second tab via realtime without reload", async ({
  browser,
}) => {
  // Two isolated browser contexts so each tab gets its own session
  // cookie + Socket.IO connection. Sharing a context would multiplex
  // the same socket for both tabs and defeat the purpose.
  const ctxA = await browser.newContext();
  const ctxB = await browser.newContext();
  const tabA = await ctxA.newPage();
  const tabB = await ctxB.newPage();

  const date = uniqueFutureDate(5);
  const uniq = `${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 6)}`;
  const meetingId = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: `Realtime Merge ${uniq}`,
    titleAr: `دمج حي ${uniq}`,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });

  try {
    await setLangEn(tabA);
    await setLangEn(tabB);

    await loginViaUi(tabA, "admin", "admin123");
    await loginViaUi(tabB, "admin", "admin123");

    await tabA.goto("/executive-meetings");
    await tabB.goto("/executive-meetings");
    await resetSchedulePrefs(tabA);
    await resetSchedulePrefs(tabB);
    await tabA.reload();
    await tabB.reload();

    // Both tabs jump to the seeded date.
    await tabA.locator('input[type="date"]').first().fill(date);
    await tabB.locator('input[type="date"]').first().fill(date);

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

    // Neither tab shows a merged cell yet.
    await expect(
      tabA.getByTestId(`em-merge-cell-${meetingId}`),
    ).toHaveCount(0);
    await expect(
      tabB.getByTestId(`em-merge-cell-${meetingId}`),
    ).toHaveCount(0);

    // Apply a merge in tab A through the row-actions kebab → Merge
    // submenu → "Merge meeting + attendees + time" option.
    await ensureEditModeOn(tabA);
    const kebabA = rowA.locator('[data-testid^="em-row-actions-"]').first();
    await kebabA.click({ force: true });
    await tabA.getByTestId(`em-merge-trigger-${meetingId}`).click();

    // Wait for the PATCH to land before relying on the socket fan-out.
    const savePromise = tabA.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 tabA.getByTestId(`em-merge-opt-meeting-time-${meetingId}`).click();
    await savePromise;

    // Tab A renders the merged cell immediately (optimistic update).
    await expect(
      tabA.getByTestId(`em-merge-cell-${meetingId}`),
    ).toBeVisible({ timeout: 10_000 });

    // Tab B should pick up the new merge via the realtime
    // `executive_meetings_changed` event invalidating its cached
    // query for the same date — no manual reload here.
    await expect(
      tabB.getByTestId(`em-merge-cell-${meetingId}`),
    ).toBeVisible({ timeout: 15_000 });
  } finally {
    await ctxA.close();
    await ctxB.close();
  }
});

test("Schedule: hiding the # column still exposes the merge trigger on an unmerged row", async ({
  page,
}) => {
  await setLangEn(page);

  const date = uniqueFutureDate(7);
  const uniq = `${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 6)}`;
  const meetingId = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: `NumHidden ${uniq}`,
    titleAr: `بدون رقم ${uniq}`,
    startTime: "09:00:00",
    endTime: "10:00:00",
  });

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

  // Hide the # column directly via the persisted prefs so we don't
  // depend on the customize popover's exact UX for this regression.
  // Order is preserved (number stays at index 0), only `visible` flips.
  await page.evaluate(() => {
    const cols = [
      { id: "number", visible: false, width: 56 },
      { id: "meeting", visible: true, width: 360 },
      { id: "attendees", visible: true, width: 280 },
      { id: "time", visible: true, width: 200 },
    ];
    window.localStorage.setItem("em-schedule-cols-v1", JSON.stringify(cols));
  });
  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 });

  // Sanity: the # column header must actually be gone — otherwise the
  // test isn't exercising the regression.
  await expect(page.getByTestId("em-col-header-number")).toHaveCount(0);

  // Edit mode is required for the row-actions kebab to render at all.
  await ensureEditModeOn(page);

  // The kebab anchors to the FIRST VISIBLE non-merged cell, so it
  // should still be present on the row even though the # cell is
  // hidden. Open it and confirm the Merge submenu is reachable.
  const kebab = row.locator('[data-testid^="em-row-actions-"]').first();
  await expect(kebab).toHaveCount(1);
  await kebab.click({ force: true });
  await expect(
    page.getByTestId(`em-merge-trigger-${meetingId}`),
  ).toBeVisible();

  // Drilling into the Merge submenu surfaces the three "Merge ..."
  // options just like on a row whose # column is visible.
  await page.getByTestId(`em-merge-trigger-${meetingId}`).click();
  await expect(
    page.getByTestId(`em-merge-opt-meeting-attendees-${meetingId}`),
  ).toBeVisible();
  await expect(
    page.getByTestId(`em-merge-opt-meeting-time-${meetingId}`),
  ).toBeVisible();
  await expect(
    page.getByTestId(`em-merge-opt-all-${meetingId}`),
  ).toBeVisible();
});

test("Schedule: a stored merge that becomes non-contiguous after a column reorder still exposes Unmerge", async ({
  page,
}) => {
  await setLangEn(page);

  const date = uniqueFutureDate(9);
  const uniq = `${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 6)}`;
  // Seed a row that already has a stored merge spanning meeting →
  // attendees (canonical indices 1 → 2). Once we reorder the visible
  // columns to [number, meeting, time, attendees], "meeting" sits at
  // visible index 1 and "attendees" at visible index 3 — that gap of
  // one (the "time" column dropped between them) is exactly the
  // non-contiguous case that forces the schedule to fall back to
  // rendering the cells unmerged.
  const meetingId = await insertMeeting({
    meetingDate: date,
    dailyNumber: 1,
    titleEn: `NonContig ${uniq}`,
    titleAr: `غير متلاصق ${uniq}`,
    startTime: "09:00:00",
    endTime: "10:00:00",
    mergeStartColumn: "meeting",
    mergeEndColumn: "attendees",
    mergeText: "<p>spanning meeting + attendees</p>",
  });

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

  // Reorder the schedule columns so "time" sits between meeting and
  // attendees, breaking the contiguity of the stored merge.
  await page.evaluate(() => {
    const cols = [
      { id: "number", visible: true, width: 56 },
      { id: "meeting", visible: true, width: 360 },
      { id: "time", visible: true, width: 200 },
      { id: "attendees", visible: true, width: 280 },
    ];
    window.localStorage.setItem("em-schedule-cols-v1", JSON.stringify(cols));
  });
  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 });

  // Confirm the column header order matches what we wrote — order
  // matters for the contiguity calculation, so a misread here would
  // give a misleading green.
  const headerIds = await page
    .locator('[data-testid^="em-col-header-"]')
    .evaluateAll((els) =>
      els
        .map((el) => el.getAttribute("data-testid"))
        .filter((id) => id && id.startsWith("em-col-header-"))
        .map((id) => id.replace("em-col-header-", "")),
    );
  expect(headerIds).toEqual(["number", "meeting", "time", "attendees"]);

  // Because the stored merge no longer maps to contiguous visible
  // indices, the schedule should NOT render a single colSpan cell —
  // the original cells stay intact.
  await expect(
    page.getByTestId(`em-merge-cell-${meetingId}`),
  ).toHaveCount(0);

  // Edit mode is required for the kebab to mount.
  await ensureEditModeOn(page);

  // The kebab is still anchored to the first visible cell of the
  // unmerged-rendering row, and opening Merge → … must surface the
  // Unmerge option (because hasStoredMerge is true even though the
  // current visible layout can't render the merge).
  const kebab = row.locator('[data-testid^="em-row-actions-"]').first();
  await expect(kebab).toHaveCount(1);
  await kebab.click({ force: true });
  await page.getByTestId(`em-merge-trigger-${meetingId}`).click();
  const unmerge = page.getByTestId(`em-merge-opt-unmerge-${meetingId}`);
  await expect(unmerge).toBeVisible();

  // Clicking Unmerge actually clears the stored merge — confirm the
  // PATCH lands and the DB row is wiped clean. Without this assertion
  // a regression that stops sending `merge: null` (e.g. wires the
  // button up but forgets to invoke onChangeMerge(null)) would slip.
  const clearPromise = 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 unmerge.click();
  await clearPromise;

  const { rows } = await pool.query(
    `SELECT merge_start_column, merge_end_column, merge_text
       FROM executive_meetings WHERE id = $1`,
    [meetingId],
  );
  expect(rows[0].merge_start_column).toBeNull();
  expect(rows[0].merge_end_column).toBeNull();
  expect(rows[0].merge_text).toBeNull();
});
