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

let adminId;
let adminUsername;
let adminCookie;

let directHolderId;
let directHolderCookie;

let groupHolderId;
let groupHolderCookie;
let helperGroupId;

let outsiderId;
let outsiderCookie;

let testRoleId;
let permIdA;
let permIdB;

const createdRoleIds = [];
const createdGroupIds = [];
const createdUserIds = [];

async function loginAndGetCookie(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 expected 200, got ${res.status}`);
  const setCookie = res.headers.get("set-cookie");
  return setCookie
    .split(",")
    .map((c) => c.split(";")[0].trim())
    .find((c) => c.startsWith("connect.sid="));
}

async function makeUser(prefix, stamp) {
  const username = `${prefix}_${stamp}`;
  const r = 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} User`],
  );
  const id = r.rows[0].id;
  createdUserIds.push(id);
  return { id, username };
}

/**
 * Open an authenticated socket and resolve once `connect` fires (so the server
 * has joined the user's room and is ready to deliver events). Records every
 * `role_permissions_changed` payload it receives in the returned `events`
 * array so callers can assert on what arrived (or didn't), and exposes a
 * `waitForEvent(timeoutMs)` that resolves as soon as the next event lands.
 */
function connectSocket(cookie) {
  return new Promise((resolve, reject) => {
    const events = [];
    const waiters = [];
    const socket = ioClient(API_BASE, {
      path: "/api/socket.io",
      transports: ["websocket"],
      forceNew: true,
      reconnection: false,
      extraHeaders: { Cookie: cookie },
    });
    socket.on("role_permissions_changed", (payload) => {
      events.push(payload);
      while (waiters.length > 0) waiters.shift()(payload);
    });
    socket.on("connect", () =>
      resolve({
        socket,
        events,
        waitForEvent(timeoutMs = 1000) {
          // If an event already arrived before the caller started waiting,
          // resolve immediately so we don't time out spuriously.
          if (events.length > 0) return Promise.resolve(events[events.length - 1]);
          return new Promise((res, rej) => {
            const timer = setTimeout(() => {
              const idx = waiters.indexOf(once);
              if (idx >= 0) waiters.splice(idx, 1);
              rej(new Error(`Timed out waiting for role_permissions_changed after ${timeoutMs}ms`));
            }, timeoutMs);
            const once = (payload) => {
              clearTimeout(timer);
              res(payload);
            };
            waiters.push(once);
          });
        },
      }),
    );
    socket.on("connect_error", (err) => reject(err));
  });
}

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

before(async () => {
  const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;

  // Admin (used to call PUT /api/roles/:id/permissions).
  const admin = await makeUser("rt_admin", stamp);
  adminId = admin.id;
  adminUsername = admin.username;
  await pool.query(
    `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
    [adminId],
  );
  adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);

  // The non-system role whose permissions we will mutate.
  const roleRow = await pool.query(
    `INSERT INTO roles (name, description_en) VALUES ($1, $2) RETURNING id`,
    [`rt_role_${stamp}`, "realtime test role"],
  );
  testRoleId = roleRow.rows[0].id;
  createdRoleIds.push(testRoleId);

  // Two seeded permissions are enough to drive a real PUT.
  const perms = await pool.query(
    `SELECT id FROM permissions ORDER BY id LIMIT 2`,
  );
  assert.ok(perms.rowCount >= 2, "need at least 2 seeded permissions");
  permIdA = perms.rows[0].id;
  permIdB = perms.rows[1].id;

  // directHolder gets the role through user_roles directly.
  const direct = await makeUser("rt_direct", stamp);
  directHolderId = direct.id;
  await pool.query(
    `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`,
    [directHolderId, testRoleId],
  );
  directHolderCookie = await loginAndGetCookie(direct.username, TEST_PASSWORD);

  // groupHolder gets the role transitively: user_groups -> group_roles.
  const grouped = await makeUser("rt_grouped", stamp);
  groupHolderId = grouped.id;
  const groupRow = await pool.query(
    `INSERT INTO groups (name, description_en) VALUES ($1, $2) RETURNING id`,
    [`rt_group_${stamp}`, "realtime test group"],
  );
  helperGroupId = groupRow.rows[0].id;
  createdGroupIds.push(helperGroupId);
  await pool.query(
    `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
    [groupHolderId, helperGroupId],
  );
  await pool.query(
    `INSERT INTO group_roles (group_id, role_id) VALUES ($1, $2)`,
    [helperGroupId, testRoleId],
  );
  groupHolderCookie = await loginAndGetCookie(grouped.username, TEST_PASSWORD);

  // outsider does not hold the role at all (direct or via group).
  const outsider = await makeUser("rt_outsider", stamp);
  outsiderId = outsider.id;
  outsiderCookie = await loginAndGetCookie(outsider.username, TEST_PASSWORD);
});

after(async () => {
  if (createdRoleIds.length) {
    await pool.query(`DELETE FROM user_roles WHERE role_id = ANY($1::int[])`, [
      createdRoleIds,
    ]);
    await pool.query(`DELETE FROM group_roles WHERE role_id = ANY($1::int[])`, [
      createdRoleIds,
    ]);
    await pool.query(
      `DELETE FROM role_permission_audit WHERE role_id = ANY($1::int[])`,
      [createdRoleIds],
    );
    await pool.query(
      `DELETE FROM role_permissions WHERE role_id = ANY($1::int[])`,
      [createdRoleIds],
    );
    await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [
      createdRoleIds,
    ]);
  }
  if (createdGroupIds.length) {
    await pool.query(
      `DELETE FROM group_roles WHERE group_id = ANY($1::int[])`,
      [createdGroupIds],
    );
    await pool.query(
      `DELETE FROM user_groups WHERE group_id = ANY($1::int[])`,
      [createdGroupIds],
    );
    await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [
      createdGroupIds,
    ]);
  }
  for (const uid of createdUserIds) {
    await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]);
    await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]);
    await pool.query(`DELETE FROM users WHERE id = $1`, [uid]);
  }
  await pool.end();
});

test("PUT /api/roles/:id/permissions emits role_permissions_changed to direct and group-derived holders, but not to outsiders", async () => {
  const direct = await connectSocket(directHolderCookie);
  const grouped = await connectSocket(groupHolderCookie);
  const outsider = await connectSocket(outsiderCookie);

  try {
    const put = await fetch(
      `${API_BASE}/api/roles/${testRoleId}/permissions`,
      {
        method: "PUT",
        headers: { "Content-Type": "application/json", Cookie: adminCookie },
        body: JSON.stringify({ permissionIds: [permIdA, permIdB] }),
      },
    );
    assert.equal(put.status, 200, "PUT should succeed");

    // Wait for the event on each holder rather than sleeping a fixed amount —
    // resolves as soon as the broadcast lands and fails fast on regressions.
    const [directPayload, groupedPayload] = await Promise.all([
      direct.waitForEvent(2000),
      grouped.waitForEvent(2000),
    ]);
    assert.deepEqual(directPayload, { roleId: testRoleId });
    assert.deepEqual(groupedPayload, { roleId: testRoleId });

    assert.equal(
      direct.events.length,
      1,
      `direct holder should receive exactly one role_permissions_changed event, got ${direct.events.length}`,
    );
    assert.equal(
      grouped.events.length,
      1,
      `group-derived holder should receive exactly one role_permissions_changed event, got ${grouped.events.length}`,
    );

    // Negative case: give the loop a moment after both holders received their
    // events so a (regression) extra emit to the outsider would have time to
    // arrive, then assert nothing landed.
    await waitMs(100);
    assert.equal(
      outsider.events.length,
      0,
      `outsider must not receive role_permissions_changed, got ${outsider.events.length} event(s)`,
    );
  } finally {
    direct.socket.disconnect();
    grouped.socket.disconnect();
    outsider.socket.disconnect();
  }
});
