// Tests for — MR-H1 / MR-H2 / MR-M7.
//
// Coverage map (matches the "Required tests" list in the task spec):
//
//  Spec test 1 (non-owner / non-admin requests a referenced private
//              object)        → tests A + B below
//  Spec test 2 (owner reads own avatar streams body)
//                             → test C
//  Spec test 3 (admin reads any private object streams body)
//                             → test D
//  Spec test 4 (executive_viewer POST /pdf-archives → 403, no row)
//                             → test E + F
//  Spec test 5 (mutator POST /pdf-archives valid body → 201, row in DB)
//                             → test G
//  Regressions               → tests H, I, J, K
//
//  A. Unauthenticated GET /api/storage/objects/* → 401
//  B. Authed regular user GET of an executive-only PDF-archive object
//     path that they do NOT have access to → 404 (entity-lookup deny;
//     same status as a missing file to prevent existence enumeration).
//  C. Owner uploads via the presign flow, sets users.avatar_url, GETs
//     own avatar object → 200 + body bytes match what was uploaded.
//  D. Admin GETs the same avatar object → 200 + body bytes match.
//  E. POST /pdf-archives by executive_viewer → 403.
//  F. After the 403, the underlying executive_meeting_pdf_archives
//     table contains zero rows for the test archive_date — proves the
//     forbidden write did not partially execute.
//  G. POST /pdf-archives by mutator with a valid (date-only) body →
//     201 AND the new row is observable in the DB.
//  H. Authed user GET of an orphan / unknown object path → 404.
//  I. POST /pdf-archives by a mutator with an UNKNOWN body field
//     (e.g. caller-supplied filePath) → 400 (schema is .strict()).
//  J. GET /pdf-archives by executive_viewer → 200 (read endpoint not
//     accidentally locked behind requireMutate).
//  K. Admin GET of an orphan path → 404 (admin override does NOT
//     bypass the orphan guard, so admin still cannot enumerate
//     unreferenced objects in storage).

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!";
// bcrypt cost-10 hash of TEST_PASSWORD (matches the value used by the
// other executive-meetings test fixtures so we don't pay bcrypt cost
// per test seed).
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)}`;
const USERNAME_PREFIX = `objauthz_${STAMP}_`;
const ARCHIVE_DATE = "2099-12-31"; // far-future, cleanup key
const ORPHAN_ARCHIVE_PATH = `/objects/orphan-archive-${STAMP}`;

let receiverUserId; // order_receiver only — no executive role
let viewerUserId; // executive_viewer — read-only inside executive module
let mutatorUserId; // executive_office_manager — full mutator
let adminUserId; // admin — bypasses entity rules
let receiverCookie;
let viewerCookie;
let mutatorCookie;
let adminCookie;

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");
  assert.ok(setCookie, "login response missing set-cookie header");
  const cookie = setCookie
    .split(",")
    .map((c) => c.split(";")[0].trim())
    .find((c) => c.startsWith("connect.sid="));
  assert.ok(cookie, "no connect.sid cookie returned by login");
  return cookie;
}

async function seedUser(suffix, roleName) {
  const username = `${USERNAME_PREFIX}${suffix}`;
  const u = await pool.query(
    `INSERT INTO users (username, email, password_hash, display_name_en,
       preferred_language, is_active)
     VALUES ($1, $2, $3, 'ObjAuthz Test', 'en', true)
     RETURNING id`,
    [username, `${username}@ex.com`, TEST_PASSWORD_HASH],
  );
  const userId = u.rows[0].id;
  await pool.query(
    `INSERT INTO user_roles (user_id, role_id)
     SELECT $1, id FROM roles WHERE name = $2
     ON CONFLICT DO NOTHING`,
    [userId, roleName],
  );
  return { userId, username };
}

before(async () => {
  await pool.query(`DELETE FROM users WHERE username LIKE $1`, [
    `${USERNAME_PREFIX}%`,
  ]);
  await pool.query(
    `DELETE FROM executive_meeting_pdf_archives WHERE archive_date = $1`,
    [ARCHIVE_DATE],
  );
  await pool.query(
    `DELETE FROM executive_meeting_pdf_archives WHERE file_path = $1`,
    [ORPHAN_ARCHIVE_PATH],
  );

  const r = await seedUser("receiver", "order_receiver");
  receiverUserId = r.userId;
  receiverCookie = await login(r.username, TEST_PASSWORD);

  const v = await seedUser("viewer", "executive_viewer");
  viewerUserId = v.userId;
  viewerCookie = await login(v.username, TEST_PASSWORD);

  const m = await seedUser("mutator", "executive_office_manager");
  mutatorUserId = m.userId;
  mutatorCookie = await login(m.username, TEST_PASSWORD);

  const a = await seedUser("admin", "admin");
  adminUserId = a.userId;
  adminCookie = await login(a.username, TEST_PASSWORD);

  // Seed a PDF-archive row at a known executive-only path so the
  // non-owner referenced-object negative test (B) has a real entity to
  // hit (not just an orphan). Cleaned up in after().
  await pool.query(
    `INSERT INTO executive_meeting_pdf_archives
       (archive_date, file_path, version, generated_at)
     VALUES ($1, $2, 1, now())`,
    ["2098-01-02", ORPHAN_ARCHIVE_PATH],
  );
});

after(async () => {
  for (const id of [receiverUserId, viewerUserId, mutatorUserId, adminUserId]) {
    if (!id) continue;
    await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [id]);
    await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [id]);
  }
  await pool.query(
    `DELETE FROM executive_meeting_pdf_archives WHERE archive_date = $1`,
    [ARCHIVE_DATE],
  );
  await pool.query(
    `DELETE FROM executive_meeting_pdf_archives WHERE archive_date = $1`,
    ["2098-01-02"],
  );
  for (const id of [receiverUserId, viewerUserId, mutatorUserId, adminUserId]) {
    if (!id) continue;
    await pool.query(`DELETE FROM users WHERE id = $1`, [id]);
  }
  await pool.end();
});

// ---- A: unauthenticated boundary ----------------------------------
test("A: unauthenticated GET /api/storage/objects/* returns 401", async () => {
  const res = await fetch(`${API_BASE}/api/storage/objects/anything-uuid`);
  assert.equal(res.status, 401);
});

// ---- B: spec test 1 — non-owner requests a referenced private object
test("B: non-executive user GET of a PDF-archive-referenced object → 404 (entity-lookup deny)", async () => {
  // ORPHAN_ARCHIVE_PATH is referenced by the seeded archive row in
  // before(). receiverUserId has only `order_receiver` role (no
  // executive access), so canUserReadObjectPath must deny and the
  // route returns 404 BEFORE touching object storage. Body must NOT
  // stream.
  const path = ORPHAN_ARCHIVE_PATH.replace("/objects/", "");
  const res = await fetch(`${API_BASE}/api/storage/objects/${path}`, {
    headers: { cookie: receiverCookie },
  });
  assert.equal(res.status, 404, `expected 404, got ${res.status}`);
  // Verify the response body is the JSON error envelope (no streamed
  // file content). Without object-level authz the route would have
  // attempted to fetch the file from storage and either streamed it
  // or returned ObjectNotFoundError — the JSON envelope here proves
  // the deny short-circuit fired.
  const body = await res.json().catch(() => ({}));
  assert.equal(body.error, "Object not found");
});

// ---- C: spec test 2 — owner reads own avatar (body streams) -------
test("C: owner uploads + sets avatar, GETs own avatar object → 200 + body bytes match", async () => {
  // 1. Request a presigned upload URL as the receiver.
  const presignRes = await fetch(
    `${API_BASE}/api/storage/uploads/request-url`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json", cookie: receiverCookie },
      body: JSON.stringify({
        name: "avatar.bin",
        size: 11,
        contentType: "application/octet-stream",
      }),
    },
  );
  assert.equal(presignRes.status, 200, `presign expected 200, got ${presignRes.status}`);
  const { uploadURL, objectPath } = await presignRes.json();
  assert.ok(uploadURL.startsWith("http"), "uploadURL must be absolute");
  assert.ok(objectPath.startsWith("/objects/"), "objectPath must start with /objects/");

  // 2. PUT a known body to the presigned URL (this hits the active
  //    storage backend — local-FS driver in dev, S3/MinIO in production).
  const payload = "hello-world";
  const putRes = await fetch(uploadURL, {
    method: "PUT",
    headers: { "Content-Type": "application/octet-stream" },
    body: payload,
  });
  assert.ok(
    putRes.status >= 200 && putRes.status < 300,
    `PUT to presigned URL expected 2xx, got ${putRes.status}`,
  );

  // 3. Wire the uploaded path to the user's avatar_url so the
  //    entity-lookup helper resolves to "users.avatar_url match".
  await pool.query(`UPDATE users SET avatar_url = $1 WHERE id = $2`, [
    objectPath,
    receiverUserId,
  ]);

  try {
    // 4. As the owner, GET the object — must stream and bytes match.
    const path = objectPath.replace("/objects/", "");
    const getRes = await fetch(`${API_BASE}/api/storage/objects/${path}`, {
      headers: { cookie: receiverCookie },
    });
    assert.equal(getRes.status, 200, `expected 200, got ${getRes.status}`);
    const body = await getRes.text();
    assert.equal(body, payload, "streamed body must match uploaded payload");

    // 5. Sub-check: admin can ALSO read it (spec test 3 piggybacks on
    //    the same uploaded fixture so we don't double the upload cost).
    const adminGetRes = await fetch(
      `${API_BASE}/api/storage/objects/${path}`,
      { headers: { cookie: adminCookie } },
    );
    assert.equal(
      adminGetRes.status,
      200,
      `admin expected 200, got ${adminGetRes.status}`,
    );
    const adminBody = await adminGetRes.text();
    assert.equal(adminBody, payload, "admin streamed body must match");
  } finally {
    await pool.query(`UPDATE users SET avatar_url = NULL WHERE id = $1`, [
      receiverUserId,
    ]);
  }
});

// ---- D: spec test 3 — admin reads non-referenced is still denied --
test("D: admin GET of an orphan object path → 404 (admin does NOT bypass orphan guard)", async () => {
  const res = await fetch(
    `${API_BASE}/api/storage/objects/orphan-${STAMP}-admin-uuid`,
    { headers: { cookie: adminCookie } },
  );
  assert.equal(res.status, 404, `expected 404, got ${res.status}`);
});

// ---- E + F: spec test 4 — viewer POST → 403, NO row inserted ------
test("E+F: POST /pdf-archives by executive_viewer → 403 AND no row inserted", async () => {
  const res = await fetch(
    `${API_BASE}/api/executive-meetings/pdf-archives`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json", cookie: viewerCookie },
      body: JSON.stringify({ archiveDate: ARCHIVE_DATE }),
    },
  );
  assert.equal(res.status, 403, `expected 403, got ${res.status}`);
  const body = await res.json().catch(() => ({}));
  assert.equal(body.code, "forbidden");

  // Direct DB assertion: requireMutate must reject BEFORE the insert
  // runs. If the gate ever regresses (e.g. someone re-orders middleware),
  // this row check will fail loudly.
  const rows = await pool.query(
    `SELECT id FROM executive_meeting_pdf_archives WHERE archive_date = $1`,
    [ARCHIVE_DATE],
  );
  assert.equal(rows.rowCount, 0, "no archive row may be inserted on a 403");
});

// ---- G: spec test 5 — mutator POST valid → 201 + row in DB --------
test("G: POST /pdf-archives by mutator with valid body → 201 AND row exists in DB", async () => {
  const res = await fetch(
    `${API_BASE}/api/executive-meetings/pdf-archives`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json", cookie: mutatorCookie },
      body: JSON.stringify({ archiveDate: ARCHIVE_DATE }),
    },
  );
  assert.equal(res.status, 201, `expected 201, got ${res.status}`);
  const body = await res.json();
  assert.equal(body.archiveDate, ARCHIVE_DATE);
  // MR-H2: filePath is server-derived. Caller has no influence over it.
  assert.equal(body.filePath, `print:${ARCHIVE_DATE}`);

  const rows = await pool.query(
    `SELECT file_path FROM executive_meeting_pdf_archives WHERE archive_date = $1`,
    [ARCHIVE_DATE],
  );
  assert.equal(rows.rowCount, 1, "exactly one archive row must exist");
  assert.equal(rows.rows[0].file_path, `print:${ARCHIVE_DATE}`);
});

// ---- H: regression — orphan from a non-admin authed user ----------
test("H: authed user GET of an orphan object path returns 404", async () => {
  const res = await fetch(
    `${API_BASE}/api/storage/objects/orphan-${STAMP}-uuid`,
    { headers: { cookie: receiverCookie } },
  );
  assert.equal(res.status, 404);
});

// ---- I: MR-H2 — caller-supplied filePath is rejected by .strict() -
test("I: POST /pdf-archives with an unknown body field (filePath) → 400", async () => {
  const res = await fetch(
    `${API_BASE}/api/executive-meetings/pdf-archives`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json", cookie: mutatorCookie },
      body: JSON.stringify({
        archiveDate: ARCHIVE_DATE,
        // MR-H2: any caller-supplied filePath — even one that matches
        // /objects/<id> regex — must be rejected so executive writers
        // cannot point archive rows at arbitrary observed object paths.
        filePath: "/objects/some-other-uuid",
      }),
    },
  );
  assert.equal(res.status, 400, `expected 400, got ${res.status}`);
});

// ---- J: regression — read endpoint still works for read-only role -
test("J: GET /executive-meetings/pdf-archives by executive_viewer → 200", async () => {
  const res = await fetch(
    `${API_BASE}/api/executive-meetings/pdf-archives?date=${ARCHIVE_DATE}`,
    { headers: { cookie: viewerCookie } },
  );
  assert.equal(res.status, 200);
  const body = await res.json();
  assert.ok(Array.isArray(body.archives), "expected archives array");
  const ours = body.archives.find(
    (a) => a.filePath === `print:${ARCHIVE_DATE}`,
  );
  assert.ok(ours, "the seeded archive row must be visible to executive_viewer");
});

// ---- L: app-icon authz mirrors launcher RBAC ----------------------
test("L: app-icon object: user with app permission streams body, restricted user 404", async () => {
  // Upload a real icon, attach it to a freshly-created app, then
  // restrict the app via a unique permission. Only users granted
  // that permission (here: admin) should see the icon stream.
  const presignRes = await fetch(
    `${API_BASE}/api/storage/uploads/request-url`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json", cookie: adminCookie },
      body: JSON.stringify({
        name: "app-icon.bin",
        size: 8,
        contentType: "application/octet-stream",
      }),
    },
  );
  assert.equal(presignRes.status, 200);
  const { uploadURL, objectPath } = await presignRes.json();
  const payload = "iconbyt";
  const putRes = await fetch(uploadURL, {
    method: "PUT",
    headers: { "Content-Type": "application/octet-stream" },
    body: payload,
  });
  assert.ok(putRes.status >= 200 && putRes.status < 300);

  const appSlug = `obj_authz_app_${STAMP}`;
  const permName = `obj_authz_perm_${STAMP}`;
  const appRow = await pool.query(
    `INSERT INTO apps (slug, name_en, name_ar, route, image_url)
     VALUES ($1, $2, $3, $4, $5)
     RETURNING id`,
    [appSlug, appSlug, appSlug, `/${appSlug}`, objectPath],
  );
  const appId = appRow.rows[0].id;
  const permRow = await pool.query(
    `INSERT INTO permissions (name) VALUES ($1) RETURNING id`,
    [permName],
  );
  const permId = permRow.rows[0].id;
  await pool.query(
    `INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)`,
    [appId, permId],
  );

  try {
    const tail = objectPath.replace("/objects/", "");

    // Admin — sees every app via getVisibleAppsForUser short-circuit.
    const allow = await fetch(`${API_BASE}/api/storage/objects/${tail}`, {
      headers: { cookie: adminCookie },
    });
    assert.equal(allow.status, 200, `admin expected 200, got ${allow.status}`);
    assert.equal(await allow.text(), payload, "admin should stream the icon bytes");

    // Receiver — has no role granting permName, no group_apps row.
    // Must be denied at authz → 404.
    const deny = await fetch(`${API_BASE}/api/storage/objects/${tail}`, {
      headers: { cookie: receiverCookie },
    });
    assert.equal(deny.status, 404, `receiver expected 404, got ${deny.status}`);
  } finally {
    await pool.query(`DELETE FROM app_permissions WHERE app_id = $1`, [appId]);
    await pool.query(`DELETE FROM permissions WHERE id = $1`, [permId]);
    await pool.query(`DELETE FROM apps WHERE id = $1`, [appId]);
  }
});

// ---- K: positive + negative on the SAME real brand-logo object ----
test("K: brand-logo object: executive_viewer streams body 200, non-executive 404", async () => {
  // Upload a real file as admin (any authed can presign), wire it to
  // the singleton font_settings.logo_object_path, then assert the
  // allowed and denied roles diverge on body delivery for the SAME
  // existing object — proving the divergence is from authz, not from
  // missing-file behavior.
  const presignRes = await fetch(
    `${API_BASE}/api/storage/uploads/request-url`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json", cookie: adminCookie },
      body: JSON.stringify({
        name: "brand-logo.bin",
        size: 9,
        contentType: "application/octet-stream",
      }),
    },
  );
  assert.equal(presignRes.status, 200);
  const { uploadURL, objectPath } = await presignRes.json();
  const payload = "logo-data";
  const putRes = await fetch(uploadURL, {
    method: "PUT",
    headers: { "Content-Type": "application/octet-stream" },
    body: payload,
  });
  assert.ok(putRes.status >= 200 && putRes.status < 300);

  // Capture and restore the previous logo path so we don't pollute
  // the singleton row for other tests / dev usage.
  const prev = await pool.query(
    `SELECT logo_object_path FROM executive_meeting_font_settings LIMIT 1`,
  );
  const prevPath =
    prev.rows.length > 0 ? prev.rows[0].logo_object_path : null;
  await pool.query(
    `UPDATE executive_meeting_font_settings SET logo_object_path = $1`,
    [objectPath],
  );

  try {
    const tail = objectPath.replace("/objects/", "");

    // Executive viewer (read-only inside executive module) — allowed
    // → reaches storage → streams the body bytes.
    const allow = await fetch(`${API_BASE}/api/storage/objects/${tail}`, {
      headers: { cookie: viewerCookie } },
    );
    assert.equal(allow.status, 200, `viewer expected 200, got ${allow.status}`);
    assert.equal(await allow.text(), payload, "viewer should stream the bytes");

    // Order-receiver (no executive role) — denied at authz → 404
    // with the JSON error envelope. The file IS in storage; the
    // divergence here proves the authz branch is what changes the
    // outcome, not file presence.
    const deny = await fetch(`${API_BASE}/api/storage/objects/${tail}`, {
      headers: { cookie: receiverCookie } },
    );
    assert.equal(deny.status, 404, `receiver expected 404, got ${deny.status}`);
    const denyBody = await deny.json().catch(() => ({}));
    assert.equal(denyBody.error, "Object not found");
  } finally {
    await pool.query(
      `UPDATE executive_meeting_font_settings SET logo_object_path = $1`,
      [prevPath],
    );
  }
});
