// APNS sender (docs/CLOUD_INFRA.md §3). Token-based auth: an ES256 JWT signed in-Worker with
// WebCrypto, posted over HTTP/2 to Apple. The push is **content-free** — the relay can't read the
// approval, so it carries none of it; the phone wakes and pulls the real approval over the
// encrypted channel.

import type { Env } from "./env";

export interface ApnsConfig {
  keyP8: string; // PKCS#8 PEM of the .p8 auth key
  keyId: string;
  teamId: string;
  topic: string; // xyz.blakeslee.nucleic.remote
  env: "sandbox" | "production";
}

/** Build a config from the Worker secrets, or null if APNS isn't provisioned. */
export function apnsConfig(env: Env): ApnsConfig | null {
  if (!env.APNS_KEY_P8 || !env.APNS_KEY_ID || !env.APNS_TEAM_ID || !env.APNS_TOPIC) return null;
  return {
    keyP8: env.APNS_KEY_P8,
    keyId: env.APNS_KEY_ID,
    teamId: env.APNS_TEAM_ID,
    topic: env.APNS_TOPIC,
    env: env.APNS_ENV === "production" ? "production" : "sandbox",
  };
}

const enc = new TextEncoder();

function b64url(bytes: Uint8Array): string {
  let s = "";
  for (const b of bytes) s += String.fromCharCode(b);
  return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function pemToDer(pem: string): Uint8Array {
  const body = pem
    .replace(/-----BEGIN [^-]+-----/, "")
    .replace(/-----END [^-]+-----/, "")
    .replace(/\s+/g, "");
  const bin = atob(body);
  const out = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
  return out;
}

export interface JwtCache {
  token?: string;
  iat?: number;
}
const moduleCache: JwtCache = {};

/** Signed APNS provider JWT. Cached ~40 min (Apple requires refresh < 60 min). */
export async function apnsJwt(cfg: ApnsConfig, now = Date.now(), cache: JwtCache = moduleCache): Promise<string> {
  const iat = Math.floor(now / 1000);
  if (cache.token && cache.iat && iat - cache.iat < 40 * 60) return cache.token;

  const key = await crypto.subtle.importKey(
    "pkcs8",
    pemToDer(cfg.keyP8),
    { name: "ECDSA", namedCurve: "P-256" },
    false,
    ["sign"],
  );
  const header = b64url(enc.encode(JSON.stringify({ alg: "ES256", kid: cfg.keyId })));
  const claims = b64url(enc.encode(JSON.stringify({ iss: cfg.teamId, iat })));
  const input = `${header}.${claims}`;
  // WebCrypto returns the raw r||s signature (JOSE/JWS format) — exactly what APNS wants.
  const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, enc.encode(input));
  const token = `${input}.${b64url(new Uint8Array(sig))}`;
  cache.token = token;
  cache.iat = iat;
  return token;
}

/** The `apns-collapse-id` shared by the approval wake tickle and its clear. A stable id makes the
 * tickle *replaceable and recallable*: iOS coalesces repeated wakes into one lock-screen alert
 * (rather than stacking one per approval), and — because a remote notification's delivered
 * identifier equals its collapse-id — the phone can `removeDeliveredNotifications` for this exact id
 * when the approval is resolved elsewhere. Kept ≤64 bytes per Apple's limit, and mirrored by
 * `NotificationRouter.approvalTickleIdentifier` on the phone. */
export const APPROVAL_COLLAPSE_ID = "approval-pending";

/** A wake "tickle": carries no code, transcript, or approval detail — just the *category* of block
 * so the phone shows an accurate alert. `question` picks the wording: an agent question
 * (`AskUserQuestion`) waits for an *answer*, everything else waits for an *approval*. Both ride the
 * same collapse-id (a session blocked on either is one attention item, recalled by one clear). */
export function approvalPayload(question = false): Record<string, unknown> {
  return {
    aps: {
      alert: { "loc-key": question ? "question.pending" : "approval.pending" },
      "interruption-level": "time-sensitive",
      sound: "default",
    },
    nudge: 1,
  };
}

/** A content-free *recall* of the wake tickle. A silent background push (`content-available`, no
 * alert/sound) that wakes the phone in the background so it can `removeDeliveredNotifications` the
 * tickle once an approval is resolved on another device (docs/CLOUD_INFRA.md §3). Carries only a
 * `clear` marker — like the tickle it leaks nothing. */
export function clearPayload(): Record<string, unknown> {
  return {
    aps: { "content-available": 1 },
    clear: 1,
  };
}

/** A silent wake that asks the phone to adopt a push-started Live Activity (iOS 17.2+): come online
 * and register the new activity's update token so the host can keep the glance fresh and end it
 * cleanly (UX_IOS §5.3). Like `clearPayload` it's a content-free `content-available` background push
 * — it grants the app a moment of runtime and leaks nothing. Carries an `adopt` marker so the phone
 * routes it distinctly from the approval `clear`. */
export function adoptLiveActivityPayload(): Record<string, unknown> {
  return {
    aps: { "content-available": 1 },
    adopt: 1,
  };
}

/** A silent wake that asks a device to reconnect over a non-local transport, sent by the host when
 * the device's LAN link drops (SYNC_PROTOCOL §3.2). Like the adopt wake it's a content-free
 * `content-available` background push — it grants the app a moment of runtime to re-run its dial
 * loop (LAN→tailnet→relay) and leaks nothing. Carries a `reconnect` marker so the phone routes it
 * distinctly from the approval `clear` and the Live Activity `adopt`. */
export function reconnectPayload(): Record<string, unknown> {
  return {
    aps: { "content-available": 1 },
    reconnect: 1,
  };
}

/** How long an `end`-push that carries a "Done" content-state lingers before iOS dismisses it. The
 * host no longer uses this path — a finished run is now *held* on the lock screen via ordinary
 * updates until the user opens the app (UX_IOS §5.3, `SyncHost.performLiveActivityPush`), and the
 * end push it then sends is bare (immediate dismissal). Retained only so a content-bearing end still
 * lingers rather than flashing, should any caller send one. */
const DONE_LINGER_SECONDS = 4;

/** Static attributes + type name a push-to-start push must carry (iOS 17.2+) so ActivityKit knows
 * which Activity to create and how to decode it. `type` must equal the phone's `ActivityAttributes`
 * conformer name; `attributes` are its stored properties (the host name). */
interface StartAttributes {
  type: string;
  attributes: unknown;
}

/** The localized `alert` an *alerting* Live Activity update carries so the glance itself makes the
 * sound + haptic when a session newly needs the user — the host sends this in place of a separate
 * push (the approval wake tickle) or an on-device banner for a phone that already has a Live Activity
 * on screen, so an active session shows one surface (the glance), not "a Live Activity *and* a second
 * notification" (UX_IOS §5.1/§5.3). `kind` picks the wording: an `approval` waits for a decision (its
 * inline Allow/Deny live on the glance), an `input` waits for the user's next prompt. Reuses the
 * on-device loc-keys so the wording stays in lockstep and content-free — the phone localizes it; the
 * real approval is still pulled over the encrypted channel. `sound` drives the audio/haptic on iPhone
 * (where the glance updates in place — the title/body only surface on Apple Watch, so no separate
 * banner competes with the glance). */
function liveActivityAlert(kind: string): Record<string, unknown> {
  const input = kind === "input";
  return {
    title: { "loc-key": input ? "input.title" : "approval.title" },
    body: { "loc-key": input ? "input.pending" : "approval.pending" },
    sound: "default",
  };
}

/** A Live Activity start/update/end push (UX_IOS §5.3). Unlike the wake tickle these carry the
 * aggregate glance (`content-state`) so the phone's lock screen stays fresh while backgrounded —
 * the relay therefore sees it, so the host only pushes non-sensitive session counts/titles here.
 *
 * A `start` (iOS 17.2+ push-to-start) *creates* the Activity even when the app is closed/terminated,
 * so the glance appears without the user opening the app first — it must carry `attributes-type` +
 * `attributes` (the static state) alongside the initial `content-state`.
 *
 * An `end` normally dismisses at once. But an `end` that *carries* a content-state is a "Done"
 * flash: iOS shows that final glance until a future `dismissal-date`, so a completed run reads as
 * finished for a beat before it clears rather than vanishing the instant it ends.
 *
 * `alert` (`"approval"` | `"input"`) makes an `update` sound/vibrate through the glance (a session
 * newly needs the user while the phone was away) so the host can skip the redundant wake tickle /
 * banner. Only an `update` alerts — a `start` brings a cold glance up (its tickle still fires) and an
 * `end` never alerts. */
export function liveActivityPayload(
  event: string,
  contentState: unknown | null,
  now = Date.now(),
  start: StartAttributes | null = null,
  alert: string | null = null,
): Record<string, unknown> {
  const ts = Math.floor(now / 1000);
  const kind = event === "end" ? "end" : event === "start" ? "start" : "update";
  const aps: Record<string, unknown> = { timestamp: ts, event: kind };
  if (kind === "end") {
    // A "Done" end (content-state present) lingers; a bare end dismisses immediately.
    aps["dismissal-date"] = contentState ? ts + DONE_LINGER_SECONDS : ts;
    if (contentState) aps["content-state"] = contentState;
  } else {
    if (contentState) aps["content-state"] = contentState;
    if (kind === "start" && start) {
      aps["attributes-type"] = start.type;
      aps["attributes"] = start.attributes;
    }
    // An alerting update carries the sound/haptic itself, replacing the separate push/banner.
    if (kind === "update" && alert) aps["alert"] = liveActivityAlert(alert);
  }
  return { aps };
}

export interface ApnsResult {
  status: number;
  id?: string;
  reason?: string;
}

/** Per-request APNs header overrides. `collapseId` sets `apns-collapse-id` (coalesce/recall);
 * `priority` overrides the default `10` — a `background` clear must ride `5` (Apple requires it for
 * `content-available`-only pushes; a `10` is rejected), and a routine `liveactivity` update rides
 * `5` so it stays off the ActivityKit budget (see `sendLiveActivityApns`). */
interface PostOptions {
  collapseId?: string;
  priority?: string;
}

/** POST one APNS request. `pushType`/`topic` vary by kind: an `alert` tickle rides the bare
 * bundle topic; a `liveactivity` push rides `<bundle>.push-type.liveactivity`; a `background`
 * clear rides the bare topic at priority 5. */
async function postApns(
  cfg: ApnsConfig,
  deviceToken: string,
  payload: Record<string, unknown>,
  pushType: string,
  topic: string,
  now: number,
  fetchImpl: typeof fetch,
  opts: PostOptions = {},
): Promise<ApnsResult> {
  const host = cfg.env === "production" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com";
  const jwt = await apnsJwt(cfg, now);
  const headers: Record<string, string> = {
    authorization: `bearer ${jwt}`,
    "apns-topic": topic,
    "apns-push-type": pushType,
    "apns-priority": opts.priority ?? "10",
  };
  if (opts.collapseId) headers["apns-collapse-id"] = opts.collapseId;
  const res = await fetchImpl(`${host}/3/device/${deviceToken}`, {
    method: "POST",
    headers,
    body: JSON.stringify(payload),
  });
  const id = res.headers.get("apns-id") ?? undefined;
  if (res.status === 200) return { status: 200, id };
  let reason: string | undefined;
  try {
    reason = ((await res.json()) as { reason?: string })?.reason;
  } catch {
    /* non-JSON body */
  }
  return { status: res.status, id, reason };
}

/** Send the approval wake tickle (an `alert`). Rides the shared collapse-id so repeat wakes
 * coalesce into one recallable lock-screen alert. */
export async function sendApns(
  cfg: ApnsConfig,
  deviceToken: string,
  payload: Record<string, unknown>,
  now = Date.now(),
  fetchImpl: typeof fetch = fetch,
): Promise<ApnsResult> {
  return postApns(cfg, deviceToken, payload, "alert", cfg.topic, now, fetchImpl, {
    collapseId: APPROVAL_COLLAPSE_ID,
  });
}

/** Send the silent clear that recalls a delivered wake tickle. A `background` push at priority 5
 * (Apple's requirement for `content-available`-only), on the same collapse-id as the tickle. */
export async function sendApnsClear(
  cfg: ApnsConfig,
  deviceToken: string,
  payload: Record<string, unknown>,
  now = Date.now(),
  fetchImpl: typeof fetch = fetch,
): Promise<ApnsResult> {
  return postApns(cfg, deviceToken, payload, "background", cfg.topic, now, fetchImpl, {
    collapseId: APPROVAL_COLLAPSE_ID,
    priority: "5",
  });
}

/** Send the silent "adopt" wake (a `background` push at priority 5, Apple's requirement for a
 * `content-available`-only push). Unlike the approval clear it rides no collapse-id — it isn't
 * recalling a delivered alert, just granting the app runtime to register a push-started activity's
 * token — so it must not coalesce with (or evict) the approval tickle. */
export async function sendApnsAdopt(
  cfg: ApnsConfig,
  deviceToken: string,
  payload: Record<string, unknown>,
  now = Date.now(),
  fetchImpl: typeof fetch = fetch,
): Promise<ApnsResult> {
  return postApns(cfg, deviceToken, payload, "background", cfg.topic, now, fetchImpl, { priority: "5" });
}

/** Send the silent "reconnect over a non-local transport" nudge (a `background` push at priority 5,
 * Apple's requirement for a `content-available`-only push). Like the adopt wake it rides no
 * collapse-id — it isn't recalling a delivered alert, just granting the app runtime to re-dial — so
 * it must not coalesce with (or evict) the approval tickle. */
export async function sendApnsReconnect(
  cfg: ApnsConfig,
  deviceToken: string,
  payload: Record<string, unknown>,
  now = Date.now(),
  fetchImpl: typeof fetch = fetch,
): Promise<ApnsResult> {
  return postApns(cfg, deviceToken, payload, "background", cfg.topic, now, fetchImpl, { priority: "5" });
}

/** Send to a Live Activity's per-activity push token (a different topic + push type than an
 * alert, per Apple's ActivityKit push contract).
 *
 * `priority` is the crux of push-to-start reliability. Apple meters ActivityKit pushes against a
 * per-app **budget** that *shrinks the longer the app is backgrounded*; a `10` (high-priority) push
 * counts against it, a `5` (low-priority) push does not, and Apple's guidance is to **mix 5 and 10**
 * so routine traffic can't starve the budget. Sending *every* Live Activity push at `10` (the old
 * behavior) let a busy session's frequent content-state updates drain the budget while the phone was
 * away — so a later push-to-**start** got throttled/dropped, which is exactly "the glance won't
 * start after the app's been backgrounded a while" (ordinary alert pushes aren't budgeted, so they
 * kept working). Callers now ride `5` for a routine (silent) update and reserve `10` for the pushes
 * that must land promptly: the start, the end, and an alerting "needs you" update. */
export async function sendLiveActivityApns(
  cfg: ApnsConfig,
  activityPushToken: string,
  payload: Record<string, unknown>,
  priority = "10",
  now = Date.now(),
  fetchImpl: typeof fetch = fetch,
): Promise<ApnsResult> {
  return postApns(cfg, activityPushToken, payload, "liveactivity", `${cfg.topic}.push-type.liveactivity`, now, fetchImpl, {
    priority,
  });
}
