// API contract tests for the cell-merge overlay added by task #152
// to the executive-meetings PATCH endpoint. Covers:
//
//   1. Setting a merge writes mergeStartColumn / mergeEndColumn /
//      mergeText to the row and round-trips back through GET.
//   2. Clearing the merge with `merge: null` nulls all three columns
//      while leaving the rest of the row (title, attendees, etc.)
//      intact.
//   3. Invalid ranges (start column comes after end column) are
//      rejected with 400 and don't mutate the row.
//   4. mergeText is sanitized just like titleAr/titleEn — script tags,
//      event handlers, and javascript: hrefs are stripped while the
//      visible text and an allowed inline color survive.
//   5. A non-mutate user (executive_viewer role) receives 403 when
//      attempting to PATCH the merge fields, and the row is unchanged.
//
// 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";

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 Merge 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 viewerCookie = null;
let viewerUserId = null;

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

// Create a single meeting we can mutate across the tests below. Each
// test scopes its assertions to a fresh row id so test ordering doesn't
// leak state between assertions.
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");
  // 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_merge_viewer", "executive_viewer");
  viewerUserId = viewer.id;
  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 merge: setting a merge persists all three columns and round-trips via GET", async () => {
  const meeting = await createMeeting(
    "Merge set",
    "تعيين الدمج",
  );
  // Sanity: a fresh row has no merge.
  assert.equal(meeting.mergeStartColumn, null);
  assert.equal(meeting.mergeEndColumn, null);
  assert.equal(meeting.mergeText, null);

  const patch = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    {
      merge: {
        mergeStartColumn: "meeting",
        mergeEndColumn: "time",
        mergeText: "<p>Combined block</p>",
      },
    },
  );
  assert.equal(patch.status, 200, "PATCH merge should succeed");

  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.mergeStartColumn, "meeting");
  assert.equal(row.mergeEndColumn, "time");
  assert.match(
    row.mergeText ?? "",
    /Combined block/,
    "visible text from the merge label survives sanitization",
  );
});

test("PATCH merge: { merge: null } clears all three merge columns without touching the rest of the row", async () => {
  const meeting = await createMeeting("Merge clear", "مسح الدمج");

  // Seed with a merge so we have something to clear.
  const setRes = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    {
      merge: {
        mergeStartColumn: "number",
        mergeEndColumn: "attendees",
        mergeText: "to be cleared",
      },
    },
  );
  assert.equal(setRes.status, 200);

  // Now clear via merge: null in the same patch as a benign title
  // change. The title change must persist and the merge must vanish.
  const clearRes = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    {
      titleEn: "Merge cleared",
      merge: null,
    },
  );
  assert.equal(clearRes.status, 200, "clearing merge should succeed");

  const day = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings?date=${today}`,
  );
  const body = await day.json();
  const row = body.meetings.find((m) => m.id === meeting.id);
  assert.ok(row);
  assert.equal(row.mergeStartColumn, null, "start column cleared");
  assert.equal(row.mergeEndColumn, null, "end column cleared");
  assert.equal(row.mergeText, null, "merge text cleared");
  // Title change in the same PATCH was applied.
  assert.match(row.titleEn ?? "", /Merge cleared/);
  // Untouched fields stay intact.
  assert.equal(row.startTime, "09:00:00");
  assert.equal(row.endTime, "10:00:00");
});

test("PATCH merge: invalid range (start > end) is rejected with 400 and the row is unchanged", async () => {
  const meeting = await createMeeting(
    "Merge invalid",
    "مدى غير صالح",
  );

  // The schedule columns are ordered: number, meeting, attendees, time.
  // start=time, end=meeting reverses the order — must fail validation.
  const bad = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    {
      merge: {
        mergeStartColumn: "time",
        mergeEndColumn: "meeting",
        mergeText: "should not be saved",
      },
    },
  );
  assert.equal(bad.status, 400, "reversed range must be rejected");

  // The row must not have been mutated by the rejected request.
  const day = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings?date=${today}`,
  );
  const body = await day.json();
  const row = body.meetings.find((m) => m.id === meeting.id);
  assert.ok(row);
  assert.equal(row.mergeStartColumn, null);
  assert.equal(row.mergeEndColumn, null);
  assert.equal(row.mergeText, null);

  // A column id outside the enum is also rejected.
  const badEnum = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    {
      merge: {
        mergeStartColumn: "meeting",
        mergeEndColumn: "notes",
        mergeText: "x",
      },
    },
  );
  assert.equal(badEnum.status, 400, "unknown column id must be rejected");
});

test("PATCH merge: mergeText is sanitized — script tags and JS handlers are stripped, visible text survives", async () => {
  const meeting = await createMeeting(
    "Merge sanitize",
    "تنقية الدمج",
  );

  const dirty =
    '<p style="color: rgb(220, 38, 38)">Visible <strong>bold</strong></p>' +
    '<script>alert(1)</script>' +
    '<a href="javascript:hack()" onerror="hack()">link</a>' +
    '<img src=x onerror="hack()" />';

  const patch = await api(
    adminCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    {
      merge: {
        mergeStartColumn: "meeting",
        mergeEndColumn: "attendees",
        mergeText: dirty,
      },
    },
  );
  assert.equal(patch.status, 200);

  const day = await api(
    adminCookie,
    "GET",
    `/api/executive-meetings?date=${today}`,
  );
  const body = await day.json();
  const row = body.meetings.find((m) => m.id === meeting.id);
  const text = row.mergeText ?? "";

  // Visible content survives.
  assert.match(text, /Visible/, "visible text preserved");
  assert.match(text, /<(strong|b)[\s>]/i, "<strong> bold preserved");
  // The allowed inline color style on <p> must survive the sanitizer
  // — Tiptap-style formatting (color, bold, etc.) is intentionally
  // preserved so the merged label can carry the editor's formatting.
  // Different sanitizers normalize whitespace/quotes inside the style
  // attribute slightly differently, so the assertion just checks for
  // the rgb triple in any spacing.
  assert.match(
    text.toLowerCase(),
    /rgb\(\s*220\s*,\s*38\s*,\s*38\s*\)/,
    "allowed inline color style survives sanitization",
  );

  // Dangerous markup gone.
  assert.ok(!/<script/i.test(text), "<script> stripped");
  assert.ok(!/onerror/i.test(text), "onerror handler stripped");
  assert.ok(!/javascript:/i.test(text), "javascript: scheme stripped");
  assert.ok(!/<img/i.test(text), "<img> stripped");
});

test("PATCH merge: a non-editor (executive_viewer) gets 403 and the row is not mutated", async () => {
  const meeting = await createMeeting(
    "Merge forbidden",
    "ممنوع",
  );

  const forbidden = await api(
    viewerCookie,
    "PATCH",
    `/api/executive-meetings/${meeting.id}`,
    {
      merge: {
        mergeStartColumn: "meeting",
        mergeEndColumn: "time",
        mergeText: "viewer attempt",
      },
    },
  );
  assert.equal(
    forbidden.status,
    403,
    "viewer without mutate role must be rejected",
  );

  // Confirm via DB that nothing changed — defense in depth against
  // a future regression where the handler updates the row before the
  // 403 short-circuits.
  const { rows } = await pool.query(
    `SELECT merge_start_column, merge_end_column, merge_text
       FROM executive_meetings WHERE id = $1`,
    [meeting.id],
  );
  assert.equal(rows.length, 1);
  assert.equal(rows[0].merge_start_column, null);
  assert.equal(rows[0].merge_end_column, null);
  assert.equal(rows[0].merge_text, null);
});
