// node --experimental-strip-types --test
import { test } from "node:test";
import assert from "node:assert/strict";
import {
  APPROVAL_COLLAPSE_ID,
  adoptLiveActivityPayload,
  apnsJwt,
  approvalPayload,
  clearPayload,
  liveActivityPayload,
  reconnectPayload,
  sendApns,
  sendApnsAdopt,
  sendApnsClear,
  sendApnsReconnect,
  sendLiveActivityApns,
  type ApnsConfig,
} from "../src/apns.ts";

function toPem(der: ArrayBuffer): string {
  const b64 = Buffer.from(new Uint8Array(der)).toString("base64").replace(/(.{64})/g, "$1\n");
  return `-----BEGIN PRIVATE KEY-----\n${b64}\n-----END PRIVATE KEY-----\n`;
}

async function freshKey() {
  const kp = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
  const pkcs8 = await crypto.subtle.exportKey("pkcs8", kp.privateKey);
  return { pem: toPem(pkcs8), publicKey: kp.publicKey };
}

const T0 = 1_767_322_245_000;

test("apnsJwt is a verifiable ES256 token with the right claims", async () => {
  const { pem, publicKey } = await freshKey();
  const cfg: ApnsConfig = {
    keyP8: pem, keyId: "ABCDE12345", teamId: "L7UDTQ6F5W", topic: "xyz.blakeslee.nucleic.remote", env: "sandbox",
  };
  const jwt = await apnsJwt(cfg, T0, {}); // fresh cache
  const [h, c, s] = jwt.split(".");
  const header = JSON.parse(Buffer.from(h, "base64url").toString());
  const claims = JSON.parse(Buffer.from(c, "base64url").toString());

  assert.equal(header.alg, "ES256");
  assert.equal(header.kid, "ABCDE12345");
  assert.equal(claims.iss, "L7UDTQ6F5W");
  assert.equal(claims.iat, Math.floor(T0 / 1000));

  const ok = await crypto.subtle.verify(
    { name: "ECDSA", hash: "SHA-256" },
    publicKey,
    Buffer.from(s, "base64url"),
    Buffer.from(`${h}.${c}`),
  );
  assert.equal(ok, true);
});

test("apnsJwt reuses a cached token within the window", async () => {
  const { pem } = await freshKey();
  const cfg: ApnsConfig = { keyP8: pem, keyId: "K", teamId: "T", topic: "xyz.blakeslee.nucleic.remote", env: "sandbox" };
  const cache = {};
  const a = await apnsJwt(cfg, T0, cache);
  const b = await apnsJwt(cfg, T0 + 10 * 60 * 1000, cache); // +10 min < 40 min
  assert.equal(a, b);
});

test("approvalPayload carries no content", () => {
  const p = approvalPayload();
  const aps = (p as { aps: Record<string, unknown> }).aps;
  assert.equal(aps["interruption-level"], "time-sensitive");
  assert.equal(/transcript|diff|prompt|secret/.test(JSON.stringify(p)), false);
});

test("approvalPayload picks the loc-key from the block category", () => {
  const locKey = (p: Record<string, unknown>) =>
    ((p.aps as { alert: { "loc-key": string } }).alert)["loc-key"];
  // Default (a tool approval) waits for an approval; a question waits for an answer.
  assert.equal(locKey(approvalPayload()), "approval.pending");
  assert.equal(locKey(approvalPayload(false)), "approval.pending");
  assert.equal(locKey(approvalPayload(true)), "question.pending");
  // Still content-free either way.
  assert.equal(/transcript|diff|prompt|secret/.test(JSON.stringify(approvalPayload(true))), false);
});

test("sendApns posts to the sandbox host with APNS headers", async () => {
  const { pem } = await freshKey();
  const cfg: ApnsConfig = {
    keyP8: pem, keyId: "ABCDE12345", teamId: "L7UDTQ6F5W", topic: "xyz.blakeslee.nucleic.remote", env: "sandbox",
  };
  let captured: { url: string; headers: Record<string, string> } | null = null;
  const fakeFetch = (async (url: string, init: { headers: Record<string, string> }) => {
    captured = { url, headers: init.headers };
    return new Response(null, { status: 200, headers: { "apns-id": "xyz" } });
  }) as unknown as typeof fetch;

  const r = await sendApns(cfg, "devtoken", approvalPayload(), T0, fakeFetch);
  assert.equal(r.status, 200);
  assert.equal(r.id, "xyz");
  assert.ok(captured);
  assert.match(captured!.url, /^https:\/\/api\.sandbox\.push\.apple\.com\/3\/device\/devtoken$/);
  assert.equal(captured!.headers["apns-topic"], "xyz.blakeslee.nucleic.remote");
  assert.equal(captured!.headers["apns-push-type"], "alert");
  // The tickle rides the shared collapse-id so wakes coalesce and stay recallable.
  assert.equal(captured!.headers["apns-collapse-id"], APPROVAL_COLLAPSE_ID);
  assert.match(captured!.headers["authorization"], /^bearer /);
});

test("clearPayload is a content-free silent background push", () => {
  const p = clearPayload() as { aps: Record<string, unknown>; clear?: unknown };
  assert.equal(p.aps["content-available"], 1);
  assert.equal(p.aps.alert, undefined);
  assert.equal(p.aps.sound, undefined);
  assert.equal(p.clear, 1);
  assert.equal(/transcript|diff|prompt|secret/.test(JSON.stringify(p)), false);
});

test("sendApnsClear posts a background push at priority 5 on the tickle's collapse-id", async () => {
  const { pem } = await freshKey();
  const cfg: ApnsConfig = {
    keyP8: pem, keyId: "ABCDE12345", teamId: "L7UDTQ6F5W", topic: "xyz.blakeslee.nucleic.remote", env: "production",
  };
  let captured: { url: string; headers: Record<string, string> } | null = null;
  const fakeFetch = (async (url: string, init: { headers: Record<string, string> }) => {
    captured = { url, headers: init.headers };
    return new Response(null, { status: 200, headers: { "apns-id": "clr" } });
  }) as unknown as typeof fetch;

  const r = await sendApnsClear(cfg, "devtoken", clearPayload(), T0, fakeFetch);
  assert.equal(r.status, 200);
  assert.match(captured!.url, /^https:\/\/api\.push\.apple\.com\/3\/device\/devtoken$/);
  assert.equal(captured!.headers["apns-topic"], "xyz.blakeslee.nucleic.remote");
  assert.equal(captured!.headers["apns-push-type"], "background");
  assert.equal(captured!.headers["apns-priority"], "5");
  assert.equal(captured!.headers["apns-collapse-id"], APPROVAL_COLLAPSE_ID);
});

test("liveActivityPayload carries the content-state for an update, dismissal for an end", () => {
  const update = liveActivityPayload("update", { runningCount: 2, needsYouCount: 1 }, T0) as {
    aps: Record<string, unknown>;
  };
  assert.equal(update.aps.event, "update");
  assert.equal(update.aps.timestamp, Math.floor(T0 / 1000));
  assert.deepEqual(update.aps["content-state"], { runningCount: 2, needsYouCount: 1 });

  const end = liveActivityPayload("end", null, T0) as { aps: Record<string, unknown> };
  assert.equal(end.aps.event, "end");
  assert.equal(end.aps["dismissal-date"], Math.floor(T0 / 1000));
  assert.equal(end.aps["content-state"], undefined);

  // A "Done" end carries the final glance and lingers a few seconds before dismissing.
  const doneEnd = liveActivityPayload("end", { runningCount: 0, needsYouCount: 0 }, T0) as {
    aps: Record<string, unknown>;
  };
  assert.equal(doneEnd.aps.event, "end");
  assert.deepEqual(doneEnd.aps["content-state"], { runningCount: 0, needsYouCount: 0 });
  assert.equal(doneEnd.aps["dismissal-date"], Math.floor(T0 / 1000) + 4);
  assert.ok((doneEnd.aps["dismissal-date"] as number) > (end.aps["dismissal-date"] as number));
});

test("liveActivityPayload start carries attributes + type so ActivityKit can create the activity", () => {
  const start = liveActivityPayload(
    "start",
    { runningCount: 1, needsYouCount: 0 },
    T0,
    { type: "NucleicSessionAttributes", attributes: { hostName: "Studio" } },
  ) as { aps: Record<string, unknown> };
  assert.equal(start.aps.event, "start");
  assert.equal(start.aps.timestamp, Math.floor(T0 / 1000));
  assert.deepEqual(start.aps["content-state"], { runningCount: 1, needsYouCount: 0 });
  assert.equal(start.aps["attributes-type"], "NucleicSessionAttributes");
  assert.deepEqual(start.aps.attributes, { hostName: "Studio" });
  // A start is not an end — it must not carry a dismissal.
  assert.equal(start.aps["dismissal-date"], undefined);

  // Without the attributes bundle a start still encodes, just without the create fields (the
  // relay only omits them when the caller didn't supply them — an update/end never has them).
  const bare = liveActivityPayload("start", {}, T0) as { aps: Record<string, unknown> };
  assert.equal(bare.aps.event, "start");
  assert.equal(bare.aps["attributes-type"], undefined);
  assert.equal(bare.aps.attributes, undefined);
});

test("liveActivityPayload alerts an update through the glance, never a start/end", () => {
  // An alerting update carries the localized alert (sound + haptic) so the glance itself is the
  // notification — the host sends this in place of a separate wake tickle / banner. `approval` and
  // `input` pick the wording; both make the sound.
  const approval = liveActivityPayload("update", { needsYouCount: 1, approvalCount: 1 }, T0, null, "approval") as {
    aps: { alert?: { title?: { "loc-key"?: string }; body?: { "loc-key"?: string }; sound?: string } };
  };
  assert.equal(approval.aps.alert?.sound, "default");
  assert.equal(approval.aps.alert?.body?.["loc-key"], "approval.pending");
  // Content-free, exactly like the wake tickle it replaces.
  assert.equal(/transcript|diff|prompt|secret/.test(JSON.stringify(approval)), false);

  const input = liveActivityPayload("update", { needsYouCount: 1 }, T0, null, "input") as {
    aps: { alert?: { body?: { "loc-key"?: string } } };
  };
  assert.equal(input.aps.alert?.body?.["loc-key"], "input.pending");

  // A silent update (default) carries no alert — byte-identical to before.
  const silent = liveActivityPayload("update", { approvalCount: 1 }, T0) as { aps: { alert?: unknown } };
  assert.equal(silent.aps.alert, undefined);

  // An alert is ignored for start/end: a start brings a cold glance up (its own tickle fires) and
  // an end never alerts.
  const start = liveActivityPayload("start", {}, T0, { type: "T", attributes: {} }, "approval") as {
    aps: { alert?: unknown };
  };
  assert.equal(start.aps.alert, undefined);
  const end = liveActivityPayload("end", null, T0, null, "approval") as { aps: { alert?: unknown } };
  assert.equal(end.aps.alert, undefined);
});

test("sendLiveActivityApns uses the liveactivity push type and topic suffix", async () => {
  const { pem } = await freshKey();
  const cfg: ApnsConfig = {
    keyP8: pem, keyId: "ABCDE12345", teamId: "L7UDTQ6F5W", topic: "xyz.blakeslee.nucleic.remote", env: "production",
  };
  let captured: { url: string; headers: Record<string, string> } | null = null;
  const fakeFetch = (async (url: string, init: { headers: Record<string, string> }) => {
    captured = { url, headers: init.headers };
    return new Response(null, { status: 200, headers: { "apns-id": "la1" } });
  }) as unknown as typeof fetch;

  const r = await sendLiveActivityApns(cfg, "activitytoken", liveActivityPayload("update", {}, T0), "10", T0, fakeFetch);
  assert.equal(r.status, 200);
  assert.match(captured!.url, /^https:\/\/api\.push\.apple\.com\/3\/device\/activitytoken$/);
  assert.equal(captured!.headers["apns-push-type"], "liveactivity");
  assert.equal(captured!.headers["apns-topic"], "xyz.blakeslee.nucleic.remote.push-type.liveactivity");
});

test("sendLiveActivityApns defaults to priority 10 and honors a low-priority override", async () => {
  const { pem } = await freshKey();
  const cfg: ApnsConfig = {
    keyP8: pem, keyId: "ABCDE12345", teamId: "L7UDTQ6F5W", topic: "xyz.blakeslee.nucleic.remote", env: "production",
  };
  let captured: { headers: Record<string, string> } | null = null;
  const fakeFetch = (async (_url: string, init: { headers: Record<string, string> }) => {
    captured = { headers: init.headers };
    return new Response(null, { status: 200, headers: { "apns-id": "la2" } });
  }) as unknown as typeof fetch;

  // Default (a start / end / alerting update) rides high priority so it lands promptly.
  await sendLiveActivityApns(cfg, "activitytoken", liveActivityPayload("start", {}, T0), undefined, T0, fakeFetch);
  assert.equal(captured!.headers["apns-priority"], "10");

  // A routine content-state update rides `5` so it stays off the ActivityKit budget — the mix that
  // keeps a busy backgrounded session from starving a later push-to-start.
  await sendLiveActivityApns(cfg, "activitytoken", liveActivityPayload("update", {}, T0), "5", T0, fakeFetch);
  assert.equal(captured!.headers["apns-priority"], "5");
});

test("adoptLiveActivityPayload is a content-free silent wake with an adopt marker", () => {
  const p = adoptLiveActivityPayload() as { aps: Record<string, unknown>; adopt?: unknown };
  assert.equal(p.aps["content-available"], 1);
  assert.equal(p.aps.alert, undefined);
  assert.equal(p.aps.sound, undefined);
  assert.equal(p.adopt, 1);
  assert.equal(/transcript|diff|prompt|secret/.test(JSON.stringify(p)), false);
});

test("sendApnsAdopt posts a background push at priority 5 with no collapse-id", async () => {
  const { pem } = await freshKey();
  const cfg: ApnsConfig = {
    keyP8: pem, keyId: "ABCDE12345", teamId: "L7UDTQ6F5W", topic: "xyz.blakeslee.nucleic.remote", env: "production",
  };
  let captured: { url: string; headers: Record<string, string> } | null = null;
  const fakeFetch = (async (url: string, init: { headers: Record<string, string> }) => {
    captured = { url, headers: init.headers };
    return new Response(null, { status: 200, headers: { "apns-id": "adp" } });
  }) as unknown as typeof fetch;

  const r = await sendApnsAdopt(cfg, "devtoken", adoptLiveActivityPayload(), T0, fakeFetch);
  assert.equal(r.status, 200);
  // Rides the bare device topic (not the .push-type.liveactivity one) — it wakes the app, not the activity.
  assert.equal(captured!.headers["apns-topic"], "xyz.blakeslee.nucleic.remote");
  assert.equal(captured!.headers["apns-push-type"], "background");
  assert.equal(captured!.headers["apns-priority"], "5");
  // Must NOT share the approval tickle's collapse-id, or it would evict/coalesce with it.
  assert.equal(captured!.headers["apns-collapse-id"], undefined);
});

test("reconnectPayload is a content-free silent wake with a reconnect marker", () => {
  const p = reconnectPayload() as { aps: Record<string, unknown>; reconnect?: unknown };
  assert.equal(p.aps["content-available"], 1);
  assert.equal(p.aps.alert, undefined);
  assert.equal(p.aps.sound, undefined);
  assert.equal(p.reconnect, 1);
  assert.equal(/transcript|diff|prompt|secret/.test(JSON.stringify(p)), false);
});

test("sendApnsReconnect posts a background push at priority 5 with no collapse-id", async () => {
  const { pem } = await freshKey();
  const cfg: ApnsConfig = {
    keyP8: pem, keyId: "ABCDE12345", teamId: "L7UDTQ6F5W", topic: "xyz.blakeslee.nucleic.remote", env: "production",
  };
  let captured: { url: string; headers: Record<string, string> } | null = null;
  const fakeFetch = (async (url: string, init: { headers: Record<string, string> }) => {
    captured = { url, headers: init.headers };
    return new Response(null, { status: 200, headers: { "apns-id": "rec" } });
  }) as unknown as typeof fetch;

  const r = await sendApnsReconnect(cfg, "devtoken", reconnectPayload(), T0, fakeFetch);
  assert.equal(r.status, 200);
  // Rides the bare device topic — it wakes the app so it can re-dial, not any activity.
  assert.equal(captured!.headers["apns-topic"], "xyz.blakeslee.nucleic.remote");
  assert.equal(captured!.headers["apns-push-type"], "background");
  assert.equal(captured!.headers["apns-priority"], "5");
  // Must NOT share the approval tickle's collapse-id, or it would evict/coalesce with it.
  assert.equal(captured!.headers["apns-collapse-id"], undefined);
});

test("sendApns surfaces an APNS rejection reason", async () => {
  const { pem } = await freshKey();
  const cfg: ApnsConfig = { keyP8: pem, keyId: "K", teamId: "T", topic: "xyz.blakeslee.nucleic.remote", env: "production" };
  const fakeFetch = (async () =>
    new Response(JSON.stringify({ reason: "BadDeviceToken" }), { status: 400 })) as unknown as typeof fetch;
  const r = await sendApns(cfg, "bad", approvalPayload(), T0, fakeFetch);
  assert.equal(r.status, 400);
  assert.equal(r.reason, "BadDeviceToken");
});
