// True unit test for the 404/410 cleanup path in sendPushToUser.
//
// Runs in-process via `tsx` so we can `mock.module('web-push', ...)` to
// stub the actual delivery call. We:
//   1. Insert a real row in `push_subscriptions` for a freshly-created
//      throwaway user.
//   2. Force the mocked `webpush.sendNotification` to throw a 410 once
//      (and 404 once) the way the FCM/APNs bridges do.
//   3. Call `sendPushToUser` directly and assert the row was pruned.
//
// This is the closest we can get to exercising the production prune
// path without standing up a real push gateway, and it exercises the
// real DB + real lib code (only `web-push` and the socket-presence
// check are stubbed).
//
// Run with:
//   pnpm --filter @workspace/api-server exec \
//     node --import tsx --test tests/push-410-unit.test.ts
import { test, before, after, mock } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";

const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
  throw new Error("DATABASE_URL must be set to run these tests");
}

// Track sendNotification calls and let each test rig the next response.
const sendCalls: Array<{ endpoint: string; payload: string }> = [];
let nextThrow: { statusCode: number } | null = null;

mock.module("web-push", {
  defaultExport: {
    setVapidDetails: () => {},
    generateVAPIDKeys: () => ({
      publicKey: "B".padEnd(87, "A"),
      privateKey: "C".padEnd(43, "A"),
    }),
    sendNotification: async (sub: { endpoint: string }, payload: string) => {
      sendCalls.push({ endpoint: sub.endpoint, payload });
      if (nextThrow) {
        const err: Error & { statusCode?: number } = new Error("stubbed");
        err.statusCode = nextThrow.statusCode;
        nextThrow = null;
        throw err;
      }
      return { statusCode: 201, body: "", headers: {} };
    },
  },
});

// Block the socket-presence dedup gate from short-circuiting the send.
// `sendPushToUser` lazy-imports `../index.js` to read `io`; throwing
// here makes `isUserConnected` swallow the error and return false.
mock.module("../src/index.js", {
  namedExports: {
    get io() {
      throw new Error("io not available in unit test");
    },
  },
});

// Ensure VAPID is configured (the lib has its own fallback, but being
// explicit avoids surprise re-keying between runs).
process.env.VAPID_PUBLIC_KEY =
  process.env.VAPID_PUBLIC_KEY ?? "B".padEnd(87, "A");
process.env.VAPID_PRIVATE_KEY =
  process.env.VAPID_PRIVATE_KEY ?? "C".padEnd(43, "A");
process.env.VAPID_SUBJECT = process.env.VAPID_SUBJECT ?? "mailto:test@example.com";

const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdUserIds: number[] = [];

async function createUser(prefix: string): Promise<number> {
  const username = `${prefix}_${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 8)}`;
  const { rows } = await pool.query(
    `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
     VALUES ($1, $2, '$2b$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $3, 'en', true)
     RETURNING id`,
    [username, `${username}@example.com`, prefix],
  );
  const id = rows[0].id as number;
  createdUserIds.push(id);
  return id;
}

async function insertSub(userId: number, endpoint: string): Promise<void> {
  await pool.query(
    `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth)
     VALUES ($1, $2, 'p256dh_test', 'auth_test')`,
    [userId, endpoint],
  );
}

async function rowExists(endpoint: string): Promise<boolean> {
  const r = await pool.query(
    `SELECT 1 FROM push_subscriptions WHERE endpoint = $1`,
    [endpoint],
  );
  return (r.rowCount ?? 0) > 0;
}

// Import AFTER mocks are registered.
const { sendPushToUser } = await import("../src/lib/push.js");

before(() => {
  // Touch sendCalls so the import is exercised.
  sendCalls.length = 0;
});

after(async () => {
  if (createdUserIds.length > 0) {
    await pool.query(
      `DELETE FROM push_subscriptions WHERE user_id = ANY($1::int[])`,
      [createdUserIds],
    );
    await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
      createdUserIds,
    ]);
  }
  await pool.end();
});

test("410 Gone response prunes the stale push subscription", async () => {
  const userId = await createUser("p410");
  const endpoint = `https://push.example.test/410-${Date.now()}`;
  await insertSub(userId, endpoint);
  assert.equal(await rowExists(endpoint), true);

  nextThrow = { statusCode: 410 };
  await sendPushToUser(userId, {
    title: "t",
    body: "b",
    type: "note",
  });

  assert.equal(sendCalls.at(-1)?.endpoint, endpoint, "stub should have been called");
  assert.equal(
    await rowExists(endpoint),
    false,
    "row must be pruned after a 410 response",
  );
});

test("404 Not Found response prunes the stale push subscription", async () => {
  const userId = await createUser("p404");
  const endpoint = `https://push.example.test/404-${Date.now()}`;
  await insertSub(userId, endpoint);
  assert.equal(await rowExists(endpoint), true);

  nextThrow = { statusCode: 404 };
  await sendPushToUser(userId, {
    title: "t",
    body: "b",
    type: "note",
  });

  assert.equal(
    await rowExists(endpoint),
    false,
    "row must be pruned after a 404 response",
  );
});

test("non-410/404 errors leave the subscription intact", async () => {
  const userId = await createUser("p500");
  const endpoint = `https://push.example.test/500-${Date.now()}`;
  await insertSub(userId, endpoint);

  nextThrow = { statusCode: 500 };
  await sendPushToUser(userId, {
    title: "t",
    body: "b",
    type: "note",
  });

  assert.equal(
    await rowExists(endpoint),
    true,
    "transient 5xx must NOT delete the subscription",
  );
});

test("notificationsMuted=true silences every channel", async () => {
  const userId = await createUser("pmute");
  const endpoint = `https://push.example.test/mute-${Date.now()}`;
  await insertSub(userId, endpoint);
  await pool.query(
    `UPDATE users SET notifications_muted = true WHERE id = $1`,
    [userId],
  );

  const before = sendCalls.length;
  await sendPushToUser(userId, {
    title: "t",
    body: "b",
    type: "note",
  });

  assert.equal(
    sendCalls.length,
    before,
    "muted user must not trigger sendNotification at all",
  );
  assert.equal(
    await rowExists(endpoint),
    true,
    "muting must not delete the subscription",
  );
});

test("per-channel flag off skips that channel only", async () => {
  const userId = await createUser("pchan");
  const endpoint = `https://push.example.test/chan-${Date.now()}`;
  await insertSub(userId, endpoint);
  await pool.query(
    `UPDATE users SET notify_notes_enabled = false WHERE id = $1`,
    [userId],
  );

  const before = sendCalls.length;
  await sendPushToUser(userId, {
    title: "t",
    body: "b",
    type: "note",
  });
  assert.equal(
    sendCalls.length,
    before,
    "notes-disabled user must not receive a note push",
  );

  // Same user, different channel — order push must still fire.
  await sendPushToUser(userId, {
    title: "t",
    body: "b",
    type: "order",
  });
  assert.equal(
    sendCalls.length,
    before + 1,
    "order channel must still fire when only notes is off",
  );
});

test("oversized body is truncated so the wire payload stays under 4096 bytes", async () => {
  const userId = await createUser("ptrunc");
  const endpoint = `https://push.example.test/trunc-${Date.now()}`;
  await insertSub(userId, endpoint);

  // 8000 bytes of ASCII — comfortably above the 3500-byte budget and
  // way past the 4096-byte web-push cap, so truncation MUST kick in.
  const huge = "x".repeat(8000);
  await sendPushToUser(userId, {
    title: "t",
    body: huge,
    type: "note",
  });

  const sent = sendCalls.at(-1);
  assert.ok(sent, "send stub should have been called");
  const size = Buffer.byteLength(sent.payload, "utf8");
  assert.ok(
    size < 4096,
    `truncated payload must fit under 4096 bytes, got ${size}`,
  );
  const parsed = JSON.parse(sent.payload) as { body: string };
  assert.ok(
    parsed.body.length < huge.length,
    "body must have been shortened",
  );
});

test("preferredLanguage=en picks the English title/body", async () => {
  const userId = await createUser("plangen");
  await pool.query(
    `UPDATE users SET preferred_language = 'en' WHERE id = $1`,
    [userId],
  );
  const endpoint = `https://push.example.test/langen-${Date.now()}`;
  await insertSub(userId, endpoint);

  await sendPushToUser(userId, {
    title: "ar-fallback",
    body: "ar-fallback",
    titleAr: "ملاحظة جديدة",
    titleEn: "New note",
    bodyAr: "أرسل لك ملاحظة",
    bodyEn: "sent you a note",
    type: "note",
  });

  const sent = sendCalls.at(-1);
  assert.ok(sent, "send stub should have been called");
  const parsed = JSON.parse(sent.payload) as { title: string; body: string };
  assert.equal(parsed.title, "New note");
  assert.equal(parsed.body, "sent you a note");
});

test("preferredLanguage=ar picks the Arabic title/body", async () => {
  const userId = await createUser("plangar");
  // Default is already 'ar' but be explicit so the test documents intent.
  await pool.query(
    `UPDATE users SET preferred_language = 'ar' WHERE id = $1`,
    [userId],
  );
  const endpoint = `https://push.example.test/langar-${Date.now()}`;
  await insertSub(userId, endpoint);

  await sendPushToUser(userId, {
    title: "ignored",
    body: "ignored",
    titleAr: "ملاحظة جديدة",
    titleEn: "New note",
    bodyAr: "أرسل لك ملاحظة",
    bodyEn: "sent you a note",
    type: "note",
  });

  const sent = sendCalls.at(-1);
  assert.ok(sent, "send stub should have been called");
  const parsed = JSON.parse(sent.payload) as { title: string; body: string };
  assert.equal(parsed.title, "ملاحظة جديدة");
  assert.equal(parsed.body, "أرسل لك ملاحظة");
});
