// #486: Backend test for POST /executive-meetings/swap-times.
//
// The schedule row quick-actions popover (Move up / Move down) calls
// this endpoint to swap just the (startTime, endTime) tuple between
// the clicked meeting and its chronological neighbour. The Time
// column stays visually anchored — each row keeps its slot and the
// meetings rotate through the slots — and daily numbers are
// recomputed by start time.
//
// Cases:
//   1. Happy path: A and B on same date with valid updatedAt tokens
//      → 200, times swapped, dailyNumber re-sorted, both audit rows
//      written.
//   2. Stale token on either row → 409 stale_meeting + conflict
//      payload that names the offending row + last actor.
//   3. Different dates → 400 different_dates.
//   4. Missing time window → 400 no_time_window.
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";

const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
  throw new Error("DATABASE_URL must be set to run these tests");
}

const TEST_PASSWORD_HASH =
  "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const TEST_PASSWORD = "TestPass123!";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const created = { userIds: [], meetingIds: [] };

function uniqueName(prefix) {
  return `${prefix}_${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 8)}`;
}

async function createUser(prefix, roleName, displayNameEn) {
  const username = uniqueName(prefix);
  const { rows } = await pool.query(
    `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
     VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
    [username, `${username}@example.com`, TEST_PASSWORD_HASH, displayNameEn],
  );
  const id = rows[0].id;
  created.userIds.push(id);
  for (const r of ["user", roleName]) {
    await pool.query(
      `INSERT INTO user_roles (user_id, role_id)
       SELECT $1, id FROM roles WHERE name = $2
       ON CONFLICT DO NOTHING`,
      [id, r],
    );
  }
  return { id, username };
}

function extractCookie(res) {
  const setCookie = res.headers.get("set-cookie");
  if (!setCookie) return null;
  return (
    setCookie
      .split(",")
      .map((c) => c.split(";")[0].trim())
      .find((c) => c.startsWith("connect.sid=")) ?? null
  );
}

async function login(username, password) {
  const res = await fetch(`${API_BASE}/api/auth/login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username, password }),
  });
  assert.equal(res.status, 200, `login should succeed for ${username}`);
  const cookie = extractCookie(res);
  assert.ok(cookie, "login response should set a session cookie");
  return cookie;
}

async function api(cookie, method, path, body) {
  const init = {
    method,
    headers: {
      Cookie: cookie,
      ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
    },
  };
  if (body !== undefined) init.body = JSON.stringify(body);
  return fetch(`${API_BASE}${path}`, init);
}

let userA = null;
let cookieA = null;
let userB = null;
let cookieB = null;
let viewerCookie = null;

before(async () => {
  userA = await createUser("em_swap_a", "executive_coord_lead", "Alice Swap");
  cookieA = await login(userA.username, TEST_PASSWORD);
  userB = await createUser("em_swap_b", "executive_coord_lead", "Bob Swap");
  cookieB = await login(userB.username, TEST_PASSWORD);
  // executive_viewer is the read-only role — passes
  // requireExecutiveAccess but is NOT in MUTATE_ROLES, so it lets us
  // assert that swap-times rejects non-mutators with 403.
  const viewer = await createUser("em_swap_view", "executive_viewer", "Vee Viewer");
  viewerCookie = await login(viewer.username, TEST_PASSWORD);
});

after(async () => {
  if (created.meetingIds.length > 0) {
    await pool.query(
      `DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
      [created.meetingIds],
    );
    await pool.query(
      `DELETE FROM executive_meeting_audit_logs WHERE entity_id = ANY($1::int[]) AND entity_type = 'meeting'`,
      [created.meetingIds],
    );
    await pool.query(
      `DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
      [created.meetingIds],
    );
  }
  if (created.userIds.length > 0) {
    await pool.query(
      `DELETE FROM executive_meeting_notification_prefs WHERE user_id = ANY($1::int[])`,
      [created.userIds],
    );
    await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
      created.userIds,
    ]);
    await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
      created.userIds,
    ]);
  }
  await pool.end();
});

// Use distinct far-future dates per test so each scenario gets a
// clean day — avoids any interference with seeded recurring meetings,
// other suites running in the same DB, and the `executive_meetings_
// date_number_unique` constraint that nextDailyNumber races against.
function futureDate(offsetDays) {
  return new Date(Date.now() + offsetDays * 86_400_000)
    .toISOString()
    .slice(0, 10);
}
const DATE_HAPPY = futureDate(30);
const DATE_STALE = futureDate(31);
const DATE_DIFF_A = futureDate(32);
const DATE_DIFF_B = futureDate(33);
const DATE_NO_WINDOW = futureDate(34);

async function createMeeting({ date, titleEn, startTime, endTime }) {
  const res = await api(cookieA, "POST", "/api/executive-meetings", {
    titleAr: titleEn,
    titleEn,
    meetingDate: date,
    startTime,
    endTime,
    status: "scheduled",
  });
  if (res.status !== 200 && res.status !== 201) {
    const body = await res.text();
    assert.fail(
      `meeting create should succeed (got ${res.status}): ${body.slice(0, 400)}`,
    );
  }
  const body = await res.json();
  const meeting = body.meeting ?? body;
  created.meetingIds.push(meeting.id);
  return meeting;
}

async function fetchMeeting(cookie, id) {
  const res = await api(cookie, "GET", `/api/executive-meetings/${id}`);
  assert.equal(res.status, 200, "meeting fetch should succeed");
  return res.json();
}

test("swap-times: happy path swaps (startTime, endTime) and re-sorts daily numbers", async () => {
  const a = await createMeeting({
    date: DATE_HAPPY,
    titleEn: "Swap A",
    startTime: "09:00",
    endTime: "09:30",
  });
  const b = await createMeeting({
    date: DATE_HAPPY,
    titleEn: "Swap B",
    startTime: "10:00",
    endTime: "10:30",
  });
  const aFresh = await fetchMeeting(cookieA, a.id);
  const bFresh = await fetchMeeting(cookieA, b.id);
  const beforeNumA = aFresh.dailyNumber;
  const beforeNumB = bFresh.dailyNumber;
  assert.notEqual(
    beforeNumA,
    beforeNumB,
    "two meetings on the same day should have distinct daily numbers",
  );

  const res = await api(cookieA, "POST", "/api/executive-meetings/swap-times", {
    aId: a.id,
    bId: b.id,
    expectedUpdatedAtA: aFresh.updatedAt,
    expectedUpdatedAtB: bFresh.updatedAt,
  });
  assert.equal(res.status, 200, "swap should succeed");
  const body = await res.json();
  assert.equal(body.ok, true);
  assert.equal(body.meetings.length, 2);

  const aAfter = await fetchMeeting(cookieA, a.id);
  const bAfter = await fetchMeeting(cookieA, b.id);
  // Times swapped — A now holds B's old window and vice versa.
  assert.equal(aAfter.startTime.slice(0, 5), "10:00");
  assert.equal(aAfter.endTime.slice(0, 5), "10:30");
  assert.equal(bAfter.startTime.slice(0, 5), "09:00");
  assert.equal(bAfter.endTime.slice(0, 5), "09:30");
  // Daily numbers re-sorted by start time, so A and B exchanged
  // positions in the `#` column too.
  assert.equal(aAfter.dailyNumber, beforeNumB);
  assert.equal(bAfter.dailyNumber, beforeNumA);

  // Both rows audited.
  const { rows } = await pool.query(
    `SELECT entity_id FROM executive_meeting_audit_logs
      WHERE entity_type = 'meeting'
        AND action = 'meeting_swap_times'
        AND entity_id = ANY($1::int[])`,
    [[a.id, b.id]],
  );
  const auditedIds = rows.map((r) => r.entity_id).sort();
  assert.deepEqual(auditedIds, [a.id, b.id].sort());
});

test("swap-times: stale updatedAt returns 409 stale_meeting with conflict payload", async () => {
  const a = await createMeeting({
    date: DATE_STALE,
    titleEn: "Stale A",
    startTime: "11:00",
    endTime: "11:30",
  });
  const b = await createMeeting({
    date: DATE_STALE,
    titleEn: "Stale B",
    startTime: "12:00",
    endTime: "12:30",
  });
  const aFresh = await fetchMeeting(cookieA, a.id);
  const bFresh = await fetchMeeting(cookieA, b.id);
  // Bob mutates B in between A's read and A's swap, invalidating the
  // updatedAt token Alice captured.
  const patch = await api(cookieB, "PATCH", `/api/executive-meetings/${b.id}`, {
    titleEn: "Stale B (renamed)",
  });
  assert.ok(
    patch.status === 200 || patch.status === 204,
    `patch should succeed (got ${patch.status})`,
  );
  const swap = await api(
    cookieA,
    "POST",
    "/api/executive-meetings/swap-times",
    {
      aId: a.id,
      bId: b.id,
      expectedUpdatedAtA: aFresh.updatedAt,
      expectedUpdatedAtB: bFresh.updatedAt,
    },
  );
  assert.equal(swap.status, 409);
  const body = await swap.json();
  assert.equal(body.code, "stale_meeting");
  assert.ok(body.conflict, "stale response must include conflict payload");
  assert.equal(body.conflict.id, b.id);
  assert.ok(body.conflict.lastActor, "conflict must name an actor");
  assert.equal(body.conflict.lastActor.id, userB.id);
});

test("swap-times: different dates → 400 different_dates", async () => {
  const a = await createMeeting({
    date: DATE_DIFF_A,
    titleEn: "Date A",
    startTime: "13:00",
    endTime: "13:30",
  });
  const b = await createMeeting({
    date: DATE_DIFF_B,
    titleEn: "Date B",
    startTime: "13:00",
    endTime: "13:30",
  });
  const aFresh = await fetchMeeting(cookieA, a.id);
  const bFresh = await fetchMeeting(cookieA, b.id);
  const res = await api(cookieA, "POST", "/api/executive-meetings/swap-times", {
    aId: a.id,
    bId: b.id,
    expectedUpdatedAtA: aFresh.updatedAt,
    expectedUpdatedAtB: bFresh.updatedAt,
  });
  assert.equal(res.status, 400);
  const body = await res.json();
  assert.equal(body.code, "different_dates");
});

test("swap-times: unauthenticated request → 401", async () => {
  const res = await fetch(`${API_BASE}/api/executive-meetings/swap-times`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      aId: 1,
      bId: 2,
      expectedUpdatedAtA: new Date().toISOString(),
      expectedUpdatedAtB: new Date().toISOString(),
    }),
  });
  assert.equal(res.status, 401);
});

test("swap-times: viewer (no mutate role) → 403 forbidden", async () => {
  // Create the pair as a mutator first, then try to swap as a viewer.
  const a = await createMeeting({
    date: futureDate(35),
    titleEn: "Viewer A",
    startTime: "08:00",
    endTime: "08:30",
  });
  const b = await createMeeting({
    date: futureDate(35),
    titleEn: "Viewer B",
    startTime: "09:00",
    endTime: "09:30",
  });
  const aFresh = await fetchMeeting(cookieA, a.id);
  const bFresh = await fetchMeeting(cookieA, b.id);
  const res = await api(
    viewerCookie,
    "POST",
    "/api/executive-meetings/swap-times",
    {
      aId: a.id,
      bId: b.id,
      expectedUpdatedAtA: aFresh.updatedAt,
      expectedUpdatedAtB: bFresh.updatedAt,
    },
  );
  assert.equal(res.status, 403, "viewer must not be able to swap times");
});

test("swap-times: malformed expectedUpdatedAt → 400 from zod, not 500", async () => {
  // The route passes expectedUpdatedAt straight to new Date(...).toISOString().
  // Without a strict zod guard a non-ISO string would throw and surface as a
  // 500. Lock the controlled-failure contract here.
  const res = await api(cookieA, "POST", "/api/executive-meetings/swap-times", {
    aId: 1,
    bId: 2,
    expectedUpdatedAtA: "not-a-real-timestamp",
    expectedUpdatedAtB: new Date().toISOString(),
  });
  assert.equal(res.status, 400, "malformed timestamp must be rejected at validation");
});

test("swap-times: meeting without a time window → 400 no_time_window", async () => {
  // Create A normally, then null out B's start/end directly so we can
  // exercise the guard without going through PATCH (which validates).
  const a = await createMeeting({
    date: DATE_NO_WINDOW,
    titleEn: "Window A",
    startTime: "15:00",
    endTime: "15:30",
  });
  const b = await createMeeting({
    date: DATE_NO_WINDOW,
    titleEn: "Window B",
    startTime: "16:00",
    endTime: "16:30",
  });
  await pool.query(
    `UPDATE executive_meetings SET start_time = NULL, end_time = NULL WHERE id = $1`,
    [b.id],
  );
  const aFresh = await fetchMeeting(cookieA, a.id);
  const bFresh = await fetchMeeting(cookieA, b.id);
  const res = await api(cookieA, "POST", "/api/executive-meetings/swap-times", {
    aId: a.id,
    bId: b.id,
    expectedUpdatedAtA: aFresh.updatedAt,
    expectedUpdatedAtB: bFresh.updatedAt,
  });
  assert.equal(res.status, 400);
  const body = await res.json();
  assert.equal(body.code, "no_time_window");
});
