// Out-of-band push routes (Phase C). The in-band path stores tokens when a phone connects
// over the relay WebSocket (Room control frame); these routes cover LAN-only pairings — the
// host uploads the token it received in the sync Hello, so it can wake a phone that has
// never used the relay transport.
//
// Auth model: **per-host credentials, no shared secret in the shipped app.** A host enrolls
// once (`/v1/host/enroll`, open) and receives `{hostId, hostSecret}`; only the secret's
// SHA-256 is stored. Every register/notify is then scoped to that host: it can wake only
// devices it registered itself. Open enrollment is safe because identity isn't the guard —
// scoping is: waking a phone requires its APNS token, which only the phone and its paired
// host ever hold, and the push is a content-free tickle, throttled host-side. The admin
// bearer (RELAY_ADMIN_SECRET) remains as an *operator* override and never ships in the app.
// Handlers own validation + storage semantics and are unit-tested directly (test/push.test.ts).

/** Push-token TTL: refreshed on every host upload (the phone re-sends its token in each
 * sync Hello), so a live pairing never lapses; an abandoned one ages out. */
export const PUSH_TOKEN_TTL = 60 * 60 * 24 * 90; // 90 days

/** Host-credential TTL — refreshed on every authenticated register, same liveness logic. */
export const HOST_AUTH_TTL = 60 * 60 * 24 * 365; // 1 year

const enc = new TextEncoder();

async function sha256Hex(s: string): Promise<string> {
  const digest = await crypto.subtle.digest("SHA-256", enc.encode(s));
  return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}

function json(obj: unknown, status = 200): Response {
  return new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
}

/** Constant-time-ish string compare (both operands are fixed-length hex here). */
function safeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

// MARK: - Host enrollment + auth

/** Mint a fresh host credential. The caller (a Mac host, first time its relay toggle turns
 * on) persists both parts in its Keychain; we keep only the secret's hash. */
export async function enrollHost(kv: KVNamespace): Promise<Response> {
  const hostId = crypto.randomUUID();
  const secretBytes = new Uint8Array(32);
  crypto.getRandomValues(secretBytes);
  const hostSecret = [...secretBytes].map((b) => b.toString(16).padStart(2, "0")).join("");
  await kv.put(`hostauth:${hostId}`, await sha256Hex(hostSecret), { expirationTtl: HOST_AUTH_TTL });
  return json({ hostId, hostSecret });
}

/** Resolve a `Bearer <hostId>.<hostSecret>` to its hostId, or null if absent/invalid. */
export async function authedHostId(kv: KVNamespace, bearer: string | null): Promise<string | null> {
  if (!bearer) return null;
  const dot = bearer.indexOf(".");
  if (dot <= 0) return null;
  const hostId = bearer.slice(0, dot);
  const secret = bearer.slice(dot + 1);
  const want = await kv.get(`hostauth:${hostId}`);
  if (!want) return null;
  return safeEqual(want, await sha256Hex(secret)) ? hostId : null;
}

// MARK: - Register + wake authorization

export interface PushRegisterBody {
  deviceId?: string;
  token?: string;
  env?: string;
  /** The phone's APNs topic = its own bundle id. Per-channel builds are suffixed
   * (…`.beta` / …`.canary`); APNs `BadTopic`-rejects a push whose topic isn't the target
   * app's bundle id, so the host reports it and we address every push to this device with it. */
  topic?: string;
}

/** The device-token record. `hostId` scopes wakes: "admin" records predate host auth or
 * were registered by the operator; `room:<id>` records came in over the relay WebSocket
 * (their wake path is the in-band Room frame, not these routes). */
export interface PushTokenRecord {
  token: string;
  env: "sandbox" | "production";
  /** The device's APNs topic (its bundle id). Absent on records that predate per-channel
   * topics → the caller falls back to the worker's configured `APNS_TOPIC`. */
  topic?: string;
  hostId?: string;
}

/** Store a phone's APNS token under its device id, owned by `hostId`. Also refreshes the
 * owning host's credential TTL (registration recurs on every phone Hello, so a live pairing
 * keeps both records fresh). */
export async function registerPushToken(
  kv: KVNamespace,
  hostId: string,
  body: PushRegisterBody | null,
): Promise<Response> {
  if (!body?.deviceId || !body.token) return new Response("deviceId and token required", { status: 422 });
  const apsEnv = body.env === "production" ? "production" : "sandbox";
  const record: PushTokenRecord = { token: body.token, env: apsEnv, hostId };
  if (body.topic) record.topic = body.topic;
  await kv.put(body.deviceId, JSON.stringify(record), { expirationTtl: PUSH_TOKEN_TTL });
  const auth = await kv.get(`hostauth:${hostId}`);
  if (auth) await kv.put(`hostauth:${hostId}`, auth, { expirationTtl: HOST_AUTH_TTL });
  return json({ registered: body.deviceId });
}

/** Look up the device's token record iff `hostId` may wake it — i.e. it registered it
 * (the admin operator may wake anything). Returns null when unknown or not owned. */
export async function wakeableRecord(
  kv: KVNamespace,
  hostId: string,
  deviceId: string,
  isAdmin: boolean,
): Promise<PushTokenRecord | null> {
  const raw = await kv.get(deviceId);
  if (!raw) return null;
  const record = JSON.parse(raw) as PushTokenRecord;
  if (isAdmin) return record;
  return record.hostId === hostId ? record : null;
}
