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 adminCookie;
const createdRoleIds = [];
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="));
}

before(async () => {
  const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
  adminUsername = `role_audit_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, 'Role Audit 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],
  );

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

after(async () => {
  if (createdRoleIds.length) {
    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,
    ]);
  }
  for (const uid of createdUserIds) {
    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();
});

function uniqueRoleName(prefix) {
  const stamp = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
  return `${prefix}_${stamp}`;
}

async function createRole() {
  const res = await fetch(`${API_BASE}/api/roles`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ name: uniqueRoleName("role_audit") }),
  });
  assert.equal(res.status, 201);
  const body = await res.json();
  createdRoleIds.push(body.id);
  return body;
}

async function getTwoPermissionIds() {
  const rows = await pool.query(
    `SELECT id FROM permissions ORDER BY id LIMIT 2`,
  );
  assert.ok(rows.rowCount >= 2, "need at least 2 seeded permissions");
  return [rows.rows[0].id, rows.rows[1].id];
}

test("PUT /api/roles/:id/permissions writes a role_permission_audit row", async () => {
  const role = await createRole();
  const [p1, p2] = await getTwoPermissionIds();

  const before = await pool.query(
    `SELECT COUNT(*)::int AS c FROM role_permission_audit WHERE role_id = $1`,
    [role.id],
  );
  assert.equal(before.rows[0].c, 0);

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

  const dbRows = await pool.query(
    `SELECT actor_user_id, role_id, previous_permission_ids, new_permission_ids
       FROM role_permission_audit
      WHERE role_id = $1`,
    [role.id],
  );
  assert.equal(dbRows.rowCount, 1);
  const row = dbRows.rows[0];
  assert.equal(row.actor_user_id, adminId);
  assert.equal(row.role_id, role.id);
  assert.deepEqual(row.previous_permission_ids, []);
  assert.deepEqual(
    [...row.new_permission_ids].sort((a, b) => a - b),
    [p1, p2].sort((a, b) => a - b),
  );
});

test("PUT /api/roles/:id/permissions writes an audit row on every call (including no-op replaces)", async () => {
  const role = await createRole();
  const [p1, p2] = await getTwoPermissionIds();

  const first = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
    method: "PUT",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ permissionIds: [p1, p2] }),
  });
  assert.equal(first.status, 200);

  // Re-submit the same set in a different order — the resulting permission
  // set is identical, but the spec requires recording every PUT call.
  const second = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
    method: "PUT",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ permissionIds: [p2, p1] }),
  });
  assert.equal(second.status, 200);

  const dbRows = await pool.query(
    `SELECT previous_permission_ids, new_permission_ids
       FROM role_permission_audit
      WHERE role_id = $1
      ORDER BY id`,
    [role.id],
  );
  assert.equal(
    dbRows.rowCount,
    2,
    "every PUT call must append an audit row, including no-op saves",
  );
  const sorted = [p1, p2].sort((a, b) => a - b);
  // Second row's previous and new arrays must be identical to the first
  // row's "new" array, capturing the no-op save.
  assert.deepEqual([...dbRows.rows[1].previous_permission_ids].sort((a, b) => a - b), sorted);
  assert.deepEqual([...dbRows.rows[1].new_permission_ids].sort((a, b) => a - b), sorted);
});

test("GET /api/roles/:id/audit reports no-op saves with empty added/removed", async () => {
  const role = await createRole();
  const [p1] = await getTwoPermissionIds();

  for (const set of [[p1], [p1]]) {
    const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
      method: "PUT",
      headers: { "Content-Type": "application/json", Cookie: adminCookie },
      body: JSON.stringify({ permissionIds: set }),
    });
    assert.equal(r.status, 200);
    await new Promise((r) => setTimeout(r, 10));
  }

  const get = await fetch(`${API_BASE}/api/roles/${role.id}/audit`, {
    headers: { Cookie: adminCookie },
  });
  assert.equal(get.status, 200);
  const body = await get.json();
  const { entries } = body;
  assert.equal(entries.length, 2);
  assert.equal(body.totalCount, 2);
  assert.equal(body.offset, 0);
  assert.equal(body.nextOffset, null);
  // Latest entry is the no-op save: same set in/out, empty diffs.
  assert.deepEqual(entries[0].previousPermissionIds, [p1]);
  assert.deepEqual(entries[0].newPermissionIds, [p1]);
  assert.deepEqual(entries[0].addedPermissionIds, []);
  assert.deepEqual(entries[0].removedPermissionIds, []);
});

test("GET /api/roles/:id/audit returns entries newest first with diff fields and actor", async () => {
  const role = await createRole();
  const [p1, p2] = await getTwoPermissionIds();

  // First change: add p1
  const r1 = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
    method: "PUT",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ permissionIds: [p1] }),
  });
  assert.equal(r1.status, 200);

  // Ensure the second change has a strictly later created_at so ordering is
  // deterministic regardless of clock resolution.
  await new Promise((r) => setTimeout(r, 25));

  // Second change: swap p1 -> p2
  const r2 = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
    method: "PUT",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ permissionIds: [p2] }),
  });
  assert.equal(r2.status, 200);

  const get = await fetch(`${API_BASE}/api/roles/${role.id}/audit`, {
    headers: { Cookie: adminCookie },
  });
  assert.equal(get.status, 200);
  const body = await get.json();
  assert.ok(Array.isArray(body.entries));
  assert.equal(body.entries.length, 2);
  assert.equal(body.totalCount, 2);
  assert.equal(body.offset, 0);
  assert.equal(body.limit, 10);
  assert.equal(body.nextOffset, null);

  const [latest, earlier] = body.entries;
  assert.deepEqual(latest.previousPermissionIds, [p1]);
  assert.deepEqual(latest.newPermissionIds, [p2]);
  assert.deepEqual(latest.addedPermissionIds, [p2]);
  assert.deepEqual(latest.removedPermissionIds, [p1]);
  assert.ok(latest.actor);
  assert.equal(latest.actor.id, adminId);
  assert.equal(latest.actor.username, adminUsername);

  assert.deepEqual(earlier.previousPermissionIds, []);
  assert.deepEqual(earlier.newPermissionIds, [p1]);
  assert.deepEqual(earlier.addedPermissionIds, [p1]);
  assert.deepEqual(earlier.removedPermissionIds, []);
});

test("GET /api/roles/:id/audit returns 404 for an unknown role", async () => {
  const res = await fetch(`${API_BASE}/api/roles/99999999/audit`, {
    headers: { Cookie: adminCookie },
  });
  assert.equal(res.status, 404);
});

test("GET /api/roles/:id/audit honours the limit parameter", async () => {
  const role = await createRole();
  const [p1, p2] = await getTwoPermissionIds();

  const sets = [[p1], [p2], [p1, p2], []];
  for (const s of sets) {
    const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
      method: "PUT",
      headers: { "Content-Type": "application/json", Cookie: adminCookie },
      body: JSON.stringify({ permissionIds: s }),
    });
    assert.equal(r.status, 200);
    await new Promise((r) => setTimeout(r, 5));
  }

  const limited = await fetch(
    `${API_BASE}/api/roles/${role.id}/audit?limit=2`,
    { headers: { Cookie: adminCookie } },
  );
  assert.equal(limited.status, 200);
  const body = await limited.json();
  assert.equal(body.entries.length, 2);
  assert.equal(body.totalCount, 4);
  assert.equal(body.limit, 2);
  assert.equal(body.offset, 0);
  // Two more rows beyond the page → nextOffset should point at row 2.
  assert.equal(body.nextOffset, 2);
});

test("GET /api/roles/:id/audit paginates with offset and stops at the end", async () => {
  const role = await createRole();
  const [p1, p2] = await getTwoPermissionIds();

  // 4 distinct permission sets → 4 audit rows, deterministic ordering thanks
  // to the small inter-write delay.
  const sets = [[p1], [p2], [p1, p2], []];
  for (const s of sets) {
    const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
      method: "PUT",
      headers: { "Content-Type": "application/json", Cookie: adminCookie },
      body: JSON.stringify({ permissionIds: s }),
    });
    assert.equal(r.status, 200);
    await new Promise((r) => setTimeout(r, 5));
  }

  const page1Res = await fetch(
    `${API_BASE}/api/roles/${role.id}/audit?limit=2&offset=0`,
    { headers: { Cookie: adminCookie } },
  );
  assert.equal(page1Res.status, 200);
  const page1 = await page1Res.json();
  assert.equal(page1.entries.length, 2);
  assert.equal(page1.totalCount, 4);
  assert.equal(page1.nextOffset, 2);

  const page2Res = await fetch(
    `${API_BASE}/api/roles/${role.id}/audit?limit=2&offset=2`,
    { headers: { Cookie: adminCookie } },
  );
  assert.equal(page2Res.status, 200);
  const page2 = await page2Res.json();
  assert.equal(page2.entries.length, 2);
  assert.equal(page2.totalCount, 4);
  // Reached the end of the table — there is nothing more to page through.
  assert.equal(page2.nextOffset, null);

  // Pages must not overlap.
  const page1Ids = new Set(page1.entries.map((e) => e.id));
  for (const e of page2.entries) {
    assert.equal(page1Ids.has(e.id), false, "page1/page2 overlap");
  }
});

test("GET /api/roles/:id/audit filters by actorUserId", async () => {
  const role = await createRole();
  const [p1, p2] = await getTwoPermissionIds();

  // Create a SECOND admin so we can attribute audit rows to two different
  // actors and then verify that filtering returns only one of them.
  const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
  const otherUsername = `role_audit_admin_other_${stamp}`;
  const otherRow = await pool.query(
    `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
     VALUES ($1, $2, $3, 'Role Audit Admin Other', 'en', true) RETURNING id`,
    [otherUsername, `${otherUsername}@example.com`, TEST_PASSWORD_HASH],
  );
  const otherId = otherRow.rows[0].id;
  createdUserIds.push(otherId);
  await pool.query(
    `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
    [otherId],
  );
  const otherCookie = await loginAndGetCookie(otherUsername, TEST_PASSWORD);

  // Two writes by adminCookie + one by otherCookie.
  for (const [cookie, set] of [
    [adminCookie, [p1]],
    [otherCookie, [p1, p2]],
    [adminCookie, [p2]],
  ]) {
    const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
      method: "PUT",
      headers: { "Content-Type": "application/json", Cookie: cookie },
      body: JSON.stringify({ permissionIds: set }),
    });
    assert.equal(r.status, 200);
    await new Promise((r) => setTimeout(r, 5));
  }

  const filteredRes = await fetch(
    `${API_BASE}/api/roles/${role.id}/audit?actorUserId=${otherId}`,
    { headers: { Cookie: adminCookie } },
  );
  assert.equal(filteredRes.status, 200);
  const filtered = await filteredRes.json();
  assert.equal(filtered.totalCount, 1);
  assert.equal(filtered.entries.length, 1);
  assert.equal(filtered.entries[0].actor.id, otherId);

  // actorUserId=0 means "no filter" — should match all 3 rows.
  const allRes = await fetch(
    `${API_BASE}/api/roles/${role.id}/audit?actorUserId=0`,
    { headers: { Cookie: adminCookie } },
  );
  assert.equal(allRes.status, 200);
  const all = await allRes.json();
  assert.equal(all.totalCount, 3);
});

test("GET /api/roles/:id/audit filters by date range", async () => {
  const role = await createRole();
  const [p1, p2] = await getTwoPermissionIds();

  // Make a couple of writes "today" (UTC).
  for (const set of [[p1], [p2]]) {
    const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
      method: "PUT",
      headers: { "Content-Type": "application/json", Cookie: adminCookie },
      body: JSON.stringify({ permissionIds: set }),
    });
    assert.equal(r.status, 200);
    await new Promise((r) => setTimeout(r, 5));
  }

  const today = new Date().toISOString().slice(0, 10);
  const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
    .toISOString()
    .slice(0, 10);
  const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
    .toISOString()
    .slice(0, 10);

  // Today is in range → both rows should match.
  const inRangeRes = await fetch(
    `${API_BASE}/api/roles/${role.id}/audit?from=${today}&to=${today}`,
    { headers: { Cookie: adminCookie } },
  );
  assert.equal(inRangeRes.status, 200);
  const inRange = await inRangeRes.json();
  assert.equal(inRange.totalCount, 2);

  // Yesterday-only window → no rows.
  const pastRes = await fetch(
    `${API_BASE}/api/roles/${role.id}/audit?from=${yesterday}&to=${yesterday}`,
    { headers: { Cookie: adminCookie } },
  );
  assert.equal(pastRes.status, 200);
  const past = await pastRes.json();
  assert.equal(past.totalCount, 0);
  assert.equal(past.entries.length, 0);

  // Inverted range → 400.
  const badRes = await fetch(
    `${API_BASE}/api/roles/${role.id}/audit?from=${tomorrow}&to=${yesterday}`,
    { headers: { Cookie: adminCookie } },
  );
  assert.equal(badRes.status, 400);

  // Garbage date → 400.
  const garbageRes = await fetch(
    `${API_BASE}/api/roles/${role.id}/audit?from=not-a-date`,
    { headers: { Cookie: adminCookie } },
  );
  assert.equal(garbageRes.status, 400);

  // Impossible calendar days are rejected too — without strict validation
  // these would silently roll forward into a different real date.
  for (const bad of ["2024-02-31", "2025-13-01", "2025-00-15", "2025-02-30"]) {
    const r = await fetch(
      `${API_BASE}/api/roles/${role.id}/audit?from=${bad}`,
      { headers: { Cookie: adminCookie } },
    );
    assert.equal(r.status, 400, `expected 400 for from=${bad}`);
  }
});
