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

const pool = new pg.Pool({ connectionString: DATABASE_URL });

const created = {
  userIds: [],
  groupIds: [],
  meetingIds: [],
};

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

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);
}

async function makeUser(prefix) {
  const username = uniqueName(prefix);
  const { rows } = await pool.query(
    `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
     VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
    [username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix],
  );
  const id = rows[0].id;
  created.userIds.push(id);
  return { id, username };
}

async function grantRoleDirect(userId, roleName) {
  await pool.query(
    `INSERT INTO user_roles (user_id, role_id)
     SELECT $1, id FROM roles WHERE name = $2
     ON CONFLICT DO NOTHING`,
    [userId, roleName],
  );
}

async function grantRoleViaGroup(userId, roleName, groupName) {
  const g = await pool.query(
    `INSERT INTO groups (name, description_en) VALUES ($1, $2) RETURNING id`,
    [groupName, "notif fan-out test group"],
  );
  const groupId = g.rows[0].id;
  created.groupIds.push(groupId);
  await pool.query(
    `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
    [userId, groupId],
  );
  await pool.query(
    `INSERT INTO group_roles (group_id, role_id)
     SELECT $1, id FROM roles WHERE name = $2`,
    [groupId, roleName],
  );
  return groupId;
}

function connectSocket(cookie) {
  return new Promise((resolve, reject) => {
    const events = { created: [], changed: [] };
    const socket = ioClient(API_BASE, {
      path: "/api/socket.io",
      transports: ["websocket"],
      forceNew: true,
      reconnection: false,
      extraHeaders: { Cookie: cookie },
    });
    socket.on("notification_created", (payload) => {
      events.created.push(payload);
    });
    socket.on("executive_meeting_notifications_changed", (payload) => {
      events.changed.push(payload);
    });
    socket.on("connect", () => resolve({ socket, events }));
    socket.on("connect_error", (err) => reject(err));
  });
}

function clearSocketEvents(s) {
  s.events.created.length = 0;
  s.events.changed.length = 0;
}

function waitMs(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

// Broadcasts fire via `void broadcast...()` after the HTTP response, so
// poll briefly until the captured events satisfy the predicate.
async function waitFor(predicate, { timeoutMs = 1500, intervalMs = 25 } = {}) {
  const started = Date.now();
  while (Date.now() - started < timeoutMs) {
    if (predicate()) return;
    await waitMs(intervalMs);
  }
  if (!predicate()) {
    throw new Error(`waitFor: predicate never became true within ${timeoutMs}ms`);
  }
}

async function snapshotMaxIds() {
  const en = await pool.query(
    `SELECT COALESCE(MAX(id), 0) AS m FROM executive_meeting_notifications`,
  );
  const n = await pool.query(
    `SELECT COALESCE(MAX(id), 0) AS m FROM notifications`,
  );
  return { emnMax: Number(en.rows[0].m), nMax: Number(n.rows[0].m) };
}

async function newRowsAfter(snap) {
  const emn = await pool.query(
    `SELECT id,
            meeting_id        AS "meetingId",
            user_id           AS "userId",
            notification_type AS "notificationType",
            status,
            sent_at           AS "sentAt"
       FROM executive_meeting_notifications
      WHERE id > $1
   ORDER BY id`,
    [snap.emnMax],
  );
  const n = await pool.query(
    `SELECT id,
            user_id      AS "userId",
            title_en     AS "titleEn",
            title_ar     AS "titleAr",
            type,
            related_type AS "relatedType",
            related_id   AS "relatedId"
       FROM notifications
      WHERE id > $1
   ORDER BY id`,
    [snap.nMax],
  );
  return { emn: emn.rows, notifications: n.rows };
}

function rowsForUser(rows, userId) {
  return rows.filter((r) => Number(r.userId) === Number(userId));
}

// Scope a snapshot diff to a single trigger so parallel test files
// writing notifications for the seeded admin actor can't trip our checks.
function scopeDiff(diff, { notificationType, meetingId, relatedType, relatedId }) {
  const emn = diff.emn.filter((r) => {
    if (notificationType && r.notificationType !== notificationType) return false;
    if (meetingId !== undefined && meetingId !== null) {
      if (Number(r.meetingId) !== Number(meetingId)) return false;
    }
    return true;
  });
  const notifications = diff.notifications.filter((r) => {
    if (relatedType && r.relatedType !== relatedType) return false;
    if (relatedId !== undefined && relatedId !== null) {
      if (Number(r.relatedId) !== Number(relatedId)) return false;
    }
    return true;
  });
  return { emn, notifications };
}

let adminCookie = null;
let adminUserId = null;

let approver1 = null;
let approver2 = null;
let coord1 = null;
let coord2 = null;

let approver1Sock = null;
let approver2Sock = null;
let coord1Sock = null;
let coord2Sock = 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;

  approver1 = await makeUser("notif_appr1");
  await grantRoleDirect(approver1.id, "user");
  await grantRoleDirect(approver1.id, "executive_office_manager");
  approver1.cookie = await login(approver1.username, TEST_PASSWORD);

  approver2 = await makeUser("notif_appr2");
  await grantRoleDirect(approver2.id, "user");
  await grantRoleDirect(approver2.id, "executive_office_manager");
  // approver2 also has the role via a group, so getUserIdsForRoleNames
  // sees them on both the direct and group-derived paths — dedupe must
  // collapse this to a single row.
  await grantRoleViaGroup(
    approver2.id,
    "executive_office_manager",
    uniqueName("notif_grp"),
  );
  approver2.cookie = await login(approver2.username, TEST_PASSWORD);

  coord1 = await makeUser("notif_coord1");
  await grantRoleDirect(coord1.id, "user");
  await grantRoleDirect(coord1.id, "executive_coordinator");
  coord1.cookie = await login(coord1.username, TEST_PASSWORD);

  coord2 = await makeUser("notif_coord2");
  await grantRoleDirect(coord2.id, "user");
  await grantRoleDirect(coord2.id, "executive_coordinator");
  coord2.cookie = await login(coord2.username, TEST_PASSWORD);

  approver1Sock = await connectSocket(approver1.cookie);
  approver2Sock = await connectSocket(approver2.cookie);
  coord1Sock = await connectSocket(coord1.cookie);
  coord2Sock = await connectSocket(coord2.cookie);
});

after(async () => {
  for (const s of [approver1Sock, approver2Sock, coord1Sock, coord2Sock]) {
    try {
      s?.socket?.disconnect();
    } catch {
      /* ignore */
    }
  }
  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_meeting_notifications WHERE meeting_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 executive_meeting_notifications WHERE user_id = ANY($1::int[])`,
      [created.userIds],
    );
    await pool.query(
      `DELETE FROM notifications WHERE user_id = ANY($1::int[])`,
      [created.userIds],
    );
    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 user_groups WHERE user_id = ANY($1::int[])`, [
      created.userIds,
    ]);
    await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
      created.userIds,
    ]);
  }
  if (created.groupIds.length > 0) {
    await pool.query(
      `DELETE FROM group_roles WHERE group_id = ANY($1::int[])`,
      [created.groupIds],
    );
    await pool.query(
      `DELETE FROM user_groups WHERE group_id = ANY($1::int[])`,
      [created.groupIds],
    );
    await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [
      created.groupIds,
    ]);
  }
  await pool.end();
});

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

function assertRecipientGotOneOfEach(diff, userId, expected) {
  const emnRows = rowsForUser(diff.emn, userId);
  assert.equal(
    emnRows.length,
    1,
    `user ${userId} must have exactly 1 executive_meeting_notifications row, got ${emnRows.length}`,
  );
  const emn = emnRows[0];
  assert.equal(emn.notificationType, expected.notificationType);
  assert.equal(emn.status, "sent");
  assert.ok(emn.sentAt, "sent_at must be populated");
  if (expected.meetingId !== undefined) {
    assert.equal(emn.meetingId, expected.meetingId);
  }

  const nRows = rowsForUser(diff.notifications, userId);
  assert.equal(
    nRows.length,
    1,
    `user ${userId} must have exactly 1 notifications row, got ${nRows.length}`,
  );
  const n = nRows[0];
  assert.equal(n.type, "executive_meeting");
  if (expected.relatedType !== undefined) {
    assert.equal(n.relatedType, expected.relatedType);
  }
  if (expected.relatedId !== undefined) {
    assert.equal(Number(n.relatedId), Number(expected.relatedId));
  }
}

function assertActorExcluded(diff, actorId) {
  assert.equal(
    rowsForUser(diff.emn, actorId).length,
    0,
    `actor ${actorId} must NOT receive an executive_meeting_notifications row`,
  );
  assert.equal(
    rowsForUser(diff.notifications, actorId).length,
    0,
    `actor ${actorId} must NOT receive a notifications row`,
  );
}

async function expectSocketEventsFor(
  userSocks,
  notificationType,
  meetingId,
  { expectExactlyOne = false } = {},
) {
  await waitFor(
    () =>
      userSocks.every((s) =>
        s.events.created.some((p) => p.notificationType === notificationType),
      ) &&
      userSocks.some((s) =>
        s.events.changed.some(
          (p) => p.notificationType === notificationType,
        ),
      ),
    { timeoutMs: 2000 },
  );
  // Give a brief grace window so any duplicate emission would also land
  // before we count, otherwise the strict count check could pass by
  // racing the second event.
  if (expectExactlyOne) await waitMs(150);
  for (const s of userSocks) {
    const matches = s.events.created.filter(
      (p) => p.notificationType === notificationType,
    );
    assert.ok(
      matches.length >= 1,
      `socket should have received notification_created for ${notificationType}`,
    );
    if (expectExactlyOne) {
      assert.equal(
        matches.length,
        1,
        `socket should have received exactly 1 notification_created for ${notificationType}, got ${matches.length}`,
      );
    }
    const got = matches[0];
    assert.equal(got.type, "executive_meeting");
    if (meetingId !== undefined) {
      assert.equal(got.meetingId, meetingId);
    }
  }
  const anyChanged = userSocks
    .flatMap((s) => s.events.changed)
    .find((p) => p.notificationType === notificationType);
  assert.ok(
    anyChanged,
    `executive_meeting_notifications_changed must fire for ${notificationType}`,
  );
  if (meetingId !== undefined) {
    assert.equal(anyChanged.meetingId, meetingId);
  }
}

function expectNoSocketEventFor(sock, notificationType) {
  const offending = sock.events.created.find(
    (p) => p.notificationType === notificationType,
  );
  assert.equal(
    offending,
    undefined,
    `socket should NOT have received notification_created for ${notificationType}`,
  );
}

test("meeting_created: fan-out excludes actor, dedupes direct+group, writes both tables, emits sockets", async () => {
  for (const s of [approver1Sock, approver2Sock, coord1Sock, coord2Sock]) {
    clearSocketEvents(s);
  }
  const before = await snapshotMaxIds();

  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "اجتماع إخطارات",
    titleEn: "Notif fan-out meeting",
    meetingDate: today,
    attendees: [],
  });
  assert.equal(create.status, 201);
  const meeting = await create.json();
  created.meetingIds.push(meeting.id);

  await waitFor(
    () =>
      approver1Sock.events.created.some(
        (p) => p.notificationType === "meeting_created",
      ) &&
      approver2Sock.events.created.some(
        (p) => p.notificationType === "meeting_created",
      ),
    { timeoutMs: 2000 },
  );

  const diff = scopeDiff(await newRowsAfter(before), {
    notificationType: "meeting_created",
    meetingId: meeting.id,
    relatedType: "executive_meeting",
    relatedId: meeting.id,
  });

  assertRecipientGotOneOfEach(diff, approver1.id, {
    notificationType: "meeting_created",
    meetingId: meeting.id,
    relatedType: "executive_meeting",
    relatedId: meeting.id,
  });
  assertRecipientGotOneOfEach(diff, approver2.id, {
    notificationType: "meeting_created",
    meetingId: meeting.id,
    relatedType: "executive_meeting",
    relatedId: meeting.id,
  });
  assertActorExcluded(diff, adminUserId);

  await expectSocketEventsFor(
    [approver1Sock, approver2Sock],
    "meeting_created",
    meeting.id,
    { expectExactlyOne: true },
  );
  expectNoSocketEventFor(coord1Sock, "meeting_created");
  expectNoSocketEventFor(coord2Sock, "meeting_created");
});


// #238: opt-out coverage for filterRecipientsByNotificationPref. The HTTP
// surface gives us black-box access — set a pref row, trigger the event,
// assert delivery (or non-delivery) at the EMN + notifications + socket
// layer. Each test owns its own pref row(s) and cleans them up so the
// suite stays order-independent.
async function setPref(userId, notificationType, { inApp, email }) {
  await pool.query(
    `INSERT INTO executive_meeting_notification_prefs
       (user_id, notification_type, in_app, email)
     VALUES ($1, $2, $3, $4)
     ON CONFLICT (user_id, notification_type)
       DO UPDATE SET in_app = EXCLUDED.in_app, email = EXCLUDED.email`,
    [userId, notificationType, inApp, email],
  );
}

async function clearPref(userId, notificationType) {
  await pool.query(
    `DELETE FROM executive_meeting_notification_prefs
      WHERE user_id = $1 AND notification_type = $2`,
    [userId, notificationType],
  );
}

test("pref opt-out: inApp=false drops the user from in-app fan-out (others unaffected)", async () => {
  await setPref(approver1.id, "meeting_created", { inApp: false, email: true });
  try {
    for (const s of [approver1Sock, approver2Sock]) clearSocketEvents(s);
    const before = await snapshotMaxIds();

    const create = await api(adminCookie, "POST", "/api/executive-meetings", {
      titleAr: "اختبار تعطيل",
      titleEn: "Opt-out fan-out",
      meetingDate: today,
      attendees: [],
    });
    assert.equal(create.status, 201);
    const meeting = await create.json();
    created.meetingIds.push(meeting.id);

    // Wait for approver2 (default-on) so we know fan-out completed before
    // checking that approver1 was dropped.
    await waitFor(
      () =>
        approver2Sock.events.created.some(
          (p) => p.notificationType === "meeting_created",
        ),
      { timeoutMs: 2000 },
    );

    const diff = scopeDiff(await newRowsAfter(before), {
      notificationType: "meeting_created",
      meetingId: meeting.id,
      relatedType: "executive_meeting",
      relatedId: meeting.id,
    });

    assert.equal(
      rowsForUser(diff.emn, approver1.id).length,
      0,
      "opted-out user must NOT get an executive_meeting_notifications row",
    );
    assert.equal(
      rowsForUser(diff.notifications, approver1.id).length,
      0,
      "opted-out user must NOT get a notifications row",
    );
    assertRecipientGotOneOfEach(diff, approver2.id, {
      notificationType: "meeting_created",
      meetingId: meeting.id,
      relatedType: "executive_meeting",
      relatedId: meeting.id,
    });
    expectNoSocketEventFor(approver1Sock, "meeting_created");
  } finally {
    await clearPref(approver1.id, "meeting_created");
  }
});

test("pref opt-out: missing pref row defaults to ON (user receives delivery)", async () => {
  // No setPref call — approver1 has no row at all for this event type.
  // This is the implicit default-on case. Assert it explicitly so a
  // future regression (e.g. flipping the default to off) is caught.
  await pool.query(
    `DELETE FROM executive_meeting_notification_prefs
      WHERE user_id = $1 AND notification_type = $2`,
    [approver1.id, "meeting_created"],
  );

  for (const s of [approver1Sock, approver2Sock]) clearSocketEvents(s);
  const before = await snapshotMaxIds();

  const create = await api(adminCookie, "POST", "/api/executive-meetings", {
    titleAr: "افتراضي مفعل",
    titleEn: "Default-on fan-out",
    meetingDate: today,
    attendees: [],
  });
  assert.equal(create.status, 201);
  const meeting = await create.json();
  created.meetingIds.push(meeting.id);

  await waitFor(
    () =>
      approver1Sock.events.created.some(
        (p) => p.notificationType === "meeting_created",
      ),
    { timeoutMs: 2000 },
  );

  const diff = scopeDiff(await newRowsAfter(before), {
    notificationType: "meeting_created",
    meetingId: meeting.id,
    relatedType: "executive_meeting",
    relatedId: meeting.id,
  });
  assertRecipientGotOneOfEach(diff, approver1.id, {
    notificationType: "meeting_created",
    meetingId: meeting.id,
    relatedType: "executive_meeting",
    relatedId: meeting.id,
  });
});

test("pref opt-out: email=false does NOT affect the in-app channel", async () => {
  // Channel independence — muting email must leave in-app delivery alone.
  await setPref(approver1.id, "meeting_created", { inApp: true, email: false });
  try {
    for (const s of [approver1Sock, approver2Sock]) clearSocketEvents(s);
    const before = await snapshotMaxIds();

    const create = await api(adminCookie, "POST", "/api/executive-meetings", {
      titleAr: "قناة منفصلة",
      titleEn: "Channel independence",
      meetingDate: today,
      attendees: [],
    });
    assert.equal(create.status, 201);
    const meeting = await create.json();
    created.meetingIds.push(meeting.id);

    await waitFor(
      () =>
        approver1Sock.events.created.some(
          (p) => p.notificationType === "meeting_created",
        ),
      { timeoutMs: 2000 },
    );

    const diff = scopeDiff(await newRowsAfter(before), {
      notificationType: "meeting_created",
      meetingId: meeting.id,
      relatedType: "executive_meeting",
      relatedId: meeting.id,
    });
    assertRecipientGotOneOfEach(diff, approver1.id, {
      notificationType: "meeting_created",
      meetingId: meeting.id,
      relatedType: "executive_meeting",
      relatedId: meeting.id,
    });
  } finally {
    await clearPref(approver1.id, "meeting_created");
  }
});
