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

let adminUsername;
let adminCookie;
let consumerUsername;
let consumerCookie;

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

let appId;
let permA;
let permB;
let permC;
let roleA;
let roleB;
let roleC;
let userA;        // direct role A only — has permA
let userAB;       // direct roles A and B — has permA and permB
let userViaGroup; // in a group that has role A — has permA via group
let helperGroupId;
let groupGrantedAppId;       // app explicitly granted to a group via group_apps
let groupAppGrantUserId;     // member of that group; sees the app even with no perms
let groupAppGroupId;

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

async function makePermission(prefix, stamp) {
  const r = await pool.query(
    `INSERT INTO permissions (name, description_en) VALUES ($1, $2) RETURNING id, name`,
    [`${prefix}.${stamp}`, `${prefix} permission`],
  );
  const row = r.rows[0];
  createdPermissionIds.push(row.id);
  return row;
}

async function makeRole(prefix, stamp, perms) {
  const r = await pool.query(
    `INSERT INTO roles (name, description_en) VALUES ($1, $2) RETURNING id`,
    [`${prefix}_${stamp}`, `${prefix} role`],
  );
  const id = r.rows[0].id;
  createdRoleIds.push(id);
  for (const p of perms) {
    await pool.query(
      `INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2)`,
      [id, p.id],
    );
  }
  return id;
}

async function makeGroup(prefix, stamp) {
  const r = await pool.query(
    `INSERT INTO groups (name, description_en) VALUES ($1, $2) RETURNING id`,
    [`${prefix}_${stamp}`, `${prefix} group`],
  );
  const id = r.rows[0].id;
  createdGroupIds.push(id);
  return id;
}

async function makeApp(prefix, stamp) {
  const slug = `${prefix}_${stamp}`;
  const r = await pool.query(
    `INSERT INTO apps (slug, name_ar, name_en, route, is_active, sort_order)
     VALUES ($1, $2, $3, $4, true, 999) RETURNING id`,
    [slug, slug, slug, `/${slug}`],
  );
  const id = r.rows[0].id;
  createdAppIds.push(id);
  return id;
}

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

  // Admin user (granted system-wide admin role).
  const admin = await makeUser("api_admin", stamp);
  adminUsername = admin.username;
  await pool.query(
    `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
    [admin.id],
  );

  // Plain consumer (used to verify the endpoint requires admin).
  const consumer = await makeUser("api_consumer", stamp);
  consumerUsername = consumer.username;

  adminCookie = await login(adminUsername, TEST_PASSWORD);
  consumerCookie = await login(consumerUsername, TEST_PASSWORD);

  permA = await makePermission("api.perm.a", stamp);
  permB = await makePermission("api.perm.b", stamp);
  permC = await makePermission("api.perm.c", stamp);

  roleA = await makeRole("api_role_a", stamp, [permA]);
  roleB = await makeRole("api_role_b", stamp, [permB]);
  roleC = await makeRole("api_role_c", stamp, [permC]);

  // userA: direct roleA → has permA.
  ({ id: userA } = await makeUser("api_user_a", stamp));
  await pool.query(
    `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)`,
    [userA, roleA],
  );

  // userAB: direct roles A and B → has permA and permB.
  ({ id: userAB } = await makeUser("api_user_ab", stamp));
  await pool.query(
    `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2), ($1, $3)`,
    [userAB, roleA, roleB],
  );

  // userViaGroup: gets roleA via membership in helperGroup → has permA via group.
  ({ id: userViaGroup } = await makeUser("api_user_viagrp", stamp));
  helperGroupId = await makeGroup("api_helper_grp", stamp);
  await pool.query(
    `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
    [userViaGroup, helperGroupId],
  );
  await pool.query(
    `INSERT INTO group_roles (group_id, role_id) VALUES ($1, $2)`,
    [helperGroupId, roleA],
  );

  // App that we will run impact previews against.
  appId = await makeApp("api_impact_app", stamp);

  // Separate scenario for the group-grant offset: an app with no permissions
  // but explicitly granted to a group via group_apps. A member of that group
  // sees the app even after we add a permission requirement they don't have.
  groupGrantedAppId = await makeApp("api_grpapp", stamp);
  groupAppGroupId = await makeGroup("api_grpapp_grp", stamp);
  await pool.query(
    `INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`,
    [groupAppGroupId, groupGrantedAppId],
  );
  ({ id: groupAppGrantUserId } = await makeUser("api_grpapp_user", stamp));
  await pool.query(
    `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
    [groupAppGrantUserId, groupAppGroupId],
  );
});

after(async () => {
  if (createdAppIds.length) {
    await pool.query(
      `DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`,
      [createdAppIds],
    );
    await pool.query(
      `DELETE FROM group_apps WHERE app_id = ANY($1::int[])`,
      [createdAppIds],
    );
    await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [
      createdAppIds,
    ]);
  }
  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_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 group_apps WHERE group_id = ANY($1::int[])`,
      [createdGroupIds],
    );
    await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [
      createdGroupIds,
    ]);
  }
  if (createdPermissionIds.length) {
    await pool.query(
      `DELETE FROM role_permissions WHERE permission_id = ANY($1::int[])`,
      [createdPermissionIds],
    );
    await pool.query(
      `DELETE FROM app_permissions WHERE permission_id = ANY($1::int[])`,
      [createdPermissionIds],
    );
    await pool.query(`DELETE FROM permissions WHERE id = ANY($1::int[])`, [
      createdPermissionIds,
    ]);
  }
  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();
});

async function preview(appIdToCheck, permissionIds, cookie = adminCookie) {
  return fetch(
    `${API_BASE}/api/apps/${appIdToCheck}/permissions/impact-preview`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json", Cookie: cookie },
      body: JSON.stringify({ permissionIds }),
    },
  );
}

test("preview short-circuits with noChange=true when candidate matches current set", async () => {
  // Seed app with permA so the candidate exactly matches.
  await pool.query(
    `INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)
     ON CONFLICT DO NOTHING`,
    [appId, permA.id],
  );
  try {
    const res = await preview(appId, [permA.id]);
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.equal(body.noChange, true);
    assert.equal(body.affectedUserCount, 0);
    // currentlyVisibleUserCount must be the real audience size — not
    // hardcoded to 0 — so consumers reading a noChange response still
    // get an accurate count of how many users currently see the app.
    // At minimum userA, userAB, and userViaGroup hold permA and should
    // appear in the count.
    assert.ok(
      body.currentlyVisibleUserCount >= 3,
      `expected currentlyVisibleUserCount >= 3, got ${body.currentlyVisibleUserCount}`,
    );
    assert.deepEqual(body.groups, []);
  } finally {
    await pool.query(
      `DELETE FROM app_permissions WHERE app_id = $1 AND permission_id = $2`,
      [appId, permA.id],
    );
  }
});

test("preview reports affected users when adding a permission requirement to an unrestricted app", async () => {
  // App is currently unrestricted — every non-admin user can see it.
  // Candidate: require permB. Only userAB has permB.
  // Expected: at minimum, userA and userViaGroup lose access (they have
  // permA only). Other unrelated users in the DB also currently see the
  // app (since it's unrestricted), so we use a >= assertion to stay
  // robust against test data drift in the surrounding DB.
  const res = await preview(appId, [permB.id]);
  assert.equal(res.status, 200);
  const body = await res.json();
  assert.equal(body.noChange, false);
  // Sanity: a positive number of users currently see the unrestricted app
  // and at least the two test users without permB lose access.
  assert.ok(body.currentlyVisibleUserCount >= 3);
  assert.ok(
    body.affectedUserCount >= 2,
    `expected at least 2 affected, got ${body.affectedUserCount}`,
  );
  // userAB has permB so they keep access — affected must be strictly less
  // than currently visible.
  assert.ok(body.affectedUserCount < body.currentlyVisibleUserCount);
});

test("preview narrows affected users when candidate keeps a permission they already hold", async () => {
  // Seed the app with permA AND permB required (OR semantics: holding
  // either one grants visibility). Then ask "what if we remove permB?"
  // — which keeps permA. Only users who exclusively have permB lose
  // access, so userA and userViaGroup are unaffected (they still have
  // permA). userAB also keeps access via permA.
  await pool.query(
    `INSERT INTO app_permissions (app_id, permission_id) VALUES
       ($1, $2), ($1, $3) ON CONFLICT DO NOTHING`,
    [appId, permA.id, permB.id],
  );
  try {
    const res = await preview(appId, [permA.id]);
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.equal(body.noChange, false);
    // None of our scenario users should lose access since they all have
    // permA (or are in a group that grants permA). Other users in the
    // surrounding DB might lose access if they only had permB, but our
    // three tracked users must NOT.
    // We verify by sanity-checking that currentlyVisibleUserCount stays
    // sensible; the strict invariant we rely on is the comparison below.
    assert.ok(body.currentlyVisibleUserCount >= 3);
    // affected must be < currentlyVisibleUserCount because userA, userAB
    // and userViaGroup all keep access via permA.
    assert.ok(body.affectedUserCount < body.currentlyVisibleUserCount);
  } finally {
    await pool.query(
      `DELETE FROM app_permissions WHERE app_id = $1 AND permission_id IN ($2, $3)`,
      [appId, permA.id, permB.id],
    );
  }
});

test("preview surfaces groups that grant the app via group_apps and they offset the loss", async () => {
  // groupGrantedAppId is unrestricted and explicitly granted to
  // groupAppGroupId. Adding a requirement that no test user holds
  // (permC, only assigned to roleC, which nobody in our scenario has)
  // would otherwise hide the app from groupAppGrantUserId — but that
  // user is in a group that grants the app, so they keep access.
  const res = await preview(groupGrantedAppId, [permC.id]);
  assert.equal(res.status, 200);
  const body = await res.json();
  assert.equal(body.noChange, false);
  // Groups list must include the group that grants the app.
  const groupNames = body.groups.map((g) => g.id);
  assert.ok(
    groupNames.includes(groupAppGroupId),
    `expected groups to include ${groupAppGroupId}, got ${JSON.stringify(body.groups)}`,
  );
  // groupAppGrantUserId must NOT be counted as affected — they keep
  // access via the group_apps grant. We can't directly read the affected
  // user IDs (the endpoint only returns counts), but we can prove the
  // offset works by re-running the preview against an *identical app*
  // without the group-grant and confirming the count is strictly higher
  // by at least 1. We achieve this by inserting and removing a
  // group_apps row on the same app.
  await pool.query(
    `DELETE FROM group_apps WHERE group_id = $1 AND app_id = $2`,
    [groupAppGroupId, groupGrantedAppId],
  );
  try {
    const without = await preview(groupGrantedAppId, [permC.id]);
    assert.equal(without.status, 200);
    const wbody = await without.json();
    // Removing the group grant should INCREASE affectedUserCount by at
    // least 1 (groupAppGrantUserId now has no offset).
    assert.ok(
      wbody.affectedUserCount >= body.affectedUserCount + 1,
      `expected affected count to grow when group_apps offset is removed (with offset: ${body.affectedUserCount}, without: ${wbody.affectedUserCount})`,
    );
  } finally {
    // Restore the grant so other tests / cleanup remain happy.
    await pool.query(
      `INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)
       ON CONFLICT DO NOTHING`,
      [groupAppGroupId, groupGrantedAppId],
    );
  }
});

test("preview returns 404 for an unknown app", async () => {
  const max = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM apps`);
  const unknownId = max.rows[0].m + 9999;
  const res = await preview(unknownId, [permA.id]);
  assert.equal(res.status, 404);
});

test("preview returns 400 when permissionIds is missing or invalid", async () => {
  const noBody = await fetch(
    `${API_BASE}/api/apps/${appId}/permissions/impact-preview`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json", Cookie: adminCookie },
      body: JSON.stringify({}),
    },
  );
  assert.equal(noBody.status, 400);

  const wrong = await fetch(
    `${API_BASE}/api/apps/${appId}/permissions/impact-preview`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json", Cookie: adminCookie },
      body: JSON.stringify({ permissionIds: ["abc"] }),
    },
  );
  assert.equal(wrong.status, 400);
});

test("preview requires admin", async () => {
  const res = await preview(appId, [permA.id], consumerCookie);
  assert.ok(
    res.status === 401 || res.status === 403,
    `expected 401/403, got ${res.status}`,
  );
});
