import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import zlib from "node:zlib";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
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) {
  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, 'EM Test', 'en', true) RETURNING id`,
    [username, `${username}@example.com`, TEST_PASSWORD_HASH],
  );
  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 adminCookie = null;
let adminUserId = null;
let coordCookie = null;
let coordUserId = null;
let leadCookie = null;
let leadUserId = null;

before(async () => {
  adminCookie = await login("admin", "admin123");
  const meRes = await api(adminCookie, "GET", "/api/auth/me");
  const me = await meRes.json();
  adminUserId = me.id ?? me.userId;

  const coord = await createUser("em_coord", "executive_coordinator");
  coordUserId = coord.id;
  coordCookie = await login(coord.username, TEST_PASSWORD);

  const lead = await createUser("em_lead", "executive_coord_lead");
  leadUserId = lead.id;
  leadCookie = await login(lead.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) {
    // Notification prefs are owned by users with ON DELETE CASCADE,
    // but be explicit so we don't rely on the cascade if the FK is ever
    // changed.
    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();
});

const today = new Date().toISOString().slice(0, 10);
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
  .toISOString()
  .slice(0, 10);

test("GET /me exposes the surviving capability flags", async () => {
  const res = await api(adminCookie, "GET", "/api/executive-meetings/me");
  assert.equal(res.status, 200);
  const body = await res.json();
  for (const key of [
    "userId",
    "roles",
    "canRead",
    "canMutate",
    "canEditGlobalFontSettings",
    "canViewAudit",
  ]) {
    assert.ok(key in body, `missing ${key} in /me response`);
  }
  for (const removed of [
    "canApprove",
    "canSubmitRequest",
    "canViewTasks",
    "canViewAllTasks",
  ]) {
    assert.ok(!(removed in body), `unexpected ${removed} in /me response`);
  }
  assert.equal(body.canEditGlobalFontSettings, true);

  const coordRes = await api(coordCookie, "GET", "/api/executive-meetings/me");
  const coordMe = await coordRes.json();
  assert.equal(coordMe.canRead, true);
  assert.equal(coordMe.canEditGlobalFontSettings, false);
});

test("Meetings: bilingual title required (titleEn cannot be empty)", async () => {
  const missingEn = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "اجتماع",
    meetingDate: today,
  });
  assert.equal(missingEn.status, 400);

  const emptyEn = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "اجتماع",
    titleEn: "",
    meetingDate: today,
  });
  assert.equal(emptyEn.status, 400);
});

test("Meetings: POST → GET day → DELETE roundtrip", async () => {
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "اجتماع اختبار",
    titleEn: "Test meeting",
    meetingDate: today,
    startTime: "09:00",
    endTime: "10:00",
    platform: "none",
    status: "scheduled",
    isHighlighted: 0,
    attendees: [
      { name: "Tester One", attendanceType: "internal", sortOrder: 0 },
    ],
  });
  assert.equal(create.status, 201);
  const meeting = await create.json();
  assert.ok(meeting.id);
  created.meetingIds.push(meeting.id);

  const day = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings?date=${today}`,
  );
  assert.equal(day.status, 200);
  const dayBody = await day.json();
  assert.ok(Array.isArray(dayBody.meetings));
  assert.ok(dayBody.meetings.some((m) => m.id === meeting.id));

  const del = await api(
    adminCookie,
    "DELETE",
    `/api/executive-meetings/${meeting.id}`,
  );
  assert.ok(del.status === 200 || del.status === 204);
});

test("Meetings: PUT /attendees replaces the attendee list", async () => {
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ا",
    titleEn: "A",
    meetingDate: today,
    attendees: [{ name: "Old One", attendanceType: "internal", sortOrder: 0 }],
  });
  assert.equal(create.status, 201);
  const meeting = await create.json();
  created.meetingIds.push(meeting.id);

  const replace = await api(
    adminCookie,
    "PUT",
    `/api/executive-meetings/${meeting.id}/attendees`,
    {
      attendees: [
        { name: "New One", title: "Director", attendanceType: "internal", sortOrder: 0 },
        { name: "New Two", title: "Manager", attendanceType: "virtual", sortOrder: 1 },
      ],
    },
  );
  assert.equal(replace.status, 200);
  const fetched = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings/${meeting.id}`,
  );
  const body = await fetched.json();
  assert.equal(body.attendees.length, 2);
  assert.ok(body.attendees.find((a) => a.name === "New One"));
  assert.ok(!body.attendees.find((a) => a.name === "Old One"));
});

// --- Attendee payload contract ---------------------------------
// Server is `.strict()` on the attendee schema so client-only fields like
// `_sid` (the React row id used by the drag-and-drop editor) are rejected
// loudly with 400 instead of being silently stripped. This locks down the
// wire contract so a future client refactor that accidentally serializes
// `_sid` (or any other internal field) breaks tests immediately rather
// than being absorbed by lenient parsing.
test("Attendee payload contract: POST rejects unknown fields like `_sid`", async () => {
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "س",
    titleEn: "S",
    meetingDate: today,
    attendees: [
      {
        name: "Leaky One",
        attendanceType: "internal",
        sortOrder: 0,
        _sid: "client-only-row-id",
      },
    ],
  });
  assert.equal(
    create.status,
    400,
    `expected 400 when attendee carries _sid, got ${create.status}`,
  );
  const body = await create.json();
  // Zod surfaces the rejected key in the error path.
  const serialized = JSON.stringify(body);
  assert.ok(
    serialized.includes("_sid") || serialized.toLowerCase().includes("unrecognized"),
    `expected error to mention the rejected key, got: ${serialized}`,
  );
});

test("Attendee payload contract: PATCH /:id rejects unknown attendee fields like `_sid`", async () => {
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ت",
    titleEn: "T",
    meetingDate: today,
    attendees: [{ name: "Patch Seed", attendanceType: "internal", sortOrder: 0 }],
  });
  assert.equal(create.status, 201);
  const meeting = await create.json();
  created.meetingIds.push(meeting.id);

  const patch = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    {
      attendees: [
        {
          name: "Leaky Three",
          attendanceType: "internal",
          sortOrder: 0,
          _sid: "client-only-row-id",
        },
      ],
    },
  );
  assert.equal(
    patch.status,
    400,
    `expected 400 when PATCH attendee carries _sid, got ${patch.status}`,
  );

  // The seed attendee must still be the only one stored — the rejected
  // PATCH must not have replaced the attendee list.
  const fetched = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings/${meeting.id}`,
  );
  const body = await fetched.json();
  assert.equal(body.attendees.length, 1);
  assert.equal(body.attendees[0].name, "Patch Seed");
});

test("Attendee payload contract: PUT /attendees rejects unknown fields like `_sid`", async () => {
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ش",
    titleEn: "Sh",
    meetingDate: today,
    attendees: [{ name: "Seed", attendanceType: "internal", sortOrder: 0 }],
  });
  assert.equal(create.status, 201);
  const meeting = await create.json();
  created.meetingIds.push(meeting.id);

  const replace = await api(
    adminCookie,
    "PUT",
    `/api/executive-meetings/${meeting.id}/attendees`,
    {
      attendees: [
        {
          name: "Leaky Two",
          attendanceType: "internal",
          sortOrder: 0,
          _sid: "client-only-row-id",
        },
      ],
    },
  );
  assert.equal(
    replace.status,
    400,
    `expected 400 when PUT attendee carries _sid, got ${replace.status}`,
  );

  // And the meeting's attendee list must not have changed (the seed row
  // still wins, the leaky row never persists).
  const fetched = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings/${meeting.id}`,
  );
  const body = await fetched.json();
  assert.equal(body.attendees.length, 1);
  assert.equal(body.attendees[0].name, "Seed");
});

test("Meetings: POST /duplicate clones a meeting onto another date", async () => {
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ب",
    titleEn: "B",
    meetingDate: today,
    attendees: [{ name: "Dup Att", attendanceType: "internal", sortOrder: 0 }],
  });
  assert.equal(create.status, 201);
  const original = await create.json();
  created.meetingIds.push(original.id);

  const dup = await api(
    adminCookie,
    "POST",
    `/api/executive-meetings/${original.id}/duplicate`,
    { targetDate: tomorrow },
  );
  assert.equal(dup.status, 201);
  const newRow = await dup.json();
  assert.notEqual(newRow.id, original.id);
  created.meetingIds.push(newRow.id);

  const day = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings?date=${tomorrow}`,
  );
  const dayBody = await day.json();
  assert.ok(dayBody.meetings.some((m) => m.id === newRow.id));
});

// #678: duplicating/repeating must preserve the external-meeting flag
// (and the shared row colour) instead of silently resetting the copy
// to a normal internal meeting.
test("Meetings: POST /duplicate preserves isExternal and rowColor on the copy", async () => {
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "خارجي",
    titleEn: "External",
    meetingDate: today,
    isExternal: true,
    attendees: [],
  });
  assert.equal(create.status, 201);
  const original = await create.json();
  created.meetingIds.push(original.id);
  assert.equal(original.isExternal, true, "source must be external");
  assert.equal(original.rowColor, "red", "external source is force-tinted red");

  const dup = await api(
    adminCookie,
    "POST",
    `/api/executive-meetings/${original.id}/duplicate`,
    { targetDate: tomorrow },
  );
  assert.equal(dup.status, 201);
  const copy = await dup.json();
  created.meetingIds.push(copy.id);
  assert.equal(copy.isExternal, true, "duplicate must stay external (#678)");
  assert.equal(copy.rowColor, "red", "duplicate keeps the red row colour");
});

// #189: plain-text sanitization for location / meetingUrl / notes.
test("Sanitization: POST strips HTML from location, meetingUrl, and notes", async () => {
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ل",
    titleEn: "L",
    meetingDate: today,
    location: '<script>alert("xss")</script>Conference Room A',
    meetingUrl:
      '<img src=x onerror=alert(1)>https://example.com/meet?a=1&b=2#room',
    notes: '<b>bold</b> note: 5 < 10 & ok <script>bad()</script>',
    attendees: [],
  });
  assert.equal(create.status, 201);
  const meeting = await create.json();
  created.meetingIds.push(meeting.id);

  assert.ok(!/<script/i.test(meeting.location ?? ""));
  assert.ok((meeting.location ?? "").includes("Conference Room A"));
  assert.ok(!/<img|onerror/i.test(meeting.meetingUrl ?? ""));
  assert.equal(meeting.meetingUrl, "https://example.com/meet?a=1&b=2#room");
  assert.ok(!/<script|<b>/i.test(meeting.notes ?? ""));
  assert.ok((meeting.notes ?? "").includes("5 < 10"));
  assert.ok((meeting.notes ?? "").includes("& ok"));
  assert.ok(!/&amp;|&lt;|&gt;/.test(meeting.notes ?? ""));
});

// Regression: encoded payloads must NOT be decoded back into live tags.
test("Sanitization: encoded HTML payloads (&lt;script&gt;…) are NOT decoded into live tags", async () => {
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ه",
    titleEn: "H",
    meetingDate: today,
    location: "&lt;script&gt;alert('loc')&lt;/script&gt;Boardroom",
    meetingUrl:
      "&#x3C;script&#x3E;alert('url')&#x3C;/script&#x3E;https://x.test/?a=1&b=2",
    notes: "ok &amp; safe — &lt;img onerror=alert(1) src=x&gt;",
    attendees: [],
  });
  assert.equal(create.status, 201);
  const meeting = await create.json();
  created.meetingIds.push(meeting.id);

  for (const field of ["location", "meetingUrl", "notes"]) {
    const v = meeting[field] ?? "";
    assert.ok(!/<script|<img|<iframe|<\/script/i.test(v));
  }
  assert.ok((meeting.location ?? "").includes("&lt;script&gt;"));
  assert.ok((meeting.meetingUrl ?? "").includes("&#x3C;script&#x3E;"));
  assert.ok(
    (meeting.notes ?? "").includes("&amp;") &&
      (meeting.notes ?? "").includes("&lt;img"),
  );

  const mixed = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "خ",
    titleEn: "X",
    meetingDate: today,
    location: "<ScRiPt>alert(1)</ScRiPt>Boardroom B",
    notes: "<IFRAME src=evil>x</IFRAME>visible note",
    attendees: [],
  });
  assert.equal(mixed.status, 201);
  const mm = await mixed.json();
  created.meetingIds.push(mm.id);
  assert.ok(!/<script|<iframe/i.test((mm.location ?? "") + (mm.notes ?? "")));
  assert.ok((mm.location ?? "").includes("Boardroom B"));
  assert.ok((mm.notes ?? "").includes("visible note"));
});

test("Sanitization: PATCH strips HTML from location, meetingUrl, and notes", async () => {
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "م",
    titleEn: "M",
    meetingDate: today,
    location: "Original",
    notes: "Original note",
    attendees: [],
  });
  assert.equal(create.status, 201);
  const meeting = await create.json();
  created.meetingIds.push(meeting.id);

  const patch = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    {
      location: '<script>steal()</script>Updated Room',
      meetingUrl: '<a href="javascript:bad()">https://meet.example.com</a>',
      notes: '<iframe src="x"></iframe>Updated note',
    },
  );
  assert.equal(patch.status, 200);

  const fetched = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings/${meeting.id}`,
  );
  const body = await fetched.json();
  assert.ok(!/<script/i.test(body.location ?? ""));
  assert.ok((body.location ?? "").includes("Updated Room"));
  assert.ok(!/<a |<iframe|javascript:/i.test(body.meetingUrl ?? ""));
  assert.ok((body.meetingUrl ?? "").includes("https://meet.example.com"));
  assert.ok(!/<iframe/i.test(body.notes ?? ""));
  assert.ok((body.notes ?? "").includes("Updated note"));
});

// #202: titleEn / titleAr are independent columns; saveTitle in
// executive-meetings.tsx routes by visible i18n.language, so a PATCH
// for one column must never clobber the other.
test("EditableCell contract: PATCH titleEn alone leaves titleAr untouched (and vice versa)", async () => {
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "العنوان الأصلي",
    titleEn: "Original Title",
    meetingDate: today,
    attendees: [],
  });
  assert.equal(create.status, 201);
  const meeting = await create.json();
  created.meetingIds.push(meeting.id);

  const patchEn = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    { titleEn: "<p>Edited English</p>" },
  );
  assert.equal(patchEn.status, 200);

  let fetched = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings/${meeting.id}`,
  );
  let body = await fetched.json();
  assert.ok(body.titleEn.includes("Edited English"));
  assert.ok(body.titleAr.includes("العنوان الأصلي"));

  const patchAr = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    { titleAr: "<p>عنوان معدل</p>" },
  );
  assert.equal(patchAr.status, 200);

  fetched = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings/${meeting.id}`,
  );
  body = await fetched.json();
  assert.ok(body.titleAr.includes("عنوان معدل"));
  assert.ok(body.titleEn.includes("Edited English"));
});

test("Sanitization: duplicate path re-sanitizes location/meetingUrl/notes and preserves URL ampersands", async () => {
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "د",
    titleEn: "D",
    meetingDate: today,
    location: "Boardroom 7",
    meetingUrl: "https://meet.example.com/x?room=42&token=abc",
    notes: "Bring slides & notes",
    attendees: [],
  });
  assert.equal(create.status, 201);
  const original = await create.json();
  created.meetingIds.push(original.id);

  const dup = await api(
    adminCookie,
    "POST",
    `/api/executive-meetings/${original.id}/duplicate`,
    { targetDate: tomorrow },
  );
  assert.equal(dup.status, 201);
  const cloned = await dup.json();
  created.meetingIds.push(cloned.id);

  assert.equal(cloned.meetingUrl, "https://meet.example.com/x?room=42&token=abc");
  assert.ok((cloned.notes ?? "").includes("&"));
  assert.ok(
    !/&amp;/.test(cloned.meetingUrl ?? "") && !/&amp;/.test(cloned.notes ?? ""),
  );
});

test("Audit logs: admin can list, plain coordinator gets 403", async () => {
  const ok = await api(
    adminCookie,
    "GET",
    "/api/executive-meetings/audit-logs",
  );
  assert.equal(ok.status, 200);
  const body = await ok.json();
  assert.ok(Array.isArray(body.logs ?? body.auditLogs ?? body.entries ?? []));

  const denied = await api(
    coordCookie,
    "GET",
    "/api/executive-meetings/audit-logs",
  );
  assert.equal(denied.status, 403);
});

test("Audit logs: dateFrom/dateTo, action, and actorId filters all narrow results", async () => {
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "س",
    titleEn: "S",
    meetingDate: today,
  });
  const meeting = await create.json();
  created.meetingIds.push(meeting.id);

  const filtered = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings/audit-logs?dateFrom=${today}&dateTo=${today}&action=create&actorId=${adminUserId}&entityType=meeting`,
  );
  assert.equal(filtered.status, 200);
  const body = await filtered.json();
  const entries = body.entries ?? body.logs ?? [];
  assert.ok(Array.isArray(entries));
  for (const e of entries) {
    assert.equal(e.action, "create");
    assert.equal(e.entityType, "meeting");
    assert.equal(e.performedBy, adminUserId);
  }
  assert.ok(entries.some((e) => e.entityId === meeting.id));
});

test("Notifications: GET filters by ?date= and tolerates empty days", async () => {
  const empty = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings/notifications?date=1990-01-01`,
  );
  assert.equal(empty.status, 200);
  const emptyBody = await empty.json();
  assert.ok(Array.isArray(emptyBody.notifications));

  const todayList = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings/notifications?date=${today}`,
  );
  assert.equal(todayList.status, 200);
});

test("Font settings: valid combo 200; invalid weight/size/family 400", async () => {
  const ok = await api(
    adminCookie,
    "PATCH",
    "/api/executive-meetings/font-settings",
    {
      scope: "user",
      fontFamily: "DIN Next LT Arabic",
      fontSize: 16,
      fontWeight: "bold",
      alignment: "center",
    },
  );
  assert.equal(ok.status, 200);

  const badWeight = await api(
    adminCookie,
    "PATCH",
    "/api/executive-meetings/font-settings",
    { scope: "user", fontWeight: "medium" },
  );
  assert.equal(badWeight.status, 400);

  const badSize = await api(
    adminCookie,
    "PATCH",
    "/api/executive-meetings/font-settings",
    { scope: "user", fontSize: 30 },
  );
  assert.equal(badSize.status, 400);

  const badFamily = await api(
    adminCookie,
    "PATCH",
    "/api/executive-meetings/font-settings",
    { scope: "user", fontFamily: "Comic Sans" },
  );
  assert.equal(badFamily.status, 400);
});

test("Font settings: user-scope rejects logoObjectPath with 400; null is allowed", async () => {
  const rejected = await api(
    adminCookie,
    "PUT",
    "/api/executive-meetings/font-settings",
    {
      scope: "user",
      fontFamily: "system",
      fontSize: 14,
      fontWeight: "regular",
      alignment: "start",
      fontColor: "#000000",
      logoObjectPath: "/objects/some-fake-id",
    },
  );
  assert.equal(rejected.status, 400);
  const body = await rejected.json();
  assert.equal(body.code, "logo_user_scope_forbidden");

  // null is allowed on user rows.
  const okNull = await api(
    adminCookie,
    "PUT",
    "/api/executive-meetings/font-settings",
    {
      scope: "user",
      fontFamily: "system",
      fontSize: 14,
      fontWeight: "regular",
      alignment: "start",
      fontColor: "#000000",
      logoObjectPath: null,
    },
  );
  assert.equal(okNull.status, 200);
});

test("PDF archives: POST creates a snapshot and GET returns it", async () => {
  const post = await api(
    adminCookie,
    "POST",
    "/api/executive-meetings/pdf-archives",
    { archiveDate: today },
  );
  assert.equal(post.status, 201);
  const archive = await post.json();
  assert.ok(archive.id);
  assert.ok(archive.version >= 1);

  const list = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings/pdf-archives?date=${today}`,
  );
  assert.equal(list.status, 200);
  const body = await list.json();
  const archives = body.archives ?? body.items ?? body.pdfArchives ?? [];
  assert.ok(Array.isArray(archives));
  assert.ok(archives.some((a) => a.id === archive.id));
});

test("PDF GET /executive-meetings/pdf returns a real PDF and archives it", async () => {
  // Create a meeting with mixed Arabic+Latin attendees + a time range so
  // the renderer's bidi/font-segmentation path is exercised.
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "اجتماع الميزانية ‪Q2 2026‬",
    titleEn: "Q2 2026 Budget Review",
    meetingDate: today,
    startTime: "10:00",
    endTime: "11:30",
    location: "قاعة كبرى",
    attendees: [
      { name: "أحمد العلي", title: "المدير التنفيذي",
        attendanceType: "internal", sortOrder: 0 },
      { name: "John Smith", title: "CFO",
        attendanceType: "external", sortOrder: 1 },
    ],
  });
  assert.equal(create.status, 201);
  const meeting = await create.json();
  created.meetingIds.push(meeting.id);

  // Bad date → 400
  const bad = await api(adminCookie, "GET",
    "/api/executive-meetings/pdf?date=not-a-date");
  assert.equal(bad.status, 400);

  // Coordinator without executive role is denied — auth gate matches the
  // rest of the executive-meetings module.
  const noAuth = await fetch(`${API_BASE}/api/executive-meetings/pdf?date=${today}`);
  assert.equal(noAuth.status, 401);

  // Happy path: real PDF response with non-trivial body.
  const res = await api(adminCookie, "GET",
    `/api/executive-meetings/pdf?date=${today}&lang=ar`);
  assert.equal(res.status, 200);
  assert.match(
    res.headers.get("content-type") || "",
    /^application\/pdf/,
    "must serve application/pdf",
  );
  assert.match(
    res.headers.get("content-disposition") || "",
    new RegExp(`attachment;\\s*filename="executive-meetings-${today}\\.pdf"`),
    "filename should include the requested date",
  );
  const buf = Buffer.from(await res.arrayBuffer());
  assert.ok(buf.length > 1000, `PDF should be >1KB, got ${buf.length}`);
  assert.equal(
    buf.subarray(0, 4).toString("ascii"),
    "%PDF",
    "body must start with PDF magic bytes",
  );

  // The download must have created an archive row recorded against the
  // generating user with byteSize matching the served body.
  const list = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings/pdf-archives?date=${today}`,
  );
  assert.equal(list.status, 200);
  const archives = (await list.json()).archives ?? [];
  const generated = archives.find(
    (a) => typeof a.byteSize === "number" && a.byteSize === buf.length,
  );
  assert.ok(
    generated,
    "PDF download must produce an archive row with the served byte_size",
  );
  assert.ok(generated.generatedBy, "generatedBy must be recorded");
  assert.ok(
    typeof generated.filePath === "string" && generated.filePath.length > 0,
    "filePath/storage_url must be non-empty",
  );

  // Empty-day handling: another date with no meetings still returns a
  // valid PDF (with a "no meetings" message).
  const empty = await api(adminCookie, "GET",
    `/api/executive-meetings/pdf?date=2099-01-01&lang=en`);
  assert.equal(empty.status, 200);
  const emptyBuf = Buffer.from(await empty.arrayBuffer());
  assert.equal(emptyBuf.subarray(0, 4).toString("ascii"), "%PDF");

  // Font-family must affect which font is embedded in the PDF. We render
  // the same day twice with different families and assert that:
  //   1) Both downloads succeed (200 + %PDF magic).
  //   2) The branded sans family ("DIN Next LT Arabic") embeds the real
  //      DINNextLTArabic face, while the Naskh-style family ("Majalla")
  //      embeds the real Majalla face. The PostScript name appears
  //      verbatim in the PDF font dictionary.
  const setSans = await api(
    adminCookie,
    "PUT",
    "/api/executive-meetings/font-settings",
    {
      scope: "user",
      fontFamily: "DIN Next LT Arabic",
      fontSize: 16,
      fontWeight: "bold",
      alignment: "center",
    },
  );
  assert.equal(setSans.status, 200);
  const sansRes = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings/pdf?date=${today}&lang=ar`,
  );
  assert.equal(sansRes.status, 200);
  const sansBuf = Buffer.from(await sansRes.arrayBuffer());
  assert.equal(sansBuf.subarray(0, 4).toString("ascii"), "%PDF");
  const sansAscii = sansBuf.toString("latin1");
  assert.ok(
    /DINNextLTArabic/i.test(sansAscii),
    "DIN Next LT Arabic family must embed DINNextLTArabic in the PDF",
  );
  assert.ok(
    !/NotoNaskhArabic/i.test(sansAscii),
    "DIN Next LT Arabic family must NOT embed NotoNaskhArabic",
  );

  const setNaskh = await api(
    adminCookie,
    "PUT",
    "/api/executive-meetings/font-settings",
    {
      scope: "user",
      fontFamily: "Majalla",
      fontSize: 14,
      fontWeight: "regular",
      alignment: "start",
    },
  );
  assert.equal(setNaskh.status, 200);
  const naskhRes = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings/pdf?date=${today}&lang=ar`,
  );
  assert.equal(naskhRes.status, 200);
  const naskhBuf = Buffer.from(await naskhRes.arrayBuffer());
  const naskhAscii = naskhBuf.toString("latin1");
  assert.ok(
    /Majalla/i.test(naskhAscii),
    "Majalla family must embed Majalla in the PDF",
  );
  assert.ok(
    !/NotoSansArabic|DINNextLTArabic/i.test(naskhAscii),
    "Majalla family must NOT embed a sans face",
  );

  // Exhaustively cover every remaining family in the FONT_FILES map so a
  // future silent stand-in regresses loudly. Each entry pairs the user-
  // facing family name with the PostScript-name fragment we expect to
  // see embedded verbatim in the PDF font dictionary.
  const extraFamilyCases = [
    { family: "Tajawal", expect: /Tajawal/i, forbid: /NotoSansArabic|DINNextLTArabic/i },
    {
      family: "Helvetica Neue LT Arabic",
      expect: /HelveticaNeueLTArabic/i,
      forbid: /NotoSansArabic|DINNextLTArabic/i,
    },
    { family: "system", expect: /NotoNaskhArabic/i, forbid: /NotoSansArabic|DINNextLTArabic/i },
  ];
  for (const { family, expect, forbid } of extraFamilyCases) {
    const setRes = await api(
      adminCookie,
      "PUT",
      "/api/executive-meetings/font-settings",
      {
        scope: "user",
        fontFamily: family,
        fontSize: 14,
        fontWeight: "regular",
        alignment: "start",
      },
    );
    assert.equal(setRes.status, 200, `${family}: PUT font-settings`);
    const pdfRes = await api(
      adminCookie,
      "GET",
      `/api/executive-meetings/pdf?date=${today}&lang=ar`,
    );
    assert.equal(pdfRes.status, 200, `${family}: GET pdf`);
    const pdfBuf = Buffer.from(await pdfRes.arrayBuffer());
    assert.equal(
      pdfBuf.subarray(0, 4).toString("ascii"),
      "%PDF",
      `${family}: %PDF magic`,
    );
    const ascii = pdfBuf.toString("latin1");
    assert.ok(expect.test(ascii), `${family} must embed ${expect}`);
    assert.ok(!forbid.test(ascii), `${family} must NOT embed ${forbid}`);
  }
});

test("PDF content: title label, rowColor tint, dropped isHighlighted, fontColor", async () => {
  // Inflate FlateDecode streams and assert title/tint/fontColor ops.
  const zlib = await import("node:zlib");
  const decodeStreams = (buf) => {
    const out = [];
    let cursor = 0;
    while (true) {
      const start = buf.indexOf("stream\n", cursor);
      if (start < 0) break;
      const end = buf.indexOf("\nendstream", start);
      if (end < 0) break;
      try {
        out.push(
          zlib
            .inflateSync(buf.slice(start + "stream\n".length, end))
            .toString("latin1"),
        );
      } catch {
        // not a deflate stream (font/image bytes) — skip
      }
      cursor = end + "\nendstream".length;
    }
    return out.join("\n");
  };

  const pdfDate = "2099-05-03";

  const ambered = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "اجتماع تجريبي",
    titleEn: "Tinted Meeting",
    meetingDate: pdfDate,
    attendees: [{ name: "ع", attendanceType: "internal", sortOrder: 0 }],
  });
  assert.equal(ambered.status, 201);
  const amberedId = (await ambered.json()).id;
  created.meetingIds.push(amberedId);
  const tint = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${amberedId}`,
    { rowColor: "amber" },
  );
  assert.equal(tint.status, 200);

  const highlighted = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "اجتماع آخر",
    titleEn: "Highlighted Meeting",
    meetingDate: pdfDate,
    isHighlighted: true,
    attendees: [{ name: "ب", attendanceType: "internal", sortOrder: 0 }],
  });
  assert.equal(highlighted.status, 201);
  created.meetingIds.push((await highlighted.json()).id);

  // #336699 → 0.2/0.4/0.6 in PDFKit's rg op.
  const setColor = await api(
    adminCookie,
    "PUT",
    "/api/executive-meetings/font-settings",
    {
      scope: "user",
      fontFamily: "Majalla",
      fontSize: 14,
      fontWeight: "regular",
      alignment: "start",
      fontColor: "#336699",
    },
  );
  assert.equal(setColor.status, 200);

  const res = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings/pdf?date=${pdfDate}&lang=ar`,
  );
  assert.equal(res.status, 200);
  const buf = Buffer.from(await res.arrayBuffer());
  const ascii = buf.toString("latin1");
  const decoded = decodeStreams(buf);

  // (1) /Title prefix as UTF-16BE for "قائم" (0x0642 0x0627 0x0626 0x0645).
  const titlePrefix = Buffer.from([
    0xfe, 0xff, 0x06, 0x42, 0x06, 0x27, 0x06, 0x26, 0x06, 0x45,
  ]);
  assert.ok(
    buf.includes(titlePrefix),
    "PDF /Title metadata must start with the new Arabic title (قائم…)",
  );

  // Accept rg or scn (PDFKit varies by version).
  const op = "(?:rg|scn)";

  // (2) rowColor=amber → #fef3c7 = 254/255, 243/255, 199/255.
  assert.match(
    decoded,
    new RegExp(`0\\.9960\\d* 0\\.9529\\d* 0\\.7803\\d* ${op}`),
    "amber rowColor must paint the saved palette fill",
  );

  // (3) Legacy isHighlighted fill (#fecaca) must NOT appear.
  assert.doesNotMatch(
    decoded,
    new RegExp(`0\\.9960\\d* 0\\.7921\\d* 0\\.7921\\d* ${op}`),
    "isHighlighted must no longer be baked into the PDF",
  );

  // (4) fontColor #336699 must appear as a fill op.
  assert.match(
    decoded,
    new RegExp(`(?:^|\\s)0\\.2 0\\.4 0\\.6 ${op}`),
    "user fontColor #336699 must appear as a fill operator",
  );

  void ascii;
});

test("PDF Arabic shaping: renderer hands raw Unicode to PDFKit (no manual pre-shaping)", async () => {
  // Regression for the bug where Arabic in the printed PDF appeared as
  // disconnected, visually-reversed letters. The fix: let PDFKit/fontkit
  // do the shaping + RTL reorder via `features: ['rtla','rclt',...]`,
  // and DO NOT call shapeArabic() ourselves before drawing.
  //
  // Why we no longer scan the PDF binary for U+FExx codepoints:
  //  ToUnicode CMaps are emitted by fontkit and naturally include
  //  presentation-form codepoints when the embedded Arabic font's own
  //  cmap subtable maps glyphs back to those codepoints. That's normal
  //  font internals — not pre-shaping by us — so a presence-check on
  //  the inflated streams produced false positives.
  //
  // Instead we do two things:
  //   1) End-to-end: render a real PDF for an Arabic meeting and assert
  //      the response is a non-trivial PDF that references at least one
  //      base Arabic codepoint somewhere in its inflated streams (proves
  //      Arabic content was drawn).
  //   2) Source-level guard: assert pdf-renderer.ts never CALLS
  //      shapeArabic() — the only allowed mention is its own export
  //      definition. This is the precise signal we care about.
  const shapedDate = "2099-05-04";
  const shapedMeeting = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "اجتماع الاختبار",
    titleEn: "Shaping Smoke Test",
    meetingDate: shapedDate,
    attendees: [
      { name: "محمد عبدالله", attendanceType: "internal", sortOrder: 0 },
    ],
  });
  assert.equal(shapedMeeting.status, 201);
  created.meetingIds.push((await shapedMeeting.json()).id);

  const res = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings/pdf?date=${shapedDate}&lang=ar`,
  );
  assert.equal(res.status, 200);
  const buf = Buffer.from(await res.arrayBuffer());
  assert.ok(buf.length > 1000, "rendered PDF must be a real, non-trivial document");
  assert.ok(
    buf.slice(0, 5).toString("latin1") === "%PDF-",
    "response must start with the %PDF- magic header",
  );

  // (1) Inflate streams and confirm at least one base Arabic codepoint
  //     appears — proves Arabic content was drawn through the pipeline.
  let cursor = 0;
  const decoded = [];
  while (true) {
    const start = buf.indexOf("stream\n", cursor);
    if (start < 0) break;
    const end = buf.indexOf("\nendstream", start);
    if (end < 0) break;
    try {
      decoded.push(
        zlib
          .inflateSync(buf.slice(start + "stream\n".length, end))
          .toString("latin1"),
      );
    } catch {
      // not a deflate stream
    }
    cursor = end + "\nendstream".length;
  }
  const allDecoded = decoded.join("\n");
  assert.match(
    allDecoded,
    /<(?:06[0-9A-F]{2})>/i,
    "rendered PDF must reference base Arabic codepoints somewhere in its streams",
  );

  // (2) Source-level guard: pdf-renderer.ts must not call shapeArabic().
  //     The only allowed appearance is the export's own signature.
  const here = path.dirname(fileURLToPath(import.meta.url));
  const rendererPath = path.resolve(here, "..", "src", "lib", "pdf-renderer.ts");
  const src = readFileSync(rendererPath, "utf8");
  // Hard-pin the contract: there must be exactly one definition of
  // `shapeArabic` in this file (export form). If someone refactors it
  // away or duplicates it, this guard fails fast instead of silently
  // letting an alias slip through.
  const defMatches = src.match(/^export\s+function\s+shapeArabic\b/gm) ?? [];
  assert.equal(
    defMatches.length,
    1,
    `pdf-renderer.ts must declare exactly one \`export function shapeArabic\` — found ${defMatches.length}. Update this guard if the contract intentionally changed.`,
  );
  // Strip the single definition (matched at start-of-line, balanced via
  // the closing brace at column 0) and assert the symbol is never
  // CALLED elsewhere — `name(` syntax including whitespace/comments.
  const withoutDef = src.replace(
    /^export\s+function\s+shapeArabic\b[\s\S]*?^\}\s*$/m,
    "",
  );
  // Match `shapeArabic` followed by optional whitespace/block-comments
  // then an opening paren — covers `shapeArabic(` and `shapeArabic /**/(`.
  const callRegex = /\bshapeArabic\b(?:\s|\/\*[\s\S]*?\*\/)*\(/;
  const callMatch = withoutDef.match(callRegex);
  assert.equal(
    callMatch,
    null,
    `pdf-renderer.ts must NOT call shapeArabic() — fontkit handles shaping via Arabic OpenType features. Re-introducing a pre-shaping pass causes the visually-reversed disconnected-letters regression. Found call: ${callMatch?.[0]}`,
  );
});

test("PDF embeds the brand logo when set in global font settings", async () => {
  // Build a real 1x1 RGB PNG (png-js rejects most base64 fixtures).
  const u32 = (n) => {
    const b = Buffer.alloc(4);
    b.writeUInt32BE(n, 0);
    return b;
  };
  const crc32 = (buf) => {
    const table = new Array(256);
    for (let n = 0; n < 256; n++) {
      let c = n;
      for (let k = 0; k < 8; k++)
        c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
      table[n] = c >>> 0;
    }
    let crc = 0xffffffff;
    for (let i = 0; i < buf.length; i++)
      crc = (table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8)) >>> 0;
    return (crc ^ 0xffffffff) >>> 0;
  };
  const mkChunk = (type, data) => {
    const typeBuf = Buffer.from(type, "ascii");
    const crc = crc32(Buffer.concat([typeBuf, data]));
    return Buffer.concat([u32(data.length), typeBuf, data, u32(crc)]);
  };
  const ihdr = Buffer.concat([u32(1), u32(1), Buffer.from([8, 2, 0, 0, 0])]);
  const idat = zlib.deflateSync(Buffer.from([0, 0, 255, 0]));
  const tinyPng = Buffer.concat([
    Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
    mkChunk("IHDR", ihdr),
    mkChunk("IDAT", idat),
    mkChunk("IEND", Buffer.alloc(0)),
  ]);

  // 1) Sign upload URL.
  const signRes = await api(
    adminCookie,
    "POST",
    "/api/storage/uploads/request-url",
    {
      name: "tx-os-test-logo.png",
      size: tinyPng.byteLength,
      contentType: "image/png",
    },
  );
  assert.equal(signRes.status, 200, "must be able to sign an upload URL");
  const { uploadURL, objectPath } = await signRes.json();
  assert.ok(/^\/objects\//.test(objectPath), `expected /objects/<id>, got ${objectPath}`);

  // 2) PUT bytes; skip on network/5xx, fail on 4xx.
  let putRes;
  try {
    putRes = await fetch(uploadURL, {
      method: "PUT",
      body: tinyPng,
      headers: {
        "Content-Type": "image/png",
        "Content-Length": String(tinyPng.byteLength),
      },
    });
  } catch (err) {
    console.warn(
      `[test] storage sidecar unreachable, skipping logo embed test: ${err.message}`,
    );
    return;
  }
  if (putRes.status >= 500) {
    console.warn(
      `[test] storage sidecar returned ${putRes.status}, skipping logo embed test`,
    );
    return;
  }
  assert.ok(
    putRes.ok,
    `signed PUT must succeed (got ${putRes.status} ${putRes.statusText})`,
  );

  // 3) Save logo on global row (try/finally resets it).
  const setLogo = await api(
    adminCookie,
    "PUT",
    "/api/executive-meetings/font-settings",
    {
      scope: "global",
      fontFamily: "system",
      fontSize: 14,
      fontWeight: "regular",
      alignment: "start",
      fontColor: "#000000",
      logoObjectPath: objectPath,
    },
  );
  assert.equal(setLogo.status, 200, "admin must be able to save the global logo");

  try {
    // 4) Render PDF (header draws unconditionally).
    const pdfDate = "2099-05-04";
    const res = await api(
      adminCookie,
      "GET",
      `/api/executive-meetings/pdf?date=${pdfDate}&lang=ar`,
    );
    assert.equal(
      res.status,
      200,
      "PDF render must succeed with a logo configured",
    );
    const buf = Buffer.from(await res.arrayBuffer());
    const ascii = buf.toString("latin1");

    // 5) Assert PDFKit emitted an Image XObject.
    assert.match(
      ascii,
      /\/Subtype\s*\/Image/,
      "uploaded brand logo must be embedded as an /Image XObject in the PDF",
    );
    // /Width 1 confirms our 1x1 fixture was embedded.
    assert.match(
      ascii,
      /\/Width\s+1[^0-9]/,
      "embedded image dictionary must reflect the 1x1 source PNG",
    );
  } finally {
    await api(
      adminCookie,
      "PUT",
      "/api/executive-meetings/font-settings",
      {
        scope: "global",
        fontFamily: "system",
        fontSize: 14,
        fontWeight: "regular",
        alignment: "start",
        fontColor: "#000000",
        logoObjectPath: null,
      },
    );
  }
});

test("Sanitization: rich text strips disallowed tags but keeps inline formatting", async () => {
  const dirty =
    'Hello <script>alert(1)</script><strong style="color:#ff0000">World</strong>' +
    '<a href="javascript:bad()">x</a><img src=x onerror=alert(1)>';
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: dirty,
    titleEn: dirty,
    meetingDate: today,
    attendees: [
      { name: 'Tester <em style="color:blue">One</em><script>x</script>',
        attendanceType: "internal", sortOrder: 0 },
    ],
  });
  assert.equal(create.status, 201);
  const meeting = await create.json();
  created.meetingIds.push(meeting.id);

  const day = await api(adminCookie, "GET",
    `/api/executive-meetings?date=${today}`);
  const body = await day.json();
  const m = body.meetings.find((x) => x.id === meeting.id);
  assert.ok(m, "meeting must come back");

  // disallowed tags must be stripped
  assert.ok(!/<script/i.test(m.titleAr), "script must be stripped from titleAr");
  assert.ok(!/<script/i.test(m.titleEn), "script must be stripped from titleEn");
  assert.ok(!/<img/i.test(m.titleAr), "img must be stripped");
  assert.ok(!/<a /i.test(m.titleAr), "anchor must be stripped");
  assert.ok(!/javascript:/i.test(m.titleAr), "javascript: scheme must be gone");

  // allowed inline formatting must be preserved
  assert.ok(/<strong[^>]*>World<\/strong>/i.test(m.titleAr), "strong must survive");
  assert.ok(/color:\s*#ff0000/i.test(m.titleAr), "inline color must survive");

  // attendee names get the same treatment
  const att = m.attendees.find((a) => /Tester/.test(a.name));
  assert.ok(att, "attendee must come back");
  assert.ok(!/<script/i.test(att.name), "script stripped from attendee name");
  assert.ok(/<em[^>]*>One<\/em>/i.test(att.name), "em must survive in attendee");
});

test("Sanitization: attendee.title is stripped of HTML on every write path", async () => {
  const malicious = '<script>alert(1)</script><b onclick="x">CFO</b>';
  // POST creates the meeting with a malicious title on one attendee.
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "تنظيف العنوان",
    titleEn: "Title sanitization",
    meetingDate: today,
    attendees: [
      {
        name: "Attendee POST",
        title: malicious,
        attendanceType: "internal",
        sortOrder: 0,
      },
    ],
  });
  assert.equal(create.status, 201);
  const meeting = await create.json();
  created.meetingIds.push(meeting.id);

  const postedTitle = meeting.attendees[0].title;
  assert.ok(postedTitle, "title must round-trip");
  assert.ok(!/<script/i.test(postedTitle), "POST: <script> stripped from title");
  assert.ok(!/<b/i.test(postedTitle), "POST: <b> stripped from title");
  assert.ok(!/onclick/i.test(postedTitle), "POST: onclick stripped from title");
  assert.ok(/CFO/.test(postedTitle), "POST: visible text preserved in title");

  // PATCH replaces attendees with a different malicious title.
  const patched = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    {
      attendees: [
        {
          name: "Attendee PATCH",
          title: '<img src=x onerror=alert(1)>Director',
          attendanceType: "internal",
          sortOrder: 0,
        },
      ],
    },
  );
  assert.equal(patched.status, 200);
  const patchedBody = await patched.json();
  const patchedTitle = patchedBody.attendees[0].title;
  assert.ok(!/<img/i.test(patchedTitle), "PATCH: <img> stripped from title");
  assert.ok(!/onerror/i.test(patchedTitle), "PATCH: onerror stripped from title");
  assert.ok(/Director/.test(patchedTitle), "PATCH: visible text preserved");

  // PUT /attendees replaces the list and must sanitize too.
  const put = await api(
    adminCookie,
    "PUT",
    `/api/executive-meetings/${meeting.id}/attendees`,
    {
      attendees: [
        {
          name: "Attendee PUT",
          title: '<a href="javascript:bad()">Manager</a>',
          attendanceType: "virtual",
          sortOrder: 0,
        },
      ],
    },
  );
  assert.equal(put.status, 200);
  const putBody = await put.json();
  const putTitle = putBody.attendees[0].title;
  assert.ok(!/<a/i.test(putTitle), "PUT: <a> stripped from title");
  assert.ok(!/javascript:/i.test(putTitle), "PUT: javascript: scheme gone");
  assert.ok(/Manager/.test(putTitle), "PUT: visible text preserved");

  // Duplicate path: copy of a meeting with a sanitized title must stay sanitized.
  const dup = await api(
    adminCookie,
    "POST",
    `/api/executive-meetings/${meeting.id}/duplicate`,
    { targetDate: today },
  );
  assert.equal(dup.status, 201);
  const dupBody = await dup.json();
  created.meetingIds.push(dupBody.id);
  const dupTitle = dupBody.attendees[0].title;
  assert.ok(!/<a/i.test(dupTitle), "duplicate: title remains free of HTML tags");
  assert.ok(!/<script/i.test(dupTitle), "duplicate: no <script> in copied title");
  assert.ok(/Manager/.test(dupTitle), "duplicate: visible text preserved");
});

test("Sanitization: text-align on <p> survives a round-trip via PATCH/GET", async () => {
  const m = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "محاذاة", titleEn: "Align", meetingDate: today,
  });
  const M = await m.json(); created.meetingIds.push(M.id);
  const html = '<p style="text-align: right">يمين</p><p style="text-align: center">center</p>';
  const patch = await api(adminCookie, "PATCH",
    `/api/executive-meetings/${M.id}`, { titleAr: html });
  assert.equal(patch.status, 200);
  const day = await api(adminCookie, "GET",
    `/api/executive-meetings?date=${today}`);
  const body = await day.json();
  const got = body.meetings.find((x) => x.id === M.id);
  assert.ok(got, "meeting must come back");
  assert.match(got.titleAr, /<p[^>]*text-align:\s*right[^>]*>يمين<\/p>/i,
    "right-aligned <p> survives sanitization");
  assert.match(got.titleAr, /<p[^>]*text-align:\s*center[^>]*>center<\/p>/i,
    "center-aligned <p> survives sanitization");
});

test("Reorder: POST /reorder renumbers a full day to 1..N and inherits slot times", async () => {
  // Use a per-run unique far-future date so leftover meetings from a
  // previously-failed run can't cause an `incomplete_day_reorder` 400
  // (the route rejects payloads that omit any non-cancelled meeting on
  // the day, and we don't try to clean a stale fixed date here).
  const stamp = Date.now();
  const dd = String((stamp % 28) + 1).padStart(2, "0");
  const mm = String((Math.floor(stamp / 28) % 12) + 1).padStart(2, "0");
  const reorderDate = `2099-${mm}-${dd}`;
  // Best-effort cleanup of any prior rows on the chosen slot (cheap and
  // keeps re-runs deterministic without trusting external state).
  await pool.query(
    `DELETE FROM executive_meetings WHERE meeting_date = $1`,
    [reorderDate],
  );
  const a = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "أول", titleEn: "First", meetingDate: reorderDate,
    startTime: "09:00", endTime: "09:30",
  });
  const b = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ثاني", titleEn: "Second", meetingDate: reorderDate,
    startTime: "10:00", endTime: "10:30",
  });
  const c = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ثالث", titleEn: "Third", meetingDate: reorderDate,
    startTime: "11:00", endTime: "11:30",
  });
  assert.equal(a.status, 201);
  assert.equal(b.status, 201);
  assert.equal(c.status, 201);
  const A = await a.json(); created.meetingIds.push(A.id);
  const B = await b.json(); created.meetingIds.push(B.id);
  const C = await c.json(); created.meetingIds.push(C.id);

  // reverse the order: C, B, A
  const reorder = await api(adminCookie, "POST",
    "/api/executive-meetings/reorder",
    { meetingDate: reorderDate, orderedIds: [C.id, B.id, A.id] });
  assert.equal(reorder.status, 200);

  const day = await api(adminCookie, "GET",
    `/api/executive-meetings?date=${reorderDate}`);
  const body = await day.json();
  const byId = new Map(body.meetings.map((m) => [m.id, m]));
  const cAfter = byId.get(C.id);
  const bAfter = byId.get(B.id);
  const aAfter = byId.get(A.id);
  assert.equal(cAfter.dailyNumber, 1);
  assert.equal(bAfter.dailyNumber, 2);
  assert.equal(aAfter.dailyNumber, 3);
  assert.equal(cAfter.startTime.slice(0, 5), "09:00");
  assert.equal(bAfter.startTime.slice(0, 5), "10:00");
  assert.equal(aAfter.startTime.slice(0, 5), "11:00");
  const auditRows = await pool.query(
    `SELECT action, entity_type, entity_id, new_value
       FROM executive_meeting_audit_logs
      WHERE action = 'executive_meeting.reorder'
        AND entity_id = ANY($1::int[])
      ORDER BY id DESC LIMIT 1`,
    [[A.id, B.id, C.id]],
  );
  assert.equal(auditRows.rowCount, 1, "expected one reorder audit row");
  assert.equal(auditRows.rows[0].entity_type, "meeting");
  const newOrder = auditRows.rows[0].new_value?.order;
  assert.deepEqual(newOrder, [C.id, B.id, A.id],
    "audit new_value.order must echo the new ordering");
});

test("Reorder: rejects orderedIds containing duplicates with code duplicate_ids", async () => {
  const reorderDate = "2050-04-04";
  const a = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
  });
  const A = await a.json(); created.meetingIds.push(A.id);
  const b = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
  });
  const B = await b.json(); created.meetingIds.push(B.id);
  const r = await api(adminCookie, "POST",
    "/api/executive-meetings/reorder",
    { meetingDate: reorderDate, orderedIds: [A.id, A.id, B.id] });
  assert.equal(r.status, 400);
  const body = await r.json();
  assert.equal(body.code, "duplicate_ids",
    "duplicate ids must be reported with the duplicate_ids code");
});

// dragging a visible meeting from one row to another must not
// disturb cancelled rows on the same day. The schedule view hides
// cancelled meetings (#273), so dnd-kit can only ever drag visible
// rows; the reorder endpoint must accept an orderedIds payload that
// covers exactly the visible (non-cancelled) meetings, slot-swap among
// them, and leave cancelled rows' time slots and daily numbers
// completely untouched. Before the fix, cancelled meetings were
// included in the slot pool and silently stole chronologically-sorted
// slots from the visible list, which made dragging a meeting from top
// to bottom land it in a non-chronological position on screen.
test("Reorder: leaves cancelled rows untouched and only slot-swaps visible meetings", async () => {
  const reorderDate = "2050-05-21";
  const a = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
    startTime: "09:00", endTime: "09:30",
  });
  const b = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ب", titleEn: "B (cancelled)", meetingDate: reorderDate,
    startTime: "10:00", endTime: "10:30",
  });
  const c = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ج", titleEn: "C", meetingDate: reorderDate,
    startTime: "11:00", endTime: "11:30",
  });
  const d = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "د", titleEn: "D", meetingDate: reorderDate,
    startTime: "12:00", endTime: "12:30",
  });
  const A = await a.json(); created.meetingIds.push(A.id);
  const B = await b.json(); created.meetingIds.push(B.id);
  const C = await c.json(); created.meetingIds.push(C.id);
  const D = await d.json(); created.meetingIds.push(D.id);

  // Cancel B in-place so the visible list is [A, C, D] but the day
  // still holds 4 rows. Capture B's pre-reorder slot to assert it
  // stays put after the reorder.
  await pool.query(
    `UPDATE executive_meetings SET status = 'cancelled' WHERE id = $1`,
    [B.id],
  );
  const { rows: bBeforeRows } = await pool.query(
    `SELECT daily_number, start_time, end_time, status
       FROM executive_meetings WHERE id = $1`,
    [B.id],
  );
  const bBefore = bBeforeRows[0];

  // User drags D (bottom of the visible list) up to the top, so the
  // visible order becomes [D, A, C]. The orderedIds payload covers
  // ONLY visible meetings — that is what the patched client sends.
  const reorder = await api(adminCookie, "POST",
    "/api/executive-meetings/reorder",
    { meetingDate: reorderDate, orderedIds: [D.id, A.id, C.id] });
  assert.equal(reorder.status, 200,
    "subset reorder (cancelled excluded) must be accepted");

  const day = await api(adminCookie, "GET",
    `/api/executive-meetings?date=${reorderDate}`);
  const body = await day.json();
  const byId = new Map(body.meetings.map((m) => [m.id, m]));

  // Visible meetings inherit each other's slots, sorted chronologically:
  //   D ← (09:00, daily_number from A's slot)
  //   A ← (11:00, daily_number from C's slot)
  //   C ← (12:00, daily_number from D's slot)
  // After the swap the visible rows must read top-to-bottom in
  // chronological start-time order.
  const visible = body.meetings
    .filter((m) => m.status !== "cancelled")
    .sort((a, b) => a.dailyNumber - b.dailyNumber);
  assert.deepEqual(visible.map((m) => m.id), [D.id, A.id, C.id],
    "visible rows must be in the dropped order [D, A, C]");
  const starts = visible.map((m) => m.startTime.slice(0, 5));
  for (let i = 1; i < starts.length; i++) {
    assert.ok(starts[i - 1] <= starts[i],
      `visible row ${i - 1} (${starts[i - 1]}) must be <= row ${i} (${starts[i]}) chronologically`);
  }
  // Concretely the slot-swap should produce 09:00, 11:00, 12:00 for
  // [D, A, C] (B's 10:00 slot stays with B, untouched).
  assert.equal(visible[0].startTime.slice(0, 5), "09:00");
  assert.equal(visible[1].startTime.slice(0, 5), "11:00");
  assert.equal(visible[2].startTime.slice(0, 5), "12:00");

  // Cancelled B: time slot AND daily_number must be unchanged.
  const bAfter = byId.get(B.id);
  assert.equal(bAfter.status, "cancelled");
  assert.equal(bAfter.startTime.slice(0, 5),
    String(bBefore.start_time).slice(0, 5),
    "cancelled meeting startTime must not change");
  assert.equal(bAfter.endTime.slice(0, 5),
    String(bBefore.end_time).slice(0, 5),
    "cancelled meeting endTime must not change");
  assert.equal(bAfter.dailyNumber, bBefore.daily_number,
    "cancelled meeting dailyNumber must not change");
});

test("Reorder: rejects orderedIds containing a cancelled meeting with code cancelled_in_reorder", async () => {
  const reorderDate = "2050-05-22";
  const a = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
    startTime: "09:00", endTime: "09:30",
  });
  const b = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ب", titleEn: "B (cancelled)", meetingDate: reorderDate,
    startTime: "10:00", endTime: "10:30",
  });
  const A = await a.json(); created.meetingIds.push(A.id);
  const B = await b.json(); created.meetingIds.push(B.id);
  await pool.query(
    `UPDATE executive_meetings SET status = 'cancelled' WHERE id = $1`,
    [B.id],
  );

  const r = await api(adminCookie, "POST",
    "/api/executive-meetings/reorder",
    { meetingDate: reorderDate, orderedIds: [B.id, A.id] });
  assert.equal(r.status, 400,
    "orderedIds containing a cancelled meeting must be rejected");
  const body = await r.json();
  assert.equal(body.code, "cancelled_in_reorder",
    "rejection must be reported with the cancelled_in_reorder code");
});

test("Reorder: handles a day with a null-startTime meeting deterministically", async () => {
  // Per the slot-swap sort, rows with non-null startTime come first
  // (chronological), and null-startTime rows come last. After a reorder
  // of [null-row, A, B], the null row should land in the LAST slot
  // (whatever its startTime/dailyNumber were originally), and A/B should
  // get the first two chronological slots. This proves the slot-swap
  // does not crash on nulls and produces a stable order.
  const reorderDate = "2050-06-10";
  const a = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
    startTime: "09:00", endTime: "09:30",
  });
  const b = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
    startTime: "10:00", endTime: "10:30",
  });
  const n = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ن", titleEn: "Null-time", meetingDate: reorderDate,
    // startTime/endTime intentionally omitted -> null in DB
  });
  const A = await a.json(); created.meetingIds.push(A.id);
  const B = await b.json(); created.meetingIds.push(B.id);
  const N = await n.json(); created.meetingIds.push(N.id);

  // Ask: put null-row first, then A, then B
  const r = await api(adminCookie, "POST",
    "/api/executive-meetings/reorder",
    { meetingDate: reorderDate, orderedIds: [N.id, A.id, B.id] });
  assert.equal(r.status, 200, "reorder with a null-time row must succeed");

  const after = await pool.query(
    `SELECT id, daily_number, start_time, end_time
       FROM executive_meetings
      WHERE meeting_date = $1
      ORDER BY daily_number`,
    [reorderDate],
  );
  // Slots sorted by startTime (nulls last) were:
  //   slot[0] = (09:00, 09:30, dn=A) for A originally
  //   slot[1] = (10:00, 10:30, dn=B) for B originally
  //   slot[2] = (null,  null,  dn=N) for N originally
  // Assignment N->slot[0], A->slot[1], B->slot[2]:
  const byId = Object.fromEntries(after.rows.map((r) => [r.id, r]));
  assert.equal(byId[N.id].start_time, "09:00:00",
    "null-time row should inherit the first chronological slot's startTime");
  assert.equal(byId[A.id].start_time, "10:00:00",
    "A should inherit the second chronological slot's startTime");
  assert.equal(byId[B.id].start_time, null,
    "B should inherit the originally-null third slot's startTime");
  // dailyNumbers must be unique 1..3 across the day
  const dns = after.rows.map((r) => r.daily_number).sort();
  assert.deepEqual(dns, [1, 2, 3].sort(),
    "dailyNumbers across the day must remain a unique 1..N permutation");
});

test("Reorder: rejects an incomplete-day request (orderedIds missing some)", async () => {
  const reorderDate = "2050-02-20";
  const a = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
    startTime: "08:00", endTime: "08:30",
  });
  const b = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
    startTime: "09:00", endTime: "09:30",
  });
  const A = await a.json(); created.meetingIds.push(A.id);
  const B = await b.json(); created.meetingIds.push(B.id);

  // Send only one of the two — server must refuse so 1..N renumber stays safe.
  const r = await api(adminCookie, "POST",
    "/api/executive-meetings/reorder",
    { meetingDate: reorderDate, orderedIds: [A.id] });
  assert.equal(r.status, 400);
  const body = await r.json();
  assert.equal(body.code, "incomplete_day");
});

test("Reorder: rejects ids that do not all belong to meetingDate", async () => {
  const sameDay = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "اليوم", titleEn: "Today", meetingDate: today,
  });
  const other = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "غدًا", titleEn: "Tomorrow",
    meetingDate: new Date(Date.now() + 86400000)
      .toISOString().slice(0, 10),
  });
  const S = await sameDay.json(); created.meetingIds.push(S.id);
  const O = await other.json(); created.meetingIds.push(O.id);

  const r = await api(adminCookie, "POST",
    "/api/executive-meetings/reorder",
    { meetingDate: today, orderedIds: [S.id, O.id] });
  assert.equal(r.status, 400);
});

// ---------------------------------------------------------------------
// Auto-sort the day chronologically on every write that
// affects time/order. The user reported a row showing 13:00 sitting
// above a 12:00 row after editing a single startTime — that bug exists
// because the inline-edit PATCH never re-derived `daily_number`. These
// tests lock in the new contract: any time a write touches startTime,
// endTime, meetingDate, status, or creates/duplicates/deletes a row,
// the day is renumbered 1..N by start_time so the visible list is
// strictly chronological. Cancelled rows go to the tail (preserving
// the existing renumberDayByStartTime convention) so the per-day
// unique index on (meeting_date, daily_number) stays satisfied.
// ---------------------------------------------------------------------

// Helper: assert that the visible (non-cancelled) rows on `date` read
// 1..N by start_time order, with no chronological inversions and no
// duplicate dailyNumbers. Returns the visible rows for further checks.
async function assertDayChronological(date) {
  const day = await api(adminCookie, "GET",
    `/api/executive-meetings?date=${date}`);
  assert.equal(day.status, 200);
  const body = await day.json();
  const visible = body.meetings
    .filter((m) => m.status !== "cancelled")
    .sort((a, b) => a.dailyNumber - b.dailyNumber);
  // dailyNumbers must be 1..N with no gaps.
  for (let i = 0; i < visible.length; i++) {
    assert.equal(visible[i].dailyNumber, i + 1,
      `visible row index ${i} on ${date} must have dailyNumber ${i + 1}, got ${visible[i].dailyNumber}`);
  }
  // start_times must be non-decreasing top-to-bottom (nulls last per
  // renumberDayByStartTime's NULLS LAST clause).
  let lastTime = null;
  let seenNull = false;
  for (const m of visible) {
    if (m.startTime == null) {
      seenNull = true;
      continue;
    }
    assert.ok(!seenNull,
      `null-startTime row on ${date} must come after timed rows, but a timed row (${m.startTime}) followed a null one`);
    const t = String(m.startTime).slice(0, 8);
    if (lastTime != null) {
      assert.ok(lastTime <= t,
        `start_time inversion on ${date}: ${lastTime} (row N) > ${t} (row N+1)`);
    }
    lastTime = t;
  }
  // No duplicate dailyNumbers across the whole day (cancelled + visible).
  const seen = new Set();
  for (const m of body.meetings) {
    assert.ok(!seen.has(m.dailyNumber),
      `duplicate dailyNumber ${m.dailyNumber} on ${date} after auto-sort`);
    seen.add(m.dailyNumber);
  }
  return visible;
}

test("Auto-sort: PATCH startTime later than next row demotes it to its new chronological slot", async () => {
  const d = "2050-06-01";
  const a = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "أ", titleEn: "A", meetingDate: d,
    startTime: "09:00", endTime: "09:30",
  });
  const b = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ب", titleEn: "B", meetingDate: d,
    startTime: "10:00", endTime: "10:30",
  });
  const c = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ج", titleEn: "C", meetingDate: d,
    startTime: "11:00", endTime: "11:30",
  });
  const A = await a.json(); created.meetingIds.push(A.id);
  const B = await b.json(); created.meetingIds.push(B.id);
  const C = await c.json(); created.meetingIds.push(C.id);
  await assertDayChronological(d);

  // The exact scenario from the user's screenshot: A is at the top
  // (09:00). User edits A's startTime to 13:00 — without auto-sort, A
  // stays in row 1 even though it now has the latest time.
  const patch = await api(adminCookie, "PATCH",
    `/api/executive-meetings/${A.id}`,
    { startTime: "13:00", endTime: "13:30" });
  assert.equal(patch.status, 200);

  const visible = await assertDayChronological(d);
  // After auto-sort the order must be B, C, A — A demoted to the tail
  // because its 13:00 is now the latest start time on the day.
  assert.deepEqual(visible.map((m) => m.id), [B.id, C.id, A.id],
    "the row whose startTime was bumped to 13:00 must move to the bottom");
});

test("Auto-sort: PATCH startTime earlier than first row promotes it to the top", async () => {
  const d = "2050-06-02";
  const a = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "أ", titleEn: "A", meetingDate: d,
    startTime: "09:00", endTime: "09:30",
  });
  const b = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ب", titleEn: "B", meetingDate: d,
    startTime: "10:00", endTime: "10:30",
  });
  const c = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ج", titleEn: "C", meetingDate: d,
    startTime: "11:00", endTime: "11:30",
  });
  const A = await a.json(); created.meetingIds.push(A.id);
  const B = await b.json(); created.meetingIds.push(B.id);
  const C = await c.json(); created.meetingIds.push(C.id);

  // Move C (currently bottom @ 11:00) to 07:00 — must jump to the top.
  const patch = await api(adminCookie, "PATCH",
    `/api/executive-meetings/${C.id}`,
    { startTime: "07:00", endTime: "07:30" });
  assert.equal(patch.status, 200);

  const visible = await assertDayChronological(d);
  assert.deepEqual(visible.map((m) => m.id), [C.id, A.id, B.id],
    "the row whose startTime was moved earliest must rise to the top");
});

test("Auto-sort: POST create with a middle startTime slots the new row between existing ones", async () => {
  const d = "2050-06-03";
  const a = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "أ", titleEn: "A", meetingDate: d,
    startTime: "09:00", endTime: "09:30",
  });
  const c = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ج", titleEn: "C", meetingDate: d,
    startTime: "11:00", endTime: "11:30",
  });
  const A = await a.json(); created.meetingIds.push(A.id);
  const C = await c.json(); created.meetingIds.push(C.id);

  // Create B at 10:00 — without auto-sort it would land at the tail
  // (nextDailyNumber = 3) so the visible order would be [A, C, B] even
  // though chronologically it belongs in the middle.
  const b = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ب", titleEn: "B", meetingDate: d,
    startTime: "10:00", endTime: "10:30",
  });
  assert.equal(b.status, 201);
  const B = await b.json(); created.meetingIds.push(B.id);

  const visible = await assertDayChronological(d);
  assert.deepEqual(visible.map((m) => m.id), [A.id, B.id, C.id],
    "newly-created meeting with middle startTime must slot between existing rows");
});

test("Auto-sort: clearing startTime to null pushes the row to the tail of the visible list", async () => {
  const d = "2050-06-04";
  const a = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "أ", titleEn: "A", meetingDate: d,
    startTime: "09:00", endTime: "09:30",
  });
  const b = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ب", titleEn: "B", meetingDate: d,
    startTime: "10:00", endTime: "10:30",
  });
  const c = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ج", titleEn: "C", meetingDate: d,
    startTime: "11:00", endTime: "11:30",
  });
  const A = await a.json(); created.meetingIds.push(A.id);
  const B = await b.json(); created.meetingIds.push(B.id);
  const C = await c.json(); created.meetingIds.push(C.id);

  // Clear B's start/end times — renumberDayByStartTime puts NULL-time
  // rows at the tail of the visible 1..N (NULLS LAST).
  const patch = await api(adminCookie, "PATCH",
    `/api/executive-meetings/${B.id}`,
    { startTime: null, endTime: null });
  assert.equal(patch.status, 200);

  const visible = await assertDayChronological(d);
  assert.deepEqual(visible.map((m) => m.id), [A.id, C.id, B.id],
    "row with cleared startTime must sink below the timed rows");
  assert.equal(visible[2].startTime, null, "B's startTime must be null after clearing");
});

test("Auto-sort: cancel pushes a row out of the visible list and renumbers, uncancel re-slots chronologically", async () => {
  const d = "2050-06-05";
  const a = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "أ", titleEn: "A", meetingDate: d,
    startTime: "09:00", endTime: "09:30",
  });
  const b = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ب", titleEn: "B", meetingDate: d,
    startTime: "10:00", endTime: "10:30",
  });
  const c = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ج", titleEn: "C", meetingDate: d,
    startTime: "11:00", endTime: "11:30",
  });
  const A = await a.json(); created.meetingIds.push(A.id);
  const B = await b.json(); created.meetingIds.push(B.id);
  const C = await c.json(); created.meetingIds.push(C.id);

  // Cancel B via the dedicated endpoint (which already calls
  // renumberDayByStartTime). The visible list shrinks to [A, C] and
  // their dailyNumbers must compact to 1..2 with B sitting at the tail.
  const cancel = await api(adminCookie, "POST",
    `/api/executive-meetings/${B.id}/cancel`);
  assert.equal(cancel.status, 200);
  let visible = await assertDayChronological(d);
  assert.deepEqual(visible.map((m) => m.id), [A.id, C.id],
    "cancelled row must drop out of the visible list");

  // Uncancel via PATCH status='scheduled'. B must re-enter the visible
  // list at its chronological slot (between A @ 09:00 and C @ 11:00).
  const uncancel = await api(adminCookie, "PATCH",
    `/api/executive-meetings/${B.id}`,
    { status: "scheduled" });
  assert.equal(uncancel.status, 200);
  visible = await assertDayChronological(d);
  assert.deepEqual(visible.map((m) => m.id), [A.id, B.id, C.id],
    "uncancelled row must re-slot chronologically by its startTime");
});

test("Auto-sort: PATCH meetingDate cross-day move renumbers BOTH days chronologically", async () => {
  const dSrc = "2050-07-10";
  const dDst = "2050-07-11";
  const a = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "أ", titleEn: "A-src", meetingDate: dSrc,
    startTime: "09:00", endTime: "09:30",
  });
  const b = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ب", titleEn: "B-src", meetingDate: dSrc,
    startTime: "10:00", endTime: "10:30",
  });
  const c = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ج", titleEn: "C-src", meetingDate: dSrc,
    startTime: "11:00", endTime: "11:30",
  });
  const x = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "س", titleEn: "X-dst", meetingDate: dDst,
    startTime: "08:00", endTime: "08:30",
  });
  const y = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ص", titleEn: "Y-dst", meetingDate: dDst,
    startTime: "13:00", endTime: "13:30",
  });
  const A = await a.json(); created.meetingIds.push(A.id);
  const B = await b.json(); created.meetingIds.push(B.id);
  const C = await c.json(); created.meetingIds.push(C.id);
  const X = await x.json(); created.meetingIds.push(X.id);
  const Y = await y.json(); created.meetingIds.push(Y.id);

  // Move B from the source day to the destination day at 10:00 — it
  // must arrive between X (08:00) and Y (13:00) on dDst, and the
  // source day must close ranks so [A, C] reads 1..2.
  const patch = await api(adminCookie, "PATCH",
    `/api/executive-meetings/${B.id}`,
    { meetingDate: dDst });
  assert.equal(patch.status, 200);

  const srcVisible = await assertDayChronological(dSrc);
  assert.deepEqual(srcVisible.map((m) => m.id), [A.id, C.id],
    "source day must compact to [A, C] after B moves away");
  const dstVisible = await assertDayChronological(dDst);
  assert.deepEqual(dstVisible.map((m) => m.id), [X.id, B.id, Y.id],
    "destination day must accept B at its chronological slot between X and Y");
});

test("Auto-sort: DELETE leaves the day's `#` sequence gap-free and chronological", async () => {
  const d = "2050-06-15";
  const a = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "أ", titleEn: "A", meetingDate: d,
    startTime: "09:00", endTime: "09:30",
  });
  const b = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ب", titleEn: "B", meetingDate: d,
    startTime: "10:00", endTime: "10:30",
  });
  const c = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ج", titleEn: "C", meetingDate: d,
    startTime: "11:00", endTime: "11:30",
  });
  const A = await a.json(); created.meetingIds.push(A.id);
  const B = await b.json(); created.meetingIds.push(B.id);
  const C = await c.json(); created.meetingIds.push(C.id);

  // Delete the middle row. Without auto-sort, dailyNumbers would be
  // [1, 3] with a gap, and a future POST would reuse dailyNumber=2.
  const del = await api(adminCookie, "DELETE",
    `/api/executive-meetings/${B.id}`);
  assert.ok(del.status === 200 || del.status === 204);
  // Drop B from cleanup since it's already gone.
  created.meetingIds = created.meetingIds.filter((x) => x !== B.id);

  const visible = await assertDayChronological(d);
  assert.deepEqual(visible.map((m) => m.id), [A.id, C.id],
    "remaining rows must compact to 1..2 with no gap after delete");
});

test("Auto-sort: POST /duplicate slots the cloned meeting at its chronological position on the target day", async () => {
  const dSrc = "2050-06-20";
  const dDst = "2050-06-21";
  const a = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "أ", titleEn: "A-src", meetingDate: dSrc,
    startTime: "10:00", endTime: "10:30",
  });
  const x = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "س", titleEn: "X-dst", meetingDate: dDst,
    startTime: "08:00", endTime: "08:30",
  });
  const y = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ص", titleEn: "Y-dst", meetingDate: dDst,
    startTime: "13:00", endTime: "13:30",
  });
  const A = await a.json(); created.meetingIds.push(A.id);
  const X = await x.json(); created.meetingIds.push(X.id);
  const Y = await y.json(); created.meetingIds.push(Y.id);

  // Duplicate A (10:00) onto dDst — the clone must land between X
  // (08:00) and Y (13:00). Without auto-sort, the duplicate would be
  // appended at the tail (dailyNumber = 3) and the visible order would
  // be [X, Y, clone] even though clone @ 10:00 belongs in the middle.
  const dup = await api(adminCookie, "POST",
    `/api/executive-meetings/${A.id}/duplicate`,
    { targetDate: dDst });
  assert.equal(dup.status, 201);
  const Clone = await dup.json(); created.meetingIds.push(Clone.id);

  const visible = await assertDayChronological(dDst);
  assert.deepEqual(visible.map((m) => m.id), [X.id, Clone.id, Y.id],
    "duplicate must slot at its chronological position on the target day");
});

// #312: when a PATCH (e.g. typing 13:00 on the row currently sitting
// above a 12:00 row) triggers auto-sort and the row visibly moves to a
// new dailyNumber, the audit row for that PATCH must record both the
// post-renumber dailyNumber and an `orderShifted: true` flag with the
// before/after visible-row order. Without this an admin reading the
// audit cannot tell that the user's startTime edit also moved the row
// from `#1` to `#3`.
test("Auto-sort: PATCH that reorders the day records orderShifted + final dailyNumber + dayOrder in the audit row", async () => {
  const d = "2050-07-12";
  const a = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "أ", titleEn: "A", meetingDate: d,
    startTime: "09:00", endTime: "09:30",
  });
  const b = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ب", titleEn: "B", meetingDate: d,
    startTime: "10:00", endTime: "10:30",
  });
  const c = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ج", titleEn: "C", meetingDate: d,
    startTime: "11:00", endTime: "11:30",
  });
  const A = await a.json(); created.meetingIds.push(A.id);
  const B = await b.json(); created.meetingIds.push(B.id);
  const C = await c.json(); created.meetingIds.push(C.id);
  // Sanity: starting visible order is A(09), B(10), C(11) at #1,#2,#3.
  let visible = await assertDayChronological(d);
  assert.deepEqual(visible.map((m) => m.id), [A.id, B.id, C.id]);

  // Move A from 09:00 -> 13:00 (the user's exact bug scenario). After
  // auto-sort the visible order should become B(10), C(11), A(13) and
  // A's dailyNumber should be 3.
  const patch = await api(adminCookie, "PATCH",
    `/api/executive-meetings/${A.id}`,
    { startTime: "13:00", endTime: "13:30" });
  assert.equal(patch.status, 200);
  visible = await assertDayChronological(d);
  assert.deepEqual(visible.map((m) => m.id), [B.id, C.id, A.id]);

  // Read the audit row for THIS PATCH and assert the shift metadata.
  const auditRows = await pool.query(
    `SELECT action, entity_type, entity_id, new_value
       FROM executive_meeting_audit_logs
      WHERE action = 'update'
        AND entity_type = 'meeting'
        AND entity_id = $1
      ORDER BY id DESC LIMIT 1`,
    [A.id],
  );
  assert.equal(auditRows.rowCount, 1, "expected one update audit row for A");
  const newValue = auditRows.rows[0].new_value;
  assert.ok(newValue, "audit new_value must be present");
  assert.equal(newValue.orderShifted, true,
    "audit must flag orderShifted=true when auto-sort moved the row");
  assert.equal(newValue.dailyNumber, 3,
    "audit's newValue.dailyNumber must echo the post-renumber position (#3), not the pre-renumber #1");
  assert.ok(Array.isArray(newValue.dayOrder),
    "audit must include dayOrder array describing the visible-row shift");
  const shift = newValue.dayOrder.find((s) => s.date === d);
  assert.ok(shift, `dayOrder must include an entry for the affected date ${d}`);
  assert.deepEqual(shift.before, [A.id, B.id, C.id],
    "dayOrder.before must be the visible row IDs in pre-renumber dailyNumber order");
  assert.deepEqual(shift.after, [B.id, C.id, A.id],
    "dayOrder.after must be the visible row IDs in post-renumber dailyNumber order");
});

// #312: a non-order-affecting PATCH (e.g. just changing the title)
// must NOT add orderShifted/dayOrder to the audit row, since auto-sort
// did not run. This guards against accidentally treating every PATCH
// as a reorder in the audit UI.
test("Auto-sort: PATCH that does not touch time/date/status leaves orderShifted absent from the audit", async () => {
  const d = "2050-07-13";
  const a = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "أ", titleEn: "A", meetingDate: d,
    startTime: "09:00", endTime: "09:30",
  });
  const A = await a.json(); created.meetingIds.push(A.id);

  const patch = await api(adminCookie, "PATCH",
    `/api/executive-meetings/${A.id}`,
    { titleEn: "A renamed" });
  assert.equal(patch.status, 200);

  const auditRows = await pool.query(
    `SELECT new_value FROM executive_meeting_audit_logs
      WHERE action = 'update' AND entity_type = 'meeting'
        AND entity_id = $1
      ORDER BY id DESC LIMIT 1`,
    [A.id],
  );
  assert.equal(auditRows.rowCount, 1);
  const newValue = auditRows.rows[0].new_value;
  assert.ok(!("orderShifted" in newValue),
    "title-only PATCH must not add orderShifted to the audit");
  assert.ok(!("dayOrder" in newValue),
    "title-only PATCH must not add dayOrder to the audit");
});

test("Reorder: user without executive role is denied (403)", async () => {
  const m = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "ا", titleEn: "A", meetingDate: today,
  });
  const M = await m.json(); created.meetingIds.push(M.id);

  // a brand-new "user" has only the base 'user' role (no executive perms)
  const plain = await createUser("em_plain", "user");
  const plainCookie = await login(plain.username, TEST_PASSWORD);

  const r = await api(plainCookie, "POST",
    "/api/executive-meetings/reorder",
    { meetingDate: today, orderedIds: [M.id] });
  assert.equal(r.status, 403);
});

// ---------------------------------------------------------------------
// Phase-2 additions:
//   - per-role meeting CRUD permissions
//   - request reject / withdraw flows
//   - task assignee-only status updates
//   - font-settings PATCH/GET roundtrip
//   - non-numeric :id returns 404 (router.param guard) instead of crashing
//   - audit-insert failure rolls back the parent mutation (transactional)
// ---------------------------------------------------------------------

test("Meeting CRUD permissions: coordinator forbidden, lead allowed, admin allowed", async () => {
  // executive_coordinator is in REQUEST_ROLES but NOT in MUTATE_ROLES, so
  // a direct POST/PATCH/DELETE on /executive-meetings must come back 403.
  const coordCreate = await api(coordCookie, "POST", "/api/executive-meetings", {
    titleAr: "م", titleEn: "M", meetingDate: today,
  });
  assert.equal(coordCreate.status, 403,
    "executive_coordinator must not be able to POST a meeting");

  // executive_coord_lead is in MUTATE_ROLES, so the same call must succeed.
  const leadCreate = await api(leadCookie, "POST", "/api/executive-meetings", {
    titleAr: "ل", titleEn: "L", meetingDate: today,
  });
  assert.equal(leadCreate.status, 201,
    "executive_coord_lead must be able to POST a meeting");
  const leadMeeting = await leadCreate.json();
  created.meetingIds.push(leadMeeting.id);

  // Coordinator can still GET (read role) but cannot PATCH or DELETE.
  const coordRead = await api(coordCookie, "GET",
    `/api/executive-meetings/${leadMeeting.id}`);
  assert.equal(coordRead.status, 200,
    "coordinator should still be allowed to read meeting details");

  const coordPatch = await api(coordCookie, "PATCH",
    `/api/executive-meetings/${leadMeeting.id}`, { notes: "no" });
  assert.equal(coordPatch.status, 403,
    "coordinator must not PATCH meetings directly");

  const coordDelete = await api(coordCookie, "DELETE",
    `/api/executive-meetings/${leadMeeting.id}`);
  assert.equal(coordDelete.status, 403,
    "coordinator must not DELETE meetings directly");

  // Lead can mutate.
  const leadPatch = await api(leadCookie, "PATCH",
    `/api/executive-meetings/${leadMeeting.id}`, { notes: "ok" });
  assert.equal(leadPatch.status, 200,
    "executive_coord_lead must be able to PATCH meetings");
});

test("Font settings: PUT then GET returns the user-scoped row roundtrip", async () => {
  const put = await api(adminCookie, "PUT",
    "/api/executive-meetings/font-settings", {
      scope: "user",
      fontFamily: "Tajawal",
      fontSize: 18,
      fontWeight: "bold",
      alignment: "center",
      fontColor: "#1f2937",
    });
  assert.equal(put.status, 200);

  const get = await api(adminCookie, "GET",
    "/api/executive-meetings/font-settings");
  assert.equal(get.status, 200);
  const body = await get.json();
  assert.ok(body.user, "GET must include the user-scoped row");
  assert.equal(body.user.scope, "user");
  assert.equal(body.user.fontFamily, "Tajawal");
  assert.equal(body.user.fontSize, 18);
  assert.equal(body.user.fontWeight, "bold");
  assert.equal(body.user.alignment, "center");
  assert.equal(body.user.fontColor, "#1f2937",
    "fontColor must persist via the user-scope row");

  // PATCH alias must hit the same upsert handler.
  const patch = await api(adminCookie, "PATCH",
    "/api/executive-meetings/font-settings", {
      scope: "user",
      fontFamily: "DIN Next LT Arabic",
      fontSize: 14,
      fontWeight: "regular",
      alignment: "start",
      fontColor: "#000000",
    });
  assert.equal(patch.status, 200);

  const get2 = await api(adminCookie, "GET",
    "/api/executive-meetings/font-settings");
  const body2 = await get2.json();
  // The upsert handler stores `null` on the user row for any field
  // whose value matches the current global default — see the
  // "nullified" overlay block in the route. The client resolves the
  // effective value at read time as `user[field] ?? global[field]`.
  // So for fields that DO differ from the global default we keep the
  // user value; for fields that MATCH the global default we expect
  // null on the user row.
  const effective = (field) => body2.user[field] ?? body2.global?.[field] ?? null;
  assert.equal(effective("fontFamily"), "DIN Next LT Arabic");
  assert.equal(effective("fontSize"), 14);
  assert.equal(effective("fontWeight"), "regular");
  assert.equal(effective("alignment"), "start");
  assert.equal(effective("fontColor"), "#000000");
});

test("Font settings: rejects malformed fontColor and logoObjectPath", async () => {
  // The hex regex on the API must reject non-#RRGGBB inputs so the
  // value can be safely round-tripped through CSS and PDFKit.
  const badColor = await api(adminCookie, "PATCH",
    "/api/executive-meetings/font-settings", {
      scope: "user",
      fontFamily: "system",
      fontSize: 14,
      fontWeight: "regular",
      alignment: "start",
      fontColor: "red",
    });
  assert.equal(badColor.status, 400, "non-hex fontColor must 400");

  // Anything that isn't a /objects/<id> path should also be rejected
  // — we don't want callers smuggling http URLs or relative paths
  // into the brand logo slot.
  const badPath = await api(adminCookie, "PATCH",
    "/api/executive-meetings/font-settings", {
      scope: "global",
      fontFamily: "system",
      fontSize: 14,
      fontWeight: "regular",
      alignment: "start",
      fontColor: "#000000",
      logoObjectPath: "https://evil.example.com/logo.png",
    });
  assert.equal(badPath.status, 400, "non-/objects path must 400");
});

test("router.param: non-numeric :id returns 404 across endpoints (no crash)", async () => {
  // The router.param("id", ...) guard in executive-meetings.ts must reject
  // non-digit ids by calling next("route"), so Express falls through to 404
  // instead of attempting Number("abc") and crashing further down.
  const cases = [
    ["GET",    "/api/executive-meetings/abc"],
    ["PATCH",  "/api/executive-meetings/abc"],
    ["DELETE", "/api/executive-meetings/abc"],
    ["GET",    "/api/executive-meetings/123abc"],
    ["GET",    "/api/executive-meetings/-1"],
    ["POST",   "/api/executive-meetings/abc/duplicate"],
    ["PUT",    "/api/executive-meetings/abc/attendees"],
  ];
  for (const [method, path] of cases) {
    const res = await api(adminCookie, method, path,
      method === "GET" || method === "DELETE" ? undefined : {});
    assert.equal(res.status, 404,
      `${method} ${path} should 404 (got ${res.status})`);
    // Express's default 404 fallthrough returns an HTML "Cannot METHOD
    // /path" page (text/html), so we assert that the body is *not* the
    // pretty-printed JSON of an unhandled exception. Whichever Express
    // produces, it must not be a 5xx HTML error stack trace.
    const body = await res.text();
    assert.ok(!/<pre>Error:/i.test(body),
      `${method} ${path} body must not contain a thrown Error stack`);
    assert.ok(!/TypeError|ReferenceError/i.test(body),
      `${method} ${path} body must not contain a JS exception name`);
  }
});

test("Transactional safety: a failing audit insert rolls back the parent DELETE", async () => {
  // Create a meeting we can target.
  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "اختبار-صفقة", titleEn: "Tx Rollback Target", meetingDate: today,
  });
  assert.equal(create.status, 201);
  const meeting = await create.json();
  // Do NOT push to created.meetingIds yet — we want to verify the row is
  // still there after the deliberately-failing DELETE, then clean it up
  // ourselves.
  const meetingId = meeting.id;

  // Install a BEFORE INSERT trigger on the audit log table that raises an
  // exception only when our specific (entity_type='meeting', action='delete',
  // entity_id=<this meeting>) row is being inserted. Other audit inserts —
  // including the ones that other tests run in parallel/sequence — are
  // unaffected.
  const triggerName = `tx_rollback_test_${meetingId}`;
  const fnName = `tx_rollback_test_fn_${meetingId}`;
  await pool.query(`
    CREATE OR REPLACE FUNCTION ${fnName}() RETURNS trigger AS $$
    BEGIN
      IF NEW.entity_type = 'meeting'
         AND NEW.action = 'delete'
         AND NEW.entity_id = ${meetingId} THEN
        RAISE EXCEPTION 'forced audit failure for tx rollback test';
      END IF;
      RETURN NEW;
    END;
    $$ LANGUAGE plpgsql;
  `);
  await pool.query(`
    CREATE TRIGGER ${triggerName}
    BEFORE INSERT ON executive_meeting_audit_logs
    FOR EACH ROW EXECUTE FUNCTION ${fnName}();
  `);

  let deleteStatus = null;
  try {
    const del = await api(adminCookie, "DELETE",
      `/api/executive-meetings/${meetingId}`);
    deleteStatus = del.status;
  } finally {
    // Always tear the trigger + function down so the rest of the test file
    // (and any other test runs) are not poisoned.
    await pool.query(`DROP TRIGGER IF EXISTS ${triggerName} ON executive_meeting_audit_logs`);
    await pool.query(`DROP FUNCTION IF EXISTS ${fnName}()`);
  }

  assert.equal(deleteStatus, 500,
    "DELETE must surface a 500 when the audit insert raises");

  // The meeting row must still exist — the surrounding db.transaction()
  // should have rolled the DELETE back when the audit insert failed.
  const stillThere = await pool.query(
    `SELECT id FROM executive_meetings WHERE id = $1`,
    [meetingId],
  );
  assert.equal(stillThere.rowCount, 1,
    "parent DELETE must be rolled back when audit insert fails");

  // Now clean up for real — register the id so afterAll deletes it.
  created.meetingIds.push(meetingId);
});

test("Notification prefs: GET defaults every event type to in-app + email on", async () => {
  // Use a brand-new coordinator so we know there are no pre-existing rows.
  const fresh = await createUser("em_pref_a", "executive_coordinator");
  const cookie = await login(fresh.username, TEST_PASSWORD);

  const res = await api(cookie, "GET", "/api/executive-meetings/notification-prefs");
  assert.equal(res.status, 200);
  const body = await res.json();
  assert.ok(Array.isArray(body.types) && body.types.length > 0,
    "response must include the canonical types list");
  assert.ok(Array.isArray(body.prefs) && body.prefs.length === body.types.length,
    "response must return one merged pref per canonical type");
  for (const p of body.prefs) {
    assert.equal(p.inApp, true, `default inApp must be true for ${p.notificationType}`);
    assert.equal(p.email, true, `default email must be true for ${p.notificationType}`);
  }
});

test("Notification prefs: PUT upserts and is reflected in subsequent GET", async () => {
  const fresh = await createUser("em_pref_b", "executive_coordinator");
  const cookie = await login(fresh.username, TEST_PASSWORD);

  // Pick a known type from the canonical list returned by GET.
  const initial = await (await api(cookie, "GET",
    "/api/executive-meetings/notification-prefs")).json();
  const targetType = initial.types[0];

  const put = await api(cookie, "PUT",
    "/api/executive-meetings/notification-prefs",
    { prefs: [{ notificationType: targetType, inApp: false, email: true }] });
  assert.equal(put.status, 200);
  const putBody = await put.json();
  assert.equal(putBody.ok, true);
  assert.equal(putBody.count, 1);

  const after = await (await api(cookie, "GET",
    "/api/executive-meetings/notification-prefs")).json();
  const row = after.prefs.find((p) => p.notificationType === targetType);
  assert.ok(row, "updated pref must come back in GET");
  assert.equal(row.inApp, false);
  assert.equal(row.email, true);

  // Other types must remain at default (true / true).
  for (const p of after.prefs) {
    if (p.notificationType === targetType) continue;
    assert.equal(p.inApp, true, `${p.notificationType} should remain default-on`);
    assert.equal(p.email, true, `${p.notificationType} should remain default-on`);
  }

  // Re-PUT the same type should overwrite (upsert), not duplicate.
  const put2 = await api(cookie, "PUT",
    "/api/executive-meetings/notification-prefs",
    { prefs: [{ notificationType: targetType, inApp: true, email: false }] });
  assert.equal(put2.status, 200);
  const after2 = await (await api(cookie, "GET",
    "/api/executive-meetings/notification-prefs")).json();
  const row2 = after2.prefs.find((p) => p.notificationType === targetType);
  assert.equal(row2.inApp, true);
  assert.equal(row2.email, false);
});

test("Notification prefs: PUT rejects unknown notificationType with 400", async () => {
  const fresh = await createUser("em_pref_c", "executive_coordinator");
  const cookie = await login(fresh.username, TEST_PASSWORD);

  const bad = await api(cookie, "PUT",
    "/api/executive-meetings/notification-prefs",
    { prefs: [{ notificationType: "totally_made_up", inApp: false, email: false }] });
  assert.equal(bad.status, 400);
});

test("Notification prefs: DELETE wipes the user's pref rows so GET reverts to defaults", async () => {
  // #236: clicking "Restore defaults" in the UI hits DELETE. After it
  // returns, every event type must read default-on regardless of what
  // had been muted before, and a follow-up fan-out must reach the user
  // again. Use a fresh approver so we don't disturb other prefs tests.
  const fresh = await createUser("em_pref_restore", "executive_office_manager");
  const cookie = await login(fresh.username, TEST_PASSWORD);

  const put = await api(cookie, "PUT",
    "/api/executive-meetings/notification-prefs",
    {
      prefs: [
        { notificationType: "meeting_created", inApp: false, email: false },
      ],
    });
  assert.equal(put.status, 200);

  const { rows: pre } = await pool.query(
    `SELECT COUNT(*)::int AS n
       FROM executive_meeting_notification_prefs
      WHERE user_id = $1`,
    [fresh.id],
  );
  assert.equal(pre[0].n, 1, "PUT must have left 1 pref row");

  const del = await api(cookie, "DELETE",
    "/api/executive-meetings/notification-prefs");
  assert.equal(del.status, 200);
  const delBody = await del.json();
  assert.equal(delBody.ok, true);
  assert.equal(delBody.count, 1, "DELETE must report wiping the row");

  const { rows: post } = await pool.query(
    `SELECT COUNT(*)::int AS n
       FROM executive_meeting_notification_prefs
      WHERE user_id = $1`,
    [fresh.id],
  );
  assert.equal(post[0].n, 0, "no pref rows must remain after DELETE");

  // GET must now synthesize default-on for every type.
  const after = await (await api(cookie, "GET",
    "/api/executive-meetings/notification-prefs")).json();
  for (const p of after.prefs) {
    assert.equal(p.inApp, true, `${p.notificationType} must be default-on after DELETE`);
    assert.equal(p.email, true, `${p.notificationType} must be default-on after DELETE`);
  }

  // And actual fan-out must reach the user again — a regression in the
  // DELETE handler that left rows behind would silently re-mute them.
  const beforeCount = await pool.query(
    `SELECT COUNT(*)::int AS n
       FROM executive_meeting_notifications
      WHERE user_id = $1
        AND notification_type = 'meeting_created'`,
    [fresh.id],
  );
  const submit = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "إخطار-إعادة-الافتراضات",
    titleEn: "Restore-defaults fan-out",
    meetingDate: today,
    attendees: [],
  });
  assert.equal(submit.status, 201);
  const submittedMeeting = await submit.json();
  created.meetingIds.push(submittedMeeting.id);
  // Give the fan-out a moment to land before counting.
  await new Promise((r) => setTimeout(r, 250));
  const afterCount = await pool.query(
    `SELECT COUNT(*)::int AS n
       FROM executive_meeting_notifications
      WHERE user_id = $1
        AND notification_type = 'meeting_created'`,
    [fresh.id],
  );
  assert.ok(
    afterCount.rows[0].n > beforeCount.rows[0].n,
    "user with no pref rows (post-DELETE) must receive new meeting_created rows",
  );
});

// ---------------------------------------------------------------------------
// #302 — Cascade-shift on postpone / reschedule.
// Each test uses its own far-future date so the day's meeting set is
// isolated from every other test (and from any seeded data on `today`).
// ---------------------------------------------------------------------------

async function createMeeting(date, titleEn, startTime, endTime, opts = {}) {
  const res = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: titleEn,
    titleEn,
    meetingDate: date,
    startTime,
    endTime,
    platform: "none",
    status: opts.status ?? "scheduled",
    isHighlighted: 0,
  });
  assert.equal(res.status, 201, `create ${titleEn} should succeed`);
  const body = await res.json();
  created.meetingIds.push(body.id);
  return body;
}

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

test("#302 cascade-preview: returns later active meetings with shifted times", async () => {
  const d = "2050-05-10";
  const a = await createMeeting(d, "A primary", "09:00", "10:00");
  const b = await createMeeting(d, "B follower", "10:30", "11:00");
  const c = await createMeeting(d, "C follower", "11:30", "12:00");
  // Earlier meeting must NOT be in the preview.
  const z = await createMeeting(d, "Z earlier", "08:00", "08:30");
  const res = await api(
    adminCookie,
    "POST",
    `/api/executive-meetings/${a.id}/cascade-preview`,
    { deltaMinutes: 30 },
  );
  assert.equal(res.status, 200);
  const body = await res.json();
  assert.equal(body.blockedBy, null);
  const ids = body.followers.map((f) => f.id);
  assert.deepEqual(ids, [b.id, c.id], "only later active meetings, in start order");
  assert.ok(!ids.includes(z.id), "earlier meetings must not be cascaded");
  assert.equal(body.followers[0].newStart.slice(0, 5), "11:00");
  assert.equal(body.followers[0].newEnd.slice(0, 5), "11:30");
  assert.equal(body.followers[1].newStart.slice(0, 5), "12:00");
});

test("#302 cascade-preview: skips cancelled and completed followers", async () => {
  const d = "2050-05-11";
  const a = await createMeeting(d, "A primary", "09:00", "10:00");
  const b = await createMeeting(d, "B cancelled", "10:30", "11:00", {
    status: "cancelled",
  });
  const c = await createMeeting(d, "C completed", "11:30", "12:00", {
    status: "completed",
  });
  const dActive = await createMeeting(d, "D active", "13:00", "14:00");
  const res = await api(
    adminCookie,
    "POST",
    `/api/executive-meetings/${a.id}/cascade-preview`,
    { deltaMinutes: 15 },
  );
  assert.equal(res.status, 200);
  const body = await res.json();
  const ids = body.followers.map((f) => f.id);
  assert.deepEqual(ids, [dActive.id]);
  assert.ok(!ids.includes(b.id));
  assert.ok(!ids.includes(c.id));
});

test("#302 postpone-minutes cascadeFollowing=true atomically shifts all later meetings + writes audit", async () => {
  const d = "2050-05-12";
  const a = await createMeeting(d, "A primary", "09:00", "10:00");
  const b = await createMeeting(d, "B follower", "10:30", "11:00");
  const c = await createMeeting(d, "C follower", "11:30", "12:00");
  const res = await api(
    adminCookie,
    "POST",
    `/api/executive-meetings/${a.id}/postpone-minutes`,
    {
      minutes: 30,
      expectedUpdatedAt: a.updatedAt,
      cascadeFollowing: true,
    },
  );
  assert.equal(res.status, 200);
  const body = await res.json();
  assert.equal(body.ok, true);
  assert.deepEqual(body.cascadedIds.sort((x, y) => x - y), [b.id, c.id].sort((x, y) => x - y));
  const aFresh = await getMeeting(a.id);
  const bFresh = await getMeeting(b.id);
  const cFresh = await getMeeting(c.id);
  assert.equal(aFresh.startTime.slice(0, 5), "09:30");
  assert.equal(aFresh.endTime.slice(0, 5), "10:30");
  assert.equal(bFresh.startTime.slice(0, 5), "11:00");
  assert.equal(bFresh.endTime.slice(0, 5), "11:30");
  assert.equal(cFresh.startTime.slice(0, 5), "12:00");
  assert.equal(cFresh.endTime.slice(0, 5), "12:30");
  // Audit rows for the cascade — one per follower, naming the trigger.
  const audits = await pool.query(
    `SELECT entity_id, new_value
       FROM executive_meeting_audit_logs
      WHERE action = 'meeting_cascade_shift'
        AND entity_id = ANY($1::int[])
      ORDER BY entity_id`,
    [[b.id, c.id]],
  );
  assert.equal(audits.rowCount, 2, "one cascade audit per follower");
  for (const row of audits.rows) {
    assert.equal(row.new_value.triggerMeetingId, a.id);
    assert.equal(row.new_value.deltaMinutes, 30);
  }
});

test("#302 postpone-minutes cascadeFollowing=false leaves followers untouched (no audit)", async () => {
  const d = "2050-05-13";
  const a = await createMeeting(d, "A primary", "09:00", "10:00");
  const b = await createMeeting(d, "B follower", "10:30", "11:00");
  const res = await api(
    adminCookie,
    "POST",
    `/api/executive-meetings/${a.id}/postpone-minutes`,
    {
      minutes: 15,
      expectedUpdatedAt: a.updatedAt,
      cascadeFollowing: false,
    },
  );
  assert.equal(res.status, 200);
  const body = await res.json();
  assert.deepEqual(body.cascadedIds, []);
  const aFresh = await getMeeting(a.id);
  const bFresh = await getMeeting(b.id);
  assert.equal(aFresh.startTime.slice(0, 5), "09:15", "primary moved");
  assert.equal(bFresh.startTime.slice(0, 5), "10:30", "follower unchanged");
  const audits = await pool.query(
    `SELECT COUNT(*)::int AS n
       FROM executive_meeting_audit_logs
      WHERE action = 'meeting_cascade_shift'
        AND entity_id = $1`,
    [b.id],
  );
  assert.equal(audits.rows[0].n, 0, "no cascade audit row when flag is false");
});

test("#302 postpone-minutes cascade midnight rejection rolls back primary too", async () => {
  const d = "2050-05-14";
  const a = await createMeeting(d, "A primary", "09:00", "10:00");
  // 23:30 follower would be pushed to 24:30 by a 60-minute delta.
  const b = await createMeeting(d, "B late follower", "23:30", "23:45");
  const res = await api(
    adminCookie,
    "POST",
    `/api/executive-meetings/${a.id}/postpone-minutes`,
    {
      minutes: 60,
      expectedUpdatedAt: a.updatedAt,
      cascadeFollowing: true,
    },
  );
  assert.equal(res.status, 400);
  const body = await res.json();
  assert.equal(body.code, "cascade_crosses_midnight");
  assert.equal(body.blockedBy.id, b.id);
  // Primary must be unchanged because the whole transaction rolled back.
  const aFresh = await getMeeting(a.id);
  assert.equal(aFresh.startTime.slice(0, 5), "09:00");
  assert.equal(aFresh.endTime.slice(0, 5), "10:00");
  const bFresh = await getMeeting(b.id);
  assert.equal(bFresh.startTime.slice(0, 5), "23:30");
});

test("#302 reschedule cascadeFollowing=true uses (newStart - oldStart) as delta on same day", async () => {
  const d = "2050-05-15";
  const a = await createMeeting(d, "A primary", "09:00", "10:00");
  const b = await createMeeting(d, "B follower", "10:30", "11:00");
  const res = await api(
    adminCookie,
    "POST",
    `/api/executive-meetings/${a.id}/reschedule`,
    {
      meetingDate: d,
      startTime: "09:45",
      endTime: "10:45",
      cascadeFollowing: true,
    },
  );
  assert.equal(res.status, 200);
  const body = await res.json();
  assert.deepEqual(body.cascadedIds, [b.id]);
  const bFresh = await getMeeting(b.id);
  // delta = 09:45 - 09:00 = 45m → 10:30 → 11:15
  assert.equal(bFresh.startTime.slice(0, 5), "11:15");
  assert.equal(bFresh.endTime.slice(0, 5), "11:45");
});

test("#302 reschedule cascadeFollowing=true is a no-op when date moves", async () => {
  const d1 = "2050-05-16";
  const d2 = "2050-05-17";
  const a = await createMeeting(d1, "A primary", "09:00", "10:00");
  const b = await createMeeting(d1, "B follower", "10:30", "11:00");
  const res = await api(
    adminCookie,
    "POST",
    `/api/executive-meetings/${a.id}/reschedule`,
    {
      meetingDate: d2,
      startTime: "09:30",
      endTime: "10:30",
      cascadeFollowing: true,
    },
  );
  assert.equal(res.status, 200);
  const body = await res.json();
  assert.deepEqual(body.cascadedIds, [], "cross-day reschedule must not cascade");
  const bFresh = await getMeeting(b.id);
  assert.equal(bFresh.startTime.slice(0, 5), "10:30", "follower untouched");
});

test("#302 reschedule cascadeFollowing=true is a no-op when newStart <= oldStart", async () => {
  const d = "2050-05-18";
  const a = await createMeeting(d, "A primary", "09:00", "10:00");
  const b = await createMeeting(d, "B follower", "10:30", "11:00");
  // Reschedule earlier: same day, newStart < oldStart → ignore flag.
  const res = await api(
    adminCookie,
    "POST",
    `/api/executive-meetings/${a.id}/reschedule`,
    {
      meetingDate: d,
      startTime: "08:30",
      endTime: "09:30",
      cascadeFollowing: true,
    },
  );
  assert.equal(res.status, 200);
  const body = await res.json();
  assert.deepEqual(body.cascadedIds, []);
  const bFresh = await getMeeting(b.id);
  assert.equal(bFresh.startTime.slice(0, 5), "10:30");
});

test("Notification prefs: DELETE on a user with no rows is a 200 no-op", async () => {
  // Cheap idempotence check — clicking Restore defaults twice in a row
  // must not 500 or surface a misleading error.
  const fresh = await createUser("em_pref_restore_noop", "executive_coordinator");
  const cookie = await login(fresh.username, TEST_PASSWORD);
  const del = await api(cookie, "DELETE",
    "/api/executive-meetings/notification-prefs");
  assert.equal(del.status, 200);
  const body = await del.json();
  assert.equal(body.ok, true);
  assert.equal(body.count, 0);
});
