// #497: e2e for the new rotation behaviour. Originally (#492) the
// client + server hard-blocked row-drag rotation if ANY meeting on
// the day was missing a (start_time, end_time) tuple — surfaced via
// an amber per-row warning button, dimmed/aria-disabled rows, and a
// `code: "no_time_window"` 400 from the server. Per product feedback
// that block was lifted: meetings without a time tuple are a normal
// state and rotate freely alongside timed rows. This spec verifies:
//   * no per-row drag-blocked warning button is rendered;
//   * the row is NOT aria-disabled and has no `data-drag-blocked`;
//   * dragging a row past an untimed sibling fires a successful
//     POST /api/executive-meetings/rotate-content;
//   * the untimed slot rotates with the rest — its NULL tuple ends
//     up on whichever physical row dnd-kit dropped it on.
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 rotate-allows-missing-times test",
  );
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];

function pickFutureDate(offsetDays) {
  const d = new Date(Date.now() + offsetDays * 86_400_000);
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}

const DATE = pickFutureDate(124);

async function nextDailyNumberForDate(date) {
  const { rows } = await pool.query(
    `SELECT COALESCE(MAX(daily_number), 0) + 1 AS n
       FROM executive_meetings
      WHERE meeting_date = $1`,
    [date],
  );
  return rows[0].n;
}

async function insertMeeting({ date, titleEn, startTime, endTime }) {
  const dn = await nextDailyNumberForDate(date);
  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, $2, $3, $4, $5, 'scheduled')
     RETURNING id`,
    [dn, titleEn, date, startTime, endTime],
  );
  const id = rows[0].id;
  createdMeetingIds.push(id);
  return id;
}

async function readMeeting(id) {
  const { rows } = await pool.query(
    `SELECT title_en, start_time, end_time, daily_number
       FROM executive_meetings WHERE id = $1`,
    [id],
  );
  return rows[0];
}

async function setLangEn(page) {
  await page.addInitScript(() => {
    try {
      window.localStorage.setItem("tx-lang", "en");
    } catch {
      /* ignore */
    }
  });
}

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

async function gotoTestDate(page, date) {
  await page.goto("/executive-meetings");
  const dateInput = page.locator('input[type="date"]').first();
  await dateInput.waitFor({ state: "visible", timeout: 10_000 });
  await dateInput.fill(date);
  await dateInput.dispatchEvent("change");
}

test.afterAll(async () => {
  if (createdMeetingIds.length > 0) {
    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_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
      [createdMeetingIds],
    );
    await pool.query(
      `DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
      [createdMeetingIds],
    );
  }
  await pool.end();
});

test("row-drag rotation succeeds even when one meeting on the day has no time tuple", async ({
  page,
}) => {
  const timedId = await insertMeeting({
    date: DATE,
    titleEn: "MissingTimes Timed",
    startTime: "09:00:00",
    endTime: "09:30:00",
  });
  const untimedId = await insertMeeting({
    date: DATE,
    titleEn: "MissingTimes Untimed",
    startTime: null,
    endTime: null,
  });
  const otherId = await insertMeeting({
    date: DATE,
    titleEn: "MissingTimes Other",
    startTime: "11:00:00",
    endTime: "11:30:00",
  });

  await setLangEn(page);
  await loginViaUi(page, "admin", "admin123");
  await gotoTestDate(page, DATE);

  // Force EN regardless of the admin user's stored preferredLanguage.
  await page.evaluate(async () => {
    const w = window;
    if (w.i18next && typeof w.i18next.changeLanguage === "function") {
      await w.i18next.changeLanguage("en");
    }
  });

  const timedRow = page.getByTestId(`em-row-${timedId}`);
  const untimedRow = page.getByTestId(`em-row-${untimedId}`);
  const otherRow = page.getByTestId(`em-row-${otherId}`);
  await expect(timedRow).toBeVisible({ timeout: 10_000 });
  await expect(untimedRow).toBeVisible();
  await expect(otherRow).toBeVisible();

  // No row is aria-disabled or marked drag-blocked, and the per-row
  // amber warning button is gone (the underlying drag gate was
  // lifted in #497).
  for (const row of [timedRow, untimedRow, otherRow]) {
    const aria = await row.getAttribute("aria-disabled");
    expect(aria === null || aria === "false").toBe(true);
    expect(await row.getAttribute("data-drag-blocked")).toBeNull();
  }
  for (const id of [timedId, untimedId, otherId]) {
    await expect(
      page.getByTestId(`em-row-drag-blocked-info-${id}`),
    ).toHaveCount(0);
  }

  // Drag the timed row down past the untimed row. The rotate-content
  // POST must fire and succeed, proving the server no longer rejects
  // payloads that include a NULL-time row.
  const rotatePromise = page.waitForResponse(
    (resp) => {
      const u = new URL(resp.url());
      return (
        u.pathname === "/api/executive-meetings/rotate-content" &&
        resp.request().method() === "POST" &&
        resp.ok()
      );
    },
    { timeout: 20_000 },
  );
  // Display order is chronological with NULL start times last (the
  // server's renumber + the page sort both honour NULLS LAST), so
  // the on-screen rows are: timedRow (09:00), otherRow (11:00),
  // untimedRow (null). Drag the BOTTOM row (untimed) UP to the top
  // slot. Whether dnd-kit lands the drop at slot 1 or slot 2, the
  // untimed row's null tuple is GUARANTEED to leave its original
  // physical slot — which is exactly what we need to prove.
  const numCell = page
    .locator(`[data-testid="em-row-${untimedId}"] td`)
    .first();
  await numCell.scrollIntoViewIfNeeded();
  const numBox = await numCell.boundingBox();
  const topBox = await timedRow.boundingBox();
  if (!numBox || !topBox) throw new Error("rows must be visible");
  const startX = numBox.x + numBox.width / 2;
  const startY = numBox.y + numBox.height / 2;
  await page.mouse.move(startX, startY);
  await page.mouse.down();
  await page.mouse.move(startX, startY - 12, { steps: 5 });
  await page.mouse.move(startX, topBox.y + 4, { steps: 15 });
  await page.mouse.up();
  await rotatePromise;

  // Per-meeting assertions (not a multiset invariant). The untimed
  // row was originally the only one with a null tuple; after the
  // rotation it MUST have a real time tuple (whichever slot it
  // landed in), proving the null tuple moved to a different physical
  // row. Exactly one of the other two rows now carries the null
  // tuple — that row's daily_number must sort to the end (the
  // server's renumber uses NULLS LAST).
  await expect
    .poll(async () => (await readMeeting(untimedId)).start_time, {
      timeout: 10_000,
    })
    .not.toBeNull();
  const timedAfter = await readMeeting(timedId);
  const untimedAfter = await readMeeting(untimedId);
  const otherAfter = await readMeeting(otherId);
  // Untimed row gained a real time tuple (the null moved off it).
  expect(untimedAfter.start_time).not.toBeNull();
  expect(untimedAfter.end_time).not.toBeNull();
  // Exactly one of the OTHER two rows now carries the null tuple.
  const allAfter = [timedAfter, otherAfter];
  const nullCarriers = allAfter.filter(
    (m) => m.start_time === null && m.end_time === null,
  );
  expect(nullCarriers).toHaveLength(1);
  // The two real time tuples (09:00 and 11:00) are still both
  // present somewhere in the day — slots stay anchored to physical
  // rows even as content rotates between them.
  const allRows = [timedAfter, untimedAfter, otherAfter];
  const realStarts = allRows
    .map((m) => m.start_time)
    .filter((t) => t !== null)
    .sort();
  expect(realStarts).toEqual(["09:00:00", "11:00:00"]);
  // Sanity: each row still has a daily_number > 0 and the null-tuple
  // carrier sorted to the end (renumber honours NULLS LAST).
  for (const m of allRows) {
    expect(typeof m.daily_number).toBe("number");
    expect(m.daily_number).toBeGreaterThan(0);
  }
  const nullCarrier = nullCarriers[0];
  for (const m of allRows) {
    if (m === nullCarrier) continue;
    expect(nullCarrier.daily_number).toBeGreaterThan(m.daily_number);
  }
});
