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 adminId;
let adminUsername;
let regularId;
let regularUsername;
let adminCookie;
let regularCookie;
let createdGroupIds = [];

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

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

  const a = await pool.query(
    `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
     VALUES ($1, $2, $3, 'Group Admin', 'en', true) RETURNING id`,
    [adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
  );
  adminId = a.rows[0].id;
  await pool.query(
    `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
    [adminId],
  );
  await pool.query(
    `INSERT INTO user_groups (user_id, group_id)
     SELECT $1, id FROM groups WHERE name IN ('Everyone', 'Admins')
     ON CONFLICT DO NOTHING`,
    [adminId],
  );

  const r = await pool.query(
    `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
     VALUES ($1, $2, $3, 'Group User', 'en', true) RETURNING id`,
    [regularUsername, `${regularUsername}@example.com`, TEST_PASSWORD_HASH],
  );
  regularId = r.rows[0].id;
  await pool.query(
    `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'user'`,
    [regularId],
  );
  await pool.query(
    `INSERT INTO user_groups (user_id, group_id)
     SELECT $1, id FROM groups WHERE name = 'Everyone'
     ON CONFLICT DO NOTHING`,
    [regularId],
  );

  adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
  regularCookie = await loginAndGetCookie(regularUsername, TEST_PASSWORD);
});

after(async () => {
  if (createdGroupIds.length) {
    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 group_roles WHERE group_id = ANY($1::int[])`,
      [createdGroupIds],
    );
    await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [
      createdGroupIds,
    ]);
  }
  for (const uid of [adminId, regularId]) {
    if (uid !== undefined) {
      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("GET /api/groups requires admin (regular user gets 403)", async () => {
  const res = await fetch(`${API_BASE}/api/groups`, {
    headers: { Cookie: regularCookie },
  });
  assert.equal(res.status, 403);
});

test("admin lists default seed groups including Admins, Tx, Everyone", async () => {
  const res = await fetch(`${API_BASE}/api/groups`, {
    headers: { Cookie: adminCookie },
  });
  assert.equal(res.status, 200);
  const list = await res.json();
  const names = new Set(list.map((g) => g.name));
  assert.ok(names.has("Admins"));
  assert.ok(names.has("Tx"));
  assert.ok(names.has("Everyone"));
});

test("admin creates a group, sees it, and assigns a member", async () => {
  const name = `TestGrp_${Date.now().toString(36)}`;
  const create = await fetch(`${API_BASE}/api/groups`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({
      name,
      descriptionEn: "Test group",
      userIds: [regularId],
    }),
  });
  assert.equal(create.status, 201);
  const created = await create.json();
  createdGroupIds.push(created.id);
  assert.equal(created.name, name);

  // Detail should include regularId
  const detailRes = await fetch(`${API_BASE}/api/groups/${created.id}`, {
    headers: { Cookie: adminCookie },
  });
  assert.equal(detailRes.status, 200);
  const detail = await detailRes.json();
  assert.ok(detail.userIds.includes(regularId));
});

test("creating a group with invalid userIds returns 400 and does not create rows", async () => {
  const before = await pool.query(`SELECT COUNT(*)::int AS c FROM groups`);
  const maxRow = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM users`);
  const badId = Number(maxRow.rows[0].m) + 100000;
  const res = await fetch(`${API_BASE}/api/groups`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ name: `Bad_${Date.now()}`, userIds: [badId] }),
  });
  assert.equal(res.status, 400);
  const after = await pool.query(`SELECT COUNT(*)::int AS c FROM groups`);
  assert.equal(after.rows[0].c, before.rows[0].c, "no group should be created");
});

test("admin role inherited via group_roles grants access to admin endpoints", async () => {
  // Create a group, attach the 'admin' role to it, add a fresh non-admin user
  const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
  const inheritedUsername = `gr_inherit_${stamp}`;
  const u = await pool.query(
    `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
     VALUES ($1, $2, $3, 'Inherited', 'en', true) RETURNING id`,
    [inheritedUsername, `${inheritedUsername}@example.com`, TEST_PASSWORD_HASH],
  );
  const inheritedId = u.rows[0].id;
  // No direct admin role; rely on group_roles
  await pool.query(
    `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'user'`,
    [inheritedId],
  );
  const grpName = `AdminInheritGrp_${stamp}`;
  const g = await pool.query(
    `INSERT INTO groups (name) VALUES ($1) RETURNING id`,
    [grpName],
  );
  const grpId = g.rows[0].id;
  createdGroupIds.push(grpId);
  await pool.query(
    `INSERT INTO group_roles (group_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
    [grpId],
  );
  await pool.query(
    `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
    [inheritedId, grpId],
  );

  const cookie = await loginAndGetCookie(inheritedUsername, TEST_PASSWORD);
  const res = await fetch(`${API_BASE}/api/groups`, { headers: { Cookie: cookie } });
  assert.equal(res.status, 200, "group-role-only user must be granted admin access");

  // Cleanup user
  await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [inheritedId]);
  await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [inheritedId]);
  await pool.query(`DELETE FROM users WHERE id = $1`, [inheritedId]);
});

test("deactivated admin session is denied on admin endpoints", async () => {
  const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
  const u = `deact_admin_${stamp}`;
  const r = await pool.query(
    `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
     VALUES ($1, $2, $3, 'Deact', 'en', true) RETURNING id`,
    [u, `${u}@example.com`, TEST_PASSWORD_HASH],
  );
  const uid = r.rows[0].id;
  await pool.query(
    `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
    [uid],
  );
  const cookie = await loginAndGetCookie(u, TEST_PASSWORD);
  const ok = await fetch(`${API_BASE}/api/groups`, { headers: { Cookie: cookie } });
  assert.equal(ok.status, 200);
  // Deactivate after login
  await pool.query(`UPDATE users SET is_active = false WHERE id = $1`, [uid]);
  const denied = await fetch(`${API_BASE}/api/groups`, { headers: { Cookie: cookie } });
  assert.equal(denied.status, 401, "inactive user must be rejected");
  await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]);
  await pool.query(`DELETE FROM users WHERE id = $1`, [uid]);
});

test("group create rolls back atomically when assignment fails", async () => {
  // Validation rejects bad userIds before any insert (validateAssignmentIds runs first),
  // so no `groups` row should be created and we should get a clean 400.
  const before = await pool.query(`SELECT COUNT(*)::int AS c FROM groups`);
  const maxRow = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM apps`);
  const badAppId = Number(maxRow.rows[0].m) + 100000;
  const name = `RollbackGrp_${Date.now().toString(36)}`;
  const res = await fetch(`${API_BASE}/api/groups`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ name, appIds: [badAppId] }),
  });
  assert.equal(res.status, 400);
  const after = await pool.query(`SELECT COUNT(*)::int AS c FROM groups`);
  assert.equal(after.rows[0].c, before.rows[0].c, "no group row should leak");
  const leaked = await pool.query(`SELECT id FROM groups WHERE name = $1`, [name]);
  assert.equal(leaked.rowCount, 0);
});

test("PATCH /api/groups/:id updates fields and replaces assignments transactionally", async () => {
  const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
  const create = await fetch(`${API_BASE}/api/groups`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ name: `UpdGrp_${stamp}`, descriptionEn: "before" }),
  });
  assert.equal(create.status, 201);
  const created = await create.json();
  createdGroupIds.push(created.id);

  const newName = `UpdGrpRenamed_${stamp}`;
  const patch = await fetch(`${API_BASE}/api/groups/${created.id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({
      name: newName,
      descriptionEn: "after",
      userIds: [adminId, regularId],
    }),
  });
  assert.equal(patch.status, 200);
  const updated = await patch.json();
  assert.equal(updated.name, newName);
  assert.equal(updated.descriptionEn, "after");
  assert.deepEqual(new Set(updated.userIds), new Set([adminId, regularId]));

  // Replace assignment with just the regular user; admin should be removed.
  const patch2 = await fetch(`${API_BASE}/api/groups/${created.id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ userIds: [regularId] }),
  });
  assert.equal(patch2.status, 200);
  const detail = await (
    await fetch(`${API_BASE}/api/groups/${created.id}`, { headers: { Cookie: adminCookie } })
  ).json();
  assert.deepEqual(detail.userIds, [regularId]);

  // Bad userId on update — must 400, must not partially apply (membership unchanged).
  const maxRow = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM users`);
  const badId = Number(maxRow.rows[0].m) + 100000;
  const patchBad = await fetch(`${API_BASE}/api/groups/${created.id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ userIds: [adminId, badId] }),
  });
  assert.equal(patchBad.status, 400);
  const detail2 = await (
    await fetch(`${API_BASE}/api/groups/${created.id}`, { headers: { Cookie: adminCookie } })
  ).json();
  assert.deepEqual(detail2.userIds, [regularId], "membership must be unchanged after rejected update");
});

test("creating/updating a group with invalid roleIds (or appIds) returns 400", async () => {
  const maxRoleRow = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM roles`);
  const maxAppRow = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM apps`);
  const badRoleId = Number(maxRoleRow.rows[0].m) + 100000;
  const badAppId = Number(maxAppRow.rows[0].m) + 100000;

  // CREATE with bad roleId — must 400 and create no row.
  const beforeCount = await pool.query(`SELECT COUNT(*)::int AS c FROM groups`);
  const createRes = await fetch(`${API_BASE}/api/groups`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ name: `BadRole_${Date.now()}`, roleIds: [badRoleId] }),
  });
  assert.equal(createRes.status, 400);
  const afterCount = await pool.query(`SELECT COUNT(*)::int AS c FROM groups`);
  assert.equal(afterCount.rows[0].c, beforeCount.rows[0].c);

  // Build a clean group, then PATCH with bad roleIds and bad appIds.
  const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
  const seedRes = await fetch(`${API_BASE}/api/groups`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ name: `BadRolePatch_${stamp}` }),
  });
  assert.equal(seedRes.status, 201);
  const seed = await seedRes.json();
  createdGroupIds.push(seed.id);

  const patchRoleRes = await fetch(`${API_BASE}/api/groups/${seed.id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ roleIds: [badRoleId] }),
  });
  assert.equal(patchRoleRes.status, 400);

  const patchAppRes = await fetch(`${API_BASE}/api/groups/${seed.id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ appIds: [badAppId] }),
  });
  assert.equal(patchAppRes.status, 400);

  // Confirm no role/app links leaked onto the seed group from rejected updates.
  const detail = await (
    await fetch(`${API_BASE}/api/groups/${seed.id}`, { headers: { Cookie: adminCookie } })
  ).json();
  assert.deepEqual(detail.roleIds, []);
  assert.deepEqual(detail.appIds, []);
});

test("DELETE /api/groups/:id removes a custom group; system groups are protected", async () => {
  const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
  const create = await fetch(`${API_BASE}/api/groups`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ name: `DelGrp_${stamp}` }),
  });
  assert.equal(create.status, 201);
  const created = await create.json();

  const del = await fetch(`${API_BASE}/api/groups/${created.id}`, {
    method: "DELETE",
    headers: { Cookie: adminCookie },
  });
  assert.equal(del.status, 204);
  const after = await pool.query(`SELECT id FROM groups WHERE id = $1`, [created.id]);
  assert.equal(after.rowCount, 0, "group row should be gone");

  // System-group guard: trying to delete Everyone must fail and leave row intact.
  const sysRow = await pool.query(
    `SELECT id FROM groups WHERE name = 'Everyone' AND is_system = 1`,
  );
  assert.ok(sysRow.rowCount > 0, "Everyone system group must exist");
  const everyoneId = sysRow.rows[0].id;
  const denied = await fetch(`${API_BASE}/api/groups/${everyoneId}`, {
    method: "DELETE",
    headers: { Cookie: adminCookie },
  });
  assert.equal(denied.status, 400);
  const stillThere = await pool.query(`SELECT id FROM groups WHERE id = $1`, [everyoneId]);
  assert.equal(stillThere.rowCount, 1, "system group must not be deleted");
});

test("/auth/register auto-assigns the new user to the Everyone system group", async () => {
  // Ensure registration is open for the duration of this test, then restore.
  const prior = await pool.query(
    `SELECT registration_open FROM app_settings WHERE id = 1`,
  );
  const priorOpen = prior.rows[0]?.registration_open;
  await pool.query(
    `INSERT INTO app_settings (id, registration_open) VALUES (1, true)
     ON CONFLICT (id) DO UPDATE SET registration_open = true`,
  );

  const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
  const username = `reg_everyone_${stamp}`;
  let newUserId;
  try {
    const res = await fetch(`${API_BASE}/api/auth/register`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        username,
        email: `${username}@example.com`,
        password: TEST_PASSWORD,
        displayNameEn: "Reg Everyone",
        preferredLanguage: "en",
      }),
    });
    assert.equal(res.status, 201, `register expected 201, got ${res.status}`);
    const body = await res.json();
    assert.ok(Array.isArray(body.groups), "auth user payload must include groups[]");
    assert.ok(
      body.groups.some((g) => g.name === "Everyone"),
      "register response must list Everyone in groups",
    );

    // Confirm at the DB level that the membership was actually written.
    const userRow = await pool.query(`SELECT id FROM users WHERE username = $1`, [username]);
    newUserId = userRow.rows[0]?.id;
    const linkRow = await pool.query(
      `SELECT 1 FROM user_groups ug
       JOIN groups g ON g.id = ug.group_id
       WHERE ug.user_id = $1 AND g.name = 'Everyone'`,
      [newUserId],
    );
    assert.equal(linkRow.rowCount, 1, "user_groups must link new user to Everyone");
  } finally {
    if (newUserId !== undefined) {
      await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [newUserId]);
      await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [newUserId]);
      await pool.query(`DELETE FROM users WHERE id = $1`, [newUserId]);
    }
    if (priorOpen !== undefined) {
      await pool.query(
        `UPDATE app_settings SET registration_open = $1 WHERE id = 1`,
        [priorOpen],
      );
    }
  }
});

test("GET /api/users supports q, groupId, and status filters", async () => {
  // q filter on partial username
  const qRes = await fetch(
    `${API_BASE}/api/users?q=${encodeURIComponent(regularUsername.slice(0, 8))}`,
    { headers: { Cookie: adminCookie } },
  );
  assert.equal(qRes.status, 200);
  const qList = await qRes.json();
  assert.ok(qList.some((u) => u.id === regularId));

  // status=active includes our active user
  const statusRes = await fetch(`${API_BASE}/api/users?status=active`, {
    headers: { Cookie: adminCookie },
  });
  const statusList = await statusRes.json();
  assert.ok(statusList.every((u) => u.isActive === true));

  // groupId filter — use Everyone (every user belongs)
  const groupsRes = await fetch(`${API_BASE}/api/groups`, {
    headers: { Cookie: adminCookie },
  });
  const groups = await groupsRes.json();
  const everyone = groups.find((g) => g.name === "Everyone");
  assert.ok(everyone, "Everyone group must exist");
  const grpRes = await fetch(`${API_BASE}/api/users?groupId=${everyone.id}`, {
    headers: { Cookie: adminCookie },
  });
  const grpList = await grpRes.json();
  assert.ok(grpList.some((u) => u.id === adminId));
});
