// API contract tests for the shared row-colour overlay added by
// to the executive-meetings PATCH endpoint. Covers:
//
//   1. Setting a colour persists it on the row and round-trips back
//      through GET (server is the source of truth, not the browser).
//   2. Setting `rowColor: null` clears the colour back to default
//      without disturbing other fields on the row.
//   3. Unknown / off-palette colour keys are rejected with 400 and the
//      stored value does not change.
//   4. A non-mutate user (executive_viewer role) gets 403 — they can
//      see the colour but cannot change it.
//   5. Each successful change writes an audit-log row attributed to the
//      acting user, with the old + new colour captured so the audit
//      trail mirrors other meeting field edits.
//
// Cleanup runs in `after` regardless of which assertions failed so
// repeated runs of this file against the same DB don't leak rows.

import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
import { io as ioClient } from "socket.io-client";

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 = "TestPass123!";
const TEST_PASSWORD_HASH =
  "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";

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 RowColor 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 viewerCookie = null;

const today = new Date().toISOString().slice(0, 10);

async function createMeeting(titleEn, titleAr) {
  const res = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr,
    titleEn,
    meetingDate: today,
    startTime: "09:00",
    endTime: "10:00",
    platform: "none",
    status: "scheduled",
    isHighlighted: 0,
    attendees: [
      { name: "Tester", attendanceType: "internal", sortOrder: 0 },
    ],
  });
  assert.equal(res.status, 201, "create meeting should succeed");
  const meeting = await res.json();
  created.meetingIds.push(meeting.id);
  return meeting;
}

before(async () => {
  adminCookie = await login("admin", "admin123");
  // Capture the admin's user id so we can assert audit attribution.
  const meRes = await api(adminCookie, "GET", "/api/auth/me");
  if (meRes.status === 200) {
    const me = await meRes.json();
    adminUserId = me?.id ?? me?.user?.id ?? null;
  }
  // executive_viewer is in READ_ROLES but NOT in MUTATE_ROLES, so
  // requireExecutiveAccess passes and we hit requireMutate's 403.
  // This isolates the "non-editor" case from the bare "no executive
  // access at all" case (which would 403 earlier in the chain).
  const viewer = await createUser("em_rowcolor_viewer", "executive_viewer");
  viewerCookie = await login(viewer.username, TEST_PASSWORD);
});

after(async () => {
  if (created.meetingIds.length > 0) {
    await pool.query(
      `DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
      [created.meetingIds],
    );
    await pool.query(
      `DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`,
      [created.meetingIds],
    );
    await pool.query(
      `DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
      [created.meetingIds],
    );
  }
  if (created.userIds.length > 0) {
    await pool.query(
      `DELETE FROM 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();
});

test("PATCH rowColor: setting a colour persists and round-trips via GET", async () => {
  const meeting = await createMeeting("RowColor set", "تعيين لون الصف");
  // Sanity: a fresh row has no colour set.
  assert.equal(
    meeting.rowColor ?? null,
    null,
    "fresh meeting should default to no colour",
  );

  const patch = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    { rowColor: "red" },
  );
  assert.equal(patch.status, 200, "PATCH rowColor should succeed");
  const patched = await patch.json();
  assert.equal(patched.rowColor, "red", "PATCH response includes new colour");

  // The day GET is what every other client uses to refresh after the
  // socket fires, so it MUST surface the new colour. This is the
  // assertion that proves the colour is shared with other viewers.
  const day = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings?date=${today}`,
  );
  assert.equal(day.status, 200);
  const body = await day.json();
  const row = body.meetings.find((m) => m.id === meeting.id);
  assert.ok(row, "patched meeting should appear in GET ?date=");
  assert.equal(row.rowColor, "red", "GET reflects the persisted colour");
});

test("PATCH rowColor: { rowColor: null } clears the colour without touching other fields", async () => {
  const meeting = await createMeeting("RowColor clear", "مسح لون الصف");

  // Seed with a colour so we have something to clear.
  const seed = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    { rowColor: "blue" },
  );
  assert.equal(seed.status, 200);

  const clear = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    { rowColor: null },
  );
  assert.equal(clear.status, 200, "clearing rowColor should succeed");
  const cleared = await clear.json();
  assert.equal(cleared.rowColor, null, "rowColor is null after clear");
  // Other fields stay intact through the clear — guards against a
  // future regression where the PATCH handler accidentally overwrites
  // unrelated columns when only rowColor is sent.
  assert.equal(cleared.titleEn, "RowColor clear");
  assert.equal(cleared.startTime, "09:00:00");
  assert.equal(cleared.endTime, "10:00:00");
});

test("PATCH rowColor: unknown / off-palette colour key is rejected with 400 and does not change the row", async () => {
  const meeting = await createMeeting("RowColor invalid", "لون غير صالح");
  // Seed with a known good colour so we can prove the bad request
  // didn't silently overwrite it.
  const seed = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    { rowColor: "green" },
  );
  assert.equal(seed.status, 200);

  for (const bad of ["fuchsia", "RED", "", "default", " red "]) {
    const res = await api(
      adminCookie,
      "PATCH",
      `/api/executive-meetings/${meeting.id}`,
      { rowColor: bad },
    );
    assert.equal(
      res.status,
      400,
      `unknown rowColor key "${bad}" should be rejected with 400`,
    );
    const body = await res.json();
    assert.equal(body.code, "validation");
  }

  // Confirm the row still has the seeded colour, not anything else.
  const day = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings?date=${today}`,
  );
  const row = (await day.json()).meetings.find((m) => m.id === meeting.id);
  assert.equal(row.rowColor, "green", "row colour unchanged after 400s");
});

test("PATCH rowColor: non-mutate viewer role gets 403 and the row colour is unchanged", async () => {
  const meeting = await createMeeting(
    "RowColor viewer guard",
    "حماية المشاهد من تغيير اللون",
  );
  // Admin sets a colour first so the viewer's failed PATCH would be
  // visible if the guard were missing.
  const seed = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    { rowColor: "amber" },
  );
  assert.equal(seed.status, 200);

  const blocked = await api(
    viewerCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    { rowColor: "violet" },
  );
  assert.equal(blocked.status, 403, "viewer role should be blocked");

  // Viewer can still GET — they need to *see* the colour, just not change it.
  const day = await api(
    viewerCookie,
    "GET",
    `/api/executive-meetings?date=${today}`,
  );
  assert.equal(day.status, 200);
  const row = (await day.json()).meetings.find((m) => m.id === meeting.id);
  assert.equal(
    row.rowColor,
    "amber",
    "viewer's blocked PATCH did not change the persisted colour",
  );
});

test("PATCH rowColor: the realtime executive_meetings_changed event fires for the affected date so other tabs / devices know to re-fetch", async () => {
  // This is the contract that makes "shared" actually feel realtime —
  // without it, a second viewer would only see the colour change after
  // they manually refreshed. The frontend listens for this exact event
  // (see use-notifications-socket.ts) and invalidates the day query.
  const meeting = await createMeeting(
    "RowColor socket",
    "بث اللون عبر السوكيت",
  );

  const events = [];
  const socket = ioClient(API_BASE, {
    path: "/api/socket.io",
    transports: ["websocket"],
    forceNew: true,
    reconnection: false,
    extraHeaders: { Cookie: adminCookie },
  });
  await new Promise((resolve, reject) => {
    socket.on("executive_meetings_changed", (payload) => {
      events.push(payload);
    });
    socket.on("connect", resolve);
    socket.on("connect_error", reject);
  });

  try {
    const res = await api(
      adminCookie,
      "PATCH",
      `/api/executive-meetings/${meeting.id}`,
      { rowColor: "gray" },
    );
    assert.equal(res.status, 200);

    // Give the broadcast a moment to land. 400ms is plenty for a
    // localhost socket and well under the test timeout.
    const start = Date.now();
    while (events.length === 0 && Date.now() - start < 400) {
      await new Promise((r) => setTimeout(r, 25));
    }
    assert.ok(
      events.length > 0,
      "executive_meetings_changed should fire after a rowColor PATCH",
    );
    // Some payloads also include an array of dates; we just need the
    // current day to be addressed in any of them.
    const sawToday = events.some((p) => {
      if (!p) return false;
      if (typeof p.date === "string" && p.date === today) return true;
      if (Array.isArray(p.dates) && p.dates.includes(today)) return true;
      return false;
    });
    assert.ok(
      sawToday,
      `event should reference today's date (${today}); got ${JSON.stringify(events)}`,
    );
  } finally {
    socket.disconnect();
  }
});

test("DB CHECK constraint rejects off-palette rowColor on raw UPDATE that bypasses the API (#293)", async () => {
  // The API's Zod whitelist is the primary guard, but #293 adds a
  // matching CHECK constraint on `executive_meetings.row_color` so
  // any out-of-band write path (manual psql, future bulk jobs,
  // restored backups) cannot smuggle an unrenderable colour into the
  // table either. This test bypasses the route entirely and writes
  // straight to the DB to prove the constraint is doing its job.
  const meeting = await createMeeting(
    "RowColor DB check",
    "قيد التحقق على مستوى قاعدة البيانات",
  );

  // Sanity: a NULL (default / no tint) update must still succeed —
  // the constraint allows NULL to preserve the "no colour" case.
  await pool.query(
    `UPDATE executive_meetings SET row_color = NULL WHERE id = $1`,
    [meeting.id],
  );

  // Each of the six palette keys must round-trip cleanly so we know
  // the literal-list inside the CHECK matches the API whitelist.
  for (const good of ["red", "amber", "green", "blue", "violet", "gray"]) {
    await pool.query(
      `UPDATE executive_meetings SET row_color = $1 WHERE id = $2`,
      [good, meeting.id],
    );
  }

  // Anything outside the palette must be rejected with PG's
  // check_violation (SQLSTATE 23514) and must NOT change the stored
  // colour. Cover a few realistic mistakes a future bulk-import
  // script might introduce: an unsupported key, a casing slip, an
  // empty string, and the magic string "default" the UI uses for
  // the "no tint" radio option (which on the wire is supposed to be
  // null, not the literal "default").
  for (const bad of ["fuchsia", "RED", "", "default", "red ", "blue;--"]) {
    await assert.rejects(
      pool.query(
        `UPDATE executive_meetings SET row_color = $1 WHERE id = $2`,
        [bad, meeting.id],
      ),
      (err) => {
        assert.equal(
          err.code,
          "23514",
          `expected PG check_violation for "${bad}", got ${err.code}: ${err.message}`,
        );
        assert.match(
          err.constraint ?? "",
          /executive_meetings_row_color_palette_check/,
          `error should reference the row-colour palette CHECK constraint, got "${err.constraint}"`,
        );
        return true;
      },
    );
  }

  // Last good colour was "gray" — confirm the rejected updates did
  // not silently mutate the row.
  const { rows } = await pool.query(
    `SELECT row_color FROM executive_meetings WHERE id = $1`,
    [meeting.id],
  );
  assert.equal(
    rows[0]?.row_color,
    "gray",
    "rejected updates must leave the previous colour intact",
  );

  // Belt-and-braces: also exercise INSERT, since a future bulk-import
  // job is more likely to create rows than to UPDATE in place. The
  // constraint must reject the off-palette value at row creation too,
  // not only on subsequent updates.
  await assert.rejects(
    pool.query(
      `INSERT INTO executive_meetings (title_ar, title_en, meeting_date, daily_number, row_color)
       VALUES ($1, $2, $3, $4, $5)`,
      [
        "INSERT-check probe",
        "INSERT-check probe",
        today,
        // High daily_number to dodge the unique (date, daily_number)
        // index against meetings created earlier in this file.
        99000 + Math.floor(Math.random() * 999),
        "fuchsia",
      ],
    ),
    (err) => {
      assert.equal(err.code, "23514");
      assert.match(
        err.constraint ?? "",
        /executive_meetings_row_color_palette_check/,
      );
      return true;
    },
  );
});

test("PATCH rowColor: each successful change writes an audit row attributed to the acting user", async () => {
  const meeting = await createMeeting("RowColor audit", "سجل لون الصف");

  const set = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    { rowColor: "violet" },
  );
  assert.equal(set.status, 200);

  // The audit log is read by the admin section. Pull all entries for
  // this meeting and assert that the most recent one captures the
  // colour change. We don't strictly enforce ordering of unrelated
  // create / patch entries — we just need a row whose newValue.rowColor
  // is "violet" and whose performedBy is the admin.
  const { rows: auditRows } = await pool.query(
    `SELECT action, entity_type, entity_id, new_value, performed_by
       FROM executive_meeting_audit_logs
      WHERE entity_type = 'meeting' AND entity_id = $1
      ORDER BY id DESC`,
    [meeting.id],
  );
  const colorEntry = auditRows.find((r) => {
    const nv = r.new_value;
    return (
      nv &&
      typeof nv === "object" &&
      "rowColor" in nv &&
      nv.rowColor === "violet"
    );
  });
  assert.ok(
    colorEntry,
    "audit log should contain an entry whose newValue.rowColor is the colour we just set",
  );
  if (adminUserId != null) {
    assert.equal(
      colorEntry.performed_by,
      adminUserId,
      "audit row attributed to the admin who made the change",
    );
  }
});
