// Verifies that every sensitive admin action that the API claims to audit
// actually writes the expected `audit_logs` row. Each test exercises a
// single endpoint, then queries the table and asserts:
//   - exactly one matching row exists
//   - action, actor_user_id, target_type, target_id are correct
//   - the metadata blob has the documented shape
//
// The suite owns its own admin user, so `actor_user_id = adminId` is enough
// to tell our rows apart from other rows produced by parallel suites.

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 STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;

let adminId;
let adminUsername;
let adminCookie;
const createdUserIds = [];
const createdGroupIds = [];
const createdRoleIds = [];
const createdAppIds = [];

let originalSettings = null;

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

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

async function insertUser(prefix) {
  const username = uniqueName(prefix).toLowerCase();
  const r = await pool.query(
    `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
     VALUES ($1, $2, $3, 'Audit Coverage', 'en', true) RETURNING id`,
    [username, `${username}@example.com`, TEST_PASSWORD_HASH],
  );
  const id = r.rows[0].id;
  createdUserIds.push(id);
  // Always membership in Everyone so the seed-default trigger / FK behavior matches prod.
  await pool.query(
    `INSERT INTO user_groups (user_id, group_id)
     SELECT $1, id FROM groups WHERE name = 'Everyone'
     ON CONFLICT DO NOTHING`,
    [id],
  );
  return { id, username };
}

async function insertApp(slugPrefix) {
  const slug = uniqueName(slugPrefix).toLowerCase();
  const r = await pool.query(
    `INSERT INTO apps (slug, name_ar, name_en, route, sort_order)
     VALUES ($1, 'تطبيق تدقيق', 'Audit App', '/x', 999) RETURNING id`,
    [slug],
  );
  const id = r.rows[0].id;
  createdAppIds.push(id);
  return { id, slug };
}

async function insertGroup(prefix) {
  const name = uniqueName(prefix);
  const r = await pool.query(
    `INSERT INTO groups (name, description_en) VALUES ($1, 'audit coverage group') RETURNING id`,
    [name],
  );
  const id = r.rows[0].id;
  createdGroupIds.push(id);
  return { id, name };
}

async function insertRole(prefix) {
  const name = uniqueName(prefix).toLowerCase();
  const r = await pool.query(
    `INSERT INTO roles (name, description_en) VALUES ($1, 'audit coverage role') RETURNING id`,
    [name],
  );
  const id = r.rows[0].id;
  createdRoleIds.push(id);
  return { id, name };
}

async function fetchAuditRows({ action, targetId, targetType }) {
  const rows = await pool.query(
    `SELECT id, actor_user_id, action, target_type, target_id, metadata, created_at
       FROM audit_logs
      WHERE actor_user_id = $1
        AND action = $2
        AND target_id = $3
        AND target_type = $4
      ORDER BY id`,
    [adminId, action, targetId, targetType],
  );
  return rows.rows;
}

before(async () => {
  adminUsername = `audit_cov_admin_${STAMP}`;
  const a = await pool.query(
    `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
     VALUES ($1, $2, $3, 'Audit Coverage Admin', 'en', true) RETURNING id`,
    [adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
  );
  adminId = a.rows[0].id;
  createdUserIds.push(adminId);
  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],
  );

  // Capture the current settings row so we can restore exactly what was
  // there before this suite mutated it (settings.update is global state,
  // shared with the rest of the app).
  const s = await pool.query(`SELECT * FROM app_settings WHERE id = 1`);
  if (s.rowCount === 1) {
    originalSettings = s.rows[0];
  }

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

after(async () => {
  // Wipe audit rows we created so they don't contaminate the global table.
  await pool.query(`DELETE FROM audit_logs WHERE actor_user_id = $1`, [
    adminId,
  ]);

  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 role_permission_audit 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 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,
    ]);
  }
  if (createdAppIds.length) {
    await pool.query(`DELETE FROM app_opens WHERE app_id = ANY($1::int[])`, [
      createdAppIds,
    ]);
    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 user_app_orders WHERE app_id = ANY($1::int[])`,
      [createdAppIds],
    );
    await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [
      createdAppIds,
    ]);
  }
  for (const uid of createdUserIds) {
    if (uid !== undefined) {
      await pool.query(`DELETE FROM password_reset_tokens WHERE user_id = $1`, [
        uid,
      ]);
      // Drop messages and conversations the user created/sent — the 409
      // user-delete test deliberately seeds these and the FK to users
      // is RESTRICT, so they must go before the user row can be removed.
      await pool.query(`DELETE FROM messages WHERE sender_id = $1`, [uid]);
      await pool.query(
        `DELETE FROM messages WHERE conversation_id IN (SELECT id FROM conversations WHERE created_by = $1)`,
        [uid],
      );
      await pool.query(`DELETE FROM conversations WHERE created_by = $1`, [
        uid,
      ]);
      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]);
    }
  }

  // Restore settings exactly as captured (clears any change rows we wrote).
  if (originalSettings) {
    await pool.query(
      `UPDATE app_settings
          SET site_name_ar = $1,
              site_name_en = $2,
              registration_open = $3,
              footer_text_ar = $4,
              footer_text_en = $5
        WHERE id = 1`,
      [
        originalSettings.site_name_ar,
        originalSettings.site_name_en,
        originalSettings.registration_open,
        originalSettings.footer_text_ar,
        originalSettings.footer_text_en,
      ],
    );
  }

  await pool.end();
});

// ---------- users ----------

test("DELETE /api/users/:id (no-force, no deps) writes one user.delete row with force=false", async () => {
  const { id, username } = await insertUser("u_nf");
  const email = `${username}@example.com`;

  const res = await fetch(`${API_BASE}/api/users/${id}`, {
    method: "DELETE",
    headers: { Cookie: adminCookie },
  });
  assert.equal(res.status, 204);

  const rows = await fetchAuditRows({
    action: "user.delete",
    targetId: id,
    targetType: "user",
  });
  assert.equal(rows.length, 1, "expected exactly one user.delete audit row");
  const row = rows[0];
  assert.equal(row.actor_user_id, adminId);
  assert.equal(row.metadata.username, username);
  assert.equal(row.metadata.email, email);
  assert.equal(row.metadata.force, false);
  // No-deps path must NOT include the dependency counts.
  assert.equal(row.metadata.noteCount, undefined);
  assert.equal(row.metadata.orderCount, undefined);
  assert.equal(row.metadata.conversationCount, undefined);
  assert.equal(row.metadata.messageCount, undefined);
});

test("DELETE /api/users/:id without force on a user with deps returns 409 and writes NO audit row", async () => {
  const { id } = await insertUser("u_409");
  // Give the user a dependency that blocks delete.
  const conv = await pool.query(
    `INSERT INTO conversations (created_by, is_group) VALUES ($1, true) RETURNING id`,
    [id],
  );
  await pool.query(
    `INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
    [conv.rows[0].id, id],
  );

  const res = await fetch(`${API_BASE}/api/users/${id}`, {
    method: "DELETE",
    headers: { Cookie: adminCookie },
  });
  assert.equal(res.status, 409);

  const stillThere = await pool.query(`SELECT id FROM users WHERE id = $1`, [
    id,
  ]);
  assert.equal(stillThere.rowCount, 1, "user must still exist after 409");

  const rows = await fetchAuditRows({
    action: "user.delete",
    targetId: id,
    targetType: "user",
  });
  assert.equal(
    rows.length,
    0,
    "denied delete (409) must NOT emit a user.delete audit row",
  );
});

test("DELETE /api/users/:id?force=true writes one user.delete row with force=true and dep counts", async () => {
  const { id, username } = await insertUser("u_f");

  // Add a dependency so the force branch is taken meaningfully.
  const conv = await pool.query(
    `INSERT INTO conversations (created_by, is_group) VALUES ($1, true) RETURNING id`,
    [id],
  );
  await pool.query(
    `INSERT INTO messages (conversation_id, sender_id, content) VALUES ($1, $2, 'hi')`,
    [conv.rows[0].id, id],
  );

  const res = await fetch(`${API_BASE}/api/users/${id}?force=true`, {
    method: "DELETE",
    headers: { Cookie: adminCookie },
  });
  assert.equal(res.status, 204);

  const rows = await fetchAuditRows({
    action: "user.delete",
    targetId: id,
    targetType: "user",
  });
  assert.equal(rows.length, 1);
  const row = rows[0];
  assert.equal(row.actor_user_id, adminId);
  assert.equal(row.metadata.username, username);
  assert.equal(row.metadata.force, true);
  // Force-with-deps path records the counts.
  assert.equal(typeof row.metadata.conversationCount, "number");
  assert.equal(typeof row.metadata.messageCount, "number");
  assert.ok(row.metadata.conversationCount >= 1);
  assert.ok(row.metadata.messageCount >= 1);
});

// ---------- roles ----------

test("POST /api/roles writes one role.create row", async () => {
  const name = uniqueName("aud_role_c").toLowerCase();
  const res = await fetch(`${API_BASE}/api/roles`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({
      name,
      descriptionEn: "audit role create",
      descriptionAr: "تدقيق إنشاء الدور",
    }),
  });
  assert.equal(res.status, 201);
  const created = await res.json();
  createdRoleIds.push(created.id);

  const rows = await fetchAuditRows({
    action: "role.create",
    targetId: created.id,
    targetType: "role",
  });
  assert.equal(rows.length, 1);
  assert.equal(rows[0].actor_user_id, adminId);
  assert.equal(rows[0].metadata.name, name);
  assert.equal(rows[0].metadata.descriptionEn, "audit role create");
  assert.equal(rows[0].metadata.descriptionAr, "تدقيق إنشاء الدور");
});

test("PATCH /api/roles/:id (rename) writes one role.update row marked renamed", async () => {
  const role = await insertRole("aud_role_u");
  const newName = uniqueName("aud_role_u_new").toLowerCase();

  const res = await fetch(`${API_BASE}/api/roles/${role.id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ name: newName, descriptionEn: "renamed" }),
  });
  assert.equal(res.status, 200);

  const rows = await fetchAuditRows({
    action: "role.update",
    targetId: role.id,
    targetType: "role",
  });
  assert.equal(rows.length, 1);
  const row = rows[0];
  assert.equal(row.actor_user_id, adminId);
  assert.equal(row.metadata.previousName, role.name);
  assert.equal(row.metadata.name, newName);
  assert.equal(row.metadata.renamed, true);
  assert.equal(row.metadata.renamedFrom, role.name);
  assert.equal(row.metadata.renamedTo, newName);
  assert.ok(row.metadata.changes, "metadata.changes must be present");
  assert.equal(row.metadata.changes.name, newName);
  assert.equal(row.metadata.changes.descriptionEn, "renamed");
});

test("PATCH /api/roles/:id with no effective changes does NOT write an audit row", async () => {
  const role = await insertRole("aud_role_noop");

  const res = await fetch(`${API_BASE}/api/roles/${role.id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ name: role.name }),
  });
  assert.equal(res.status, 200);

  const rows = await fetchAuditRows({
    action: "role.update",
    targetId: role.id,
    targetType: "role",
  });
  assert.equal(rows.length, 0, "no-op patch must not emit role.update");
});

test("DELETE /api/roles/:id (no users/groups) writes one role.delete row", async () => {
  const role = await insertRole("aud_role_d");

  const res = await fetch(`${API_BASE}/api/roles/${role.id}`, {
    method: "DELETE",
    headers: { Cookie: adminCookie },
  });
  assert.equal(res.status, 204);

  const rows = await fetchAuditRows({
    action: "role.delete",
    targetId: role.id,
    targetType: "role",
  });
  assert.equal(rows.length, 1);
  assert.equal(rows[0].actor_user_id, adminId);
  assert.equal(rows[0].metadata.name, role.name);
});

// ---------- groups ----------

test("POST /api/groups writes one group.create row with size counts", async () => {
  const name = uniqueName("aud_grp_c");
  const member = await insertUser("aud_grp_member");

  const res = await fetch(`${API_BASE}/api/groups`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({
      name,
      descriptionEn: "audit group create",
      userIds: [member.id],
      appIds: [],
      roleIds: [],
    }),
  });
  assert.equal(res.status, 201);
  const created = await res.json();
  createdGroupIds.push(created.id);

  const rows = await fetchAuditRows({
    action: "group.create",
    targetId: created.id,
    targetType: "group",
  });
  assert.equal(rows.length, 1);
  assert.equal(rows[0].actor_user_id, adminId);
  assert.equal(rows[0].metadata.name, name);
  assert.equal(rows[0].metadata.memberCount, 1);
  assert.equal(rows[0].metadata.appCount, 0);
  assert.equal(rows[0].metadata.roleCount, 0);
});

test("PATCH /api/groups/:id (members + apps + roles) writes one aggregated group.update row with diffs", async () => {
  const group = await insertGroup("aud_grp_u");
  const userOld = await insertUser("aud_grp_uOld");
  const userNew = await insertUser("aud_grp_uNew");
  const appOld = await insertApp("aud_grp_aOld");
  const appNew = await insertApp("aud_grp_aNew");
  const roleOld = await insertRole("aud_grp_rOld");
  const roleNew = await insertRole("aud_grp_rNew");

  // Seed the "previous" associations directly so we can predict the diff.
  await pool.query(
    `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
    [userOld.id, group.id],
  );
  await pool.query(
    `INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`,
    [group.id, appOld.id],
  );
  await pool.query(
    `INSERT INTO group_roles (group_id, role_id) VALUES ($1, $2)`,
    [group.id, roleOld.id],
  );

  const res = await fetch(`${API_BASE}/api/groups/${group.id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({
      userIds: [userNew.id],
      appIds: [appNew.id],
      roleIds: [roleNew.id],
    }),
  });
  assert.equal(res.status, 200);

  const rows = await fetchAuditRows({
    action: "group.update",
    targetId: group.id,
    targetType: "group",
  });
  assert.equal(rows.length, 1, "aggregate PATCH must emit a single group.update row");
  const meta = rows[0].metadata;
  assert.equal(meta.name, group.name);
  // Each diff is { added: number[], removed: number[] }
  assert.deepEqual(meta.members.added, [userNew.id]);
  assert.deepEqual(meta.members.removed, [userOld.id]);
  assert.deepEqual(meta.apps.added, [appNew.id]);
  assert.deepEqual(meta.apps.removed, [appOld.id]);
  assert.deepEqual(meta.roles.added, [roleNew.id]);
  assert.deepEqual(meta.roles.removed, [roleOld.id]);
});

test("PATCH /api/groups/:id with no diffs does NOT write group.update", async () => {
  const group = await insertGroup("aud_grp_noop");

  const res = await fetch(`${API_BASE}/api/groups/${group.id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({}),
  });
  assert.equal(res.status, 200);

  const rows = await fetchAuditRows({
    action: "group.update",
    targetId: group.id,
    targetType: "group",
  });
  assert.equal(rows.length, 0);
});

test("POST /api/groups/:id/users/:targetId writes one group.user.add row with username", async () => {
  const group = await insertGroup("aud_sub_uadd");
  const user = await insertUser("aud_sub_uadd_user");

  const res = await fetch(
    `${API_BASE}/api/groups/${group.id}/users/${user.id}`,
    { method: "POST", headers: { Cookie: adminCookie } },
  );
  assert.equal(res.status, 204);

  const rows = await fetchAuditRows({
    action: "group.user.add",
    targetId: group.id,
    targetType: "group",
  });
  assert.equal(rows.length, 1);
  assert.equal(rows[0].metadata.groupName, group.name);
  assert.equal(rows[0].metadata.userId, user.id);
  assert.equal(rows[0].metadata.username, user.username);
});

test("POST then DELETE /api/groups/:id/users/:targetId writes one add row and one group.user.remove row", async () => {
  const group = await insertGroup("aud_sub_uremove");
  const user = await insertUser("aud_sub_uremove_user");

  const add = await fetch(
    `${API_BASE}/api/groups/${group.id}/users/${user.id}`,
    { method: "POST", headers: { Cookie: adminCookie } },
  );
  assert.equal(add.status, 204);

  const addRows = await fetchAuditRows({
    action: "group.user.add",
    targetId: group.id,
    targetType: "group",
  });
  assert.equal(addRows.length, 1);

  const rm = await fetch(
    `${API_BASE}/api/groups/${group.id}/users/${user.id}`,
    { method: "DELETE", headers: { Cookie: adminCookie } },
  );
  assert.equal(rm.status, 204);

  const rmRows = await fetchAuditRows({
    action: "group.user.remove",
    targetId: group.id,
    targetType: "group",
  });
  assert.equal(rmRows.length, 1);
  assert.equal(rmRows[0].actor_user_id, adminId);
  assert.equal(rmRows[0].target_type, "group");
  assert.equal(rmRows[0].target_id, group.id);
  assert.equal(rmRows[0].metadata.groupName, group.name);
  assert.equal(rmRows[0].metadata.userId, user.id);
  assert.equal(rmRows[0].metadata.username, user.username);
});

test("POST then DELETE /api/groups/:id/apps/:targetId writes one add row and one remove row", async () => {
  const group = await insertGroup("aud_sub_app");
  const app = await insertApp("aud_sub_app_app");

  const add = await fetch(`${API_BASE}/api/groups/${group.id}/apps/${app.id}`, {
    method: "POST",
    headers: { Cookie: adminCookie },
  });
  assert.equal(add.status, 204);

  const addRows = await fetchAuditRows({
    action: "group.app.add",
    targetId: group.id,
    targetType: "group",
  });
  assert.equal(addRows.length, 1);
  assert.equal(addRows[0].metadata.groupName, group.name);
  assert.equal(addRows[0].metadata.appId, app.id);
  assert.equal(addRows[0].metadata.appSlug, app.slug);

  const rm = await fetch(`${API_BASE}/api/groups/${group.id}/apps/${app.id}`, {
    method: "DELETE",
    headers: { Cookie: adminCookie },
  });
  assert.equal(rm.status, 204);

  const rmRows = await fetchAuditRows({
    action: "group.app.remove",
    targetId: group.id,
    targetType: "group",
  });
  assert.equal(rmRows.length, 1);
  assert.equal(rmRows[0].metadata.appId, app.id);
  assert.equal(rmRows[0].metadata.appSlug, app.slug);
});

test("POST then DELETE /api/groups/:id/roles/:targetId writes one add row and one remove row", async () => {
  const group = await insertGroup("aud_sub_role");
  const role = await insertRole("aud_sub_role_role");

  const add = await fetch(
    `${API_BASE}/api/groups/${group.id}/roles/${role.id}`,
    { method: "POST", headers: { Cookie: adminCookie } },
  );
  assert.equal(add.status, 204);

  const addRows = await fetchAuditRows({
    action: "group.role.add",
    targetId: group.id,
    targetType: "group",
  });
  assert.equal(addRows.length, 1);
  assert.equal(addRows[0].metadata.roleId, role.id);
  assert.equal(addRows[0].metadata.roleName, role.name);

  const rm = await fetch(
    `${API_BASE}/api/groups/${group.id}/roles/${role.id}`,
    { method: "DELETE", headers: { Cookie: adminCookie } },
  );
  assert.equal(rm.status, 204);

  const rmRows = await fetchAuditRows({
    action: "group.role.remove",
    targetId: group.id,
    targetType: "group",
  });
  assert.equal(rmRows.length, 1);
  assert.equal(rmRows[0].metadata.roleName, role.name);
});

test("DELETE /api/groups/:id (empty group, no force) writes group.delete with force=false and zero counts", async () => {
  const group = await insertGroup("aud_grp_d_nf");

  const res = await fetch(`${API_BASE}/api/groups/${group.id}`, {
    method: "DELETE",
    headers: { Cookie: adminCookie },
  });
  assert.equal(res.status, 204);

  const rows = await fetchAuditRows({
    action: "group.delete",
    targetId: group.id,
    targetType: "group",
  });
  assert.equal(rows.length, 1);
  assert.equal(rows[0].actor_user_id, adminId);
  assert.equal(rows[0].metadata.name, group.name);
  assert.equal(rows[0].metadata.force, false);
  assert.equal(rows[0].metadata.memberCount, 0);
  assert.equal(rows[0].metadata.appCount, 0);
  assert.equal(rows[0].metadata.roleCount, 0);
});

test("DELETE /api/groups/:id without force on a non-empty group returns 409 and writes NO audit row", async () => {
  const group = await insertGroup("aud_grp_409");
  const member = await insertUser("aud_grp_409_member");
  await pool.query(
    `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
    [member.id, group.id],
  );

  const res = await fetch(`${API_BASE}/api/groups/${group.id}`, {
    method: "DELETE",
    headers: { Cookie: adminCookie },
  });
  assert.equal(res.status, 409);

  const stillThere = await pool.query(`SELECT id FROM groups WHERE id = $1`, [
    group.id,
  ]);
  assert.equal(stillThere.rowCount, 1, "group must still exist after 409");

  const rows = await fetchAuditRows({
    action: "group.delete",
    targetId: group.id,
    targetType: "group",
  });
  assert.equal(
    rows.length,
    0,
    "denied delete (409) must NOT emit a group.delete audit row",
  );
});

test("DELETE /api/groups/:id?force=true (with members) writes one group.delete with force=true and counts", async () => {
  const group = await insertGroup("aud_grp_d_f");
  const member = await insertUser("aud_grp_d_f_member");
  await pool.query(
    `INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
    [member.id, group.id],
  );

  const res = await fetch(`${API_BASE}/api/groups/${group.id}?force=true`, {
    method: "DELETE",
    headers: { Cookie: adminCookie },
  });
  assert.equal(res.status, 204);

  const rows = await fetchAuditRows({
    action: "group.delete",
    targetId: group.id,
    targetType: "group",
  });
  assert.equal(rows.length, 1);
  assert.equal(rows[0].metadata.force, true);
  assert.equal(rows[0].metadata.memberCount, 1);
});

// ---------- apps ----------

test("POST /api/apps writes one app.create row", async () => {
  const slug = uniqueName("aud_app_c").toLowerCase();
  const res = await fetch(`${API_BASE}/api/apps`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({
      slug,
      nameAr: "تطبيق",
      nameEn: "Audit Create App",
      iconName: "Box",
      route: "/aud-c",
      color: "#fff",
    }),
  });
  assert.equal(res.status, 201);
  const created = await res.json();
  createdAppIds.push(created.id);

  const rows = await fetchAuditRows({
    action: "app.create",
    targetId: created.id,
    targetType: "app",
  });
  assert.equal(rows.length, 1);
  assert.equal(rows[0].metadata.slug, slug);
  assert.equal(rows[0].metadata.nameEn, "Audit Create App");
  assert.equal(rows[0].metadata.route, "/aud-c");
});

test("PATCH /api/apps/:id (real change) writes one app.update row with from/to changes", async () => {
  const app = await insertApp("aud_app_u");

  const res = await fetch(`${API_BASE}/api/apps/${app.id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ nameEn: "Audit App Renamed" }),
  });
  assert.equal(res.status, 200);

  const rows = await fetchAuditRows({
    action: "app.update",
    targetId: app.id,
    targetType: "app",
  });
  assert.equal(rows.length, 1);
  assert.equal(rows[0].metadata.slug, app.slug);
  assert.equal(rows[0].metadata.changes.nameEn.from, "Audit App");
  assert.equal(rows[0].metadata.changes.nameEn.to, "Audit App Renamed");
});

test("PATCH /api/apps/:id with same value does NOT write app.update", async () => {
  const app = await insertApp("aud_app_noop");

  const res = await fetch(`${API_BASE}/api/apps/${app.id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ nameEn: "Audit App" }),
  });
  assert.equal(res.status, 200);

  const rows = await fetchAuditRows({
    action: "app.update",
    targetId: app.id,
    targetType: "app",
  });
  assert.equal(rows.length, 0);
});

test("DELETE /api/apps/:id (no deps) writes app.delete with force=false and zero counts", async () => {
  const app = await insertApp("aud_app_d_nf");

  const res = await fetch(`${API_BASE}/api/apps/${app.id}`, {
    method: "DELETE",
    headers: { Cookie: adminCookie },
  });
  assert.equal(res.status, 204);

  const rows = await fetchAuditRows({
    action: "app.delete",
    targetId: app.id,
    targetType: "app",
  });
  assert.equal(rows.length, 1);
  assert.equal(rows[0].metadata.slug, app.slug);
  assert.equal(rows[0].metadata.force, false);
  assert.equal(rows[0].metadata.groupCount, 0);
  assert.equal(rows[0].metadata.restrictionCount, 0);
  assert.equal(rows[0].metadata.openCount, 0);
});

test("DELETE /api/apps/:id without force on an app with deps returns 409 and writes NO audit row", async () => {
  const app = await insertApp("aud_app_409");
  const group = await insertGroup("aud_app_409_grp");
  await pool.query(
    `INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`,
    [group.id, app.id],
  );

  const res = await fetch(`${API_BASE}/api/apps/${app.id}`, {
    method: "DELETE",
    headers: { Cookie: adminCookie },
  });
  assert.equal(res.status, 409);

  const stillThere = await pool.query(`SELECT id FROM apps WHERE id = $1`, [
    app.id,
  ]);
  assert.equal(stillThere.rowCount, 1, "app must still exist after 409");

  const rows = await fetchAuditRows({
    action: "app.delete",
    targetId: app.id,
    targetType: "app",
  });
  assert.equal(
    rows.length,
    0,
    "denied delete (409) must NOT emit an app.delete audit row",
  );
});

test("DELETE /api/apps/:id?force=true (with deps) writes app.delete with force=true and dep counts", async () => {
  const app = await insertApp("aud_app_d_f");
  const group = await insertGroup("aud_app_d_f_grp");
  await pool.query(
    `INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`,
    [group.id, app.id],
  );
  await pool.query(`INSERT INTO app_opens (user_id, app_id) VALUES ($1, $2)`, [
    adminId,
    app.id,
  ]);

  const res = await fetch(`${API_BASE}/api/apps/${app.id}?force=true`, {
    method: "DELETE",
    headers: { Cookie: adminCookie },
  });
  assert.equal(res.status, 204);

  const rows = await fetchAuditRows({
    action: "app.delete",
    targetId: app.id,
    targetType: "app",
  });
  assert.equal(rows.length, 1);
  assert.equal(rows[0].metadata.force, true);
  assert.equal(rows[0].metadata.groupCount, 1);
  assert.equal(rows[0].metadata.openCount, 1);
});

// ---------- auth: password reset link ----------

test("POST /api/auth/admin/users/:id/issue-reset-link writes one auth.issue_reset_link row", async () => {
  const target = await insertUser("aud_reset");

  const res = await fetch(
    `${API_BASE}/api/auth/admin/users/${target.id}/issue-reset-link`,
    { method: "POST", headers: { Cookie: adminCookie } },
  );
  assert.equal(res.status, 200);
  const body = await res.json();
  assert.ok(body.resetUrl);
  assert.ok(body.expiresAt);

  const rows = await fetchAuditRows({
    action: "auth.issue_reset_link",
    targetId: target.id,
    targetType: "user",
  });
  assert.equal(rows.length, 1);
  assert.equal(rows[0].actor_user_id, adminId);
  assert.equal(rows[0].metadata.username, target.username);
  assert.equal(rows[0].metadata.email, `${target.username}@example.com`);
  assert.equal(rows[0].metadata.expiresAt, body.expiresAt);
});

// ---------- settings ----------

test("PATCH /api/settings with a real change writes one settings.update row", async () => {
  // Read current value, change it, observe the audit row, then restore.
  const before = await pool.query(
    `SELECT site_name_en FROM app_settings WHERE id = 1`,
  );
  const previous = before.rows[0]?.site_name_en ?? "Tx OS";
  const next = `Audit ${STAMP}`;

  const res = await fetch(`${API_BASE}/api/settings`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ siteNameEn: next }),
  });
  assert.equal(res.status, 200);

  const rows = await fetchAuditRows({
    action: "settings.update",
    targetId: 1,
    targetType: "settings",
  });
  assert.equal(rows.length, 1, "exactly one settings.update row for a real change");
  assert.equal(rows[0].actor_user_id, adminId);
  assert.equal(rows[0].metadata.changes.siteNameEn.from, previous);
  assert.equal(rows[0].metadata.changes.siteNameEn.to, next);

  // Clean up the audit row produced by this test so the next test starts
  // from zero settings.update rows for our admin actor.
  await pool.query(
    `DELETE FROM audit_logs WHERE actor_user_id = $1 AND action = 'settings.update'`,
    [adminId],
  );

  // Restore the original value via the API; that emits another audit row,
  // which we also clean up so a later run is unaffected.
  const restore = await fetch(`${API_BASE}/api/settings`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ siteNameEn: previous }),
  });
  assert.equal(restore.status, 200);
  await pool.query(
    `DELETE FROM audit_logs WHERE actor_user_id = $1 AND action = 'settings.update'`,
    [adminId],
  );
});

test("PATCH /api/settings with no effective change does NOT write settings.update", async () => {
  const before = await pool.query(
    `SELECT site_name_en FROM app_settings WHERE id = 1`,
  );
  const current = before.rows[0]?.site_name_en ?? "Tx OS";

  // PATCHing with the value that's already there must be a no-op.
  const res = await fetch(`${API_BASE}/api/settings`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ siteNameEn: current }),
  });
  assert.equal(res.status, 200);

  const rows = await fetchAuditRows({
    action: "settings.update",
    targetId: 1,
    targetType: "settings",
  });
  assert.equal(
    rows.length,
    0,
    "no-op settings PATCH must not emit a settings.update audit row",
  );
});
