// The relay room (docs/CLOUD_INFRA.md §2): one Durable Object per `roomId`, holding the host's
// and the phones' WebSockets and brokering frames between them. It is **E2EE-opaque** — binary
// frame *payloads* are forwarded byte-for-byte and never inspected. A separate *text* control
// plane carries relay metadata (presence, push registration, wake requests) that is explicitly
// non-secret.
//
// **Per-peer routing (mesh P2).** The old code broadcast every binary frame to every other
// socket, so with more than two peers each client received (and had to AEAD-fail) frames meant
// for others, and the host had no way to tell which client a client→host frame came from. Now the
// DO owns a symmetric 9-byte routing envelope on the *host* leg only:
//   • client → host: DO prepends [ver][tag = sha256(senderDeviceId)[:8]] so the host can demux
//     which peer sent it, then sends to the single host socket.
//   • host → client: the host prepends [ver][tag = sha256(targetDeviceId)[:8]]; the DO reads the
//     tag, strips the envelope, and forwards the raw payload to exactly that client. Unknown tag
//     → dropped + counted.
// The client↔DO leg is always raw Noise frames — the envelope exists only between the host and
// the DO, so Noise and everything above see identical bytes on every transport.
//
// Uses the WebSocket Hibernation API so idle rooms evict from memory: handlers are methods the
// runtime calls on `webSocketMessage` / `webSocketClose`, and per-socket state rides along in the
// socket's serialized attachment rather than instance fields.

import type { Env } from "./env";
// `.ts` extension so `node --experimental-strip-types` can resolve this value import when the
// Room class is pulled into a test (test/room.test.ts); esbuild/wrangler accept it too.
import { apnsConfig, approvalPayload, clearPayload, sendApns, sendApnsClear } from "./apns.ts";

interface SocketMeta {
  role: "host" | "client";
  deviceId: string;
}

/** Routing-envelope version byte + a hard ceiling well above the app's 16 MiB frame cap but
 * under Cloudflare's 32 MiB WebSocket message limit — an oversized frame closes the socket.
 * (Connection-rate abuse is bounded elsewhere: 2-minute connection tokens gate admission, and
 * Cloudflare's platform limits cap raw socket churn — a message-rate counter here is unreliable
 * under the DO clock and risks false-closing a legitimately busy session.) */
const ENVELOPE_VERSION = 0x01;
const ENVELOPE_LEN = 9; // 1 version + 8 tag bytes
const MAX_FRAME_BYTES = 20 * 1024 * 1024;
const WS_CLOSE_POLICY = 1008; // policy violation (oversized frame)

/** First 8 bytes of sha256(deviceId) — the routing tag the host addresses a peer by. */
async function deviceTag(deviceId: string): Promise<Uint8Array> {
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(deviceId));
  return new Uint8Array(digest).slice(0, 8);
}

function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
  return true;
}

export class Room implements DurableObject {
  // Plain field assignment (not constructor parameter-property shorthand) so the class can be
  // imported under `node --experimental-strip-types`, which the test runner uses.
  private readonly state: DurableObjectState;
  private readonly env: Env;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    this.env = env;
  }

  // The Worker has already verified the connection token; it forwards the resolved identity in
  // headers. We just accept the socket (hibernatable) and announce presence.
  async fetch(req: Request): Promise<Response> {
    if (req.headers.get("upgrade") !== "websocket") {
      return new Response("expected websocket", { status: 426 });
    }
    const role = req.headers.get("x-nucleic-role") === "host" ? "host" : "client";
    const deviceId = req.headers.get("x-nucleic-device") ?? "unknown";

    // One host per room: a new host connection replaces any prior one (a stale socket the old
    // host never cleanly closed would otherwise shadow the live one in `getWebSockets("host")`).
    // Likewise a client reconnecting under the same deviceId supersedes its own stale socket.
    for (const existing of this.state.getWebSockets(role === "host" ? "host" : deviceId)) {
      const m = existing.deserializeAttachment() as SocketMeta | null;
      if (role === "host" || m?.deviceId === deviceId) {
        try {
          existing.close(1000, "superseded");
        } catch {
          /* already closing */
        }
      }
    }

    const { 0: client, 1: server } = new WebSocketPair();
    this.state.acceptWebSocket(server, [role, deviceId]);
    server.serializeAttachment({ role, deviceId } satisfies SocketMeta);

    this.broadcastPresence();
    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws: WebSocket, message: ArrayBuffer | string): Promise<void> {
    // Text = relay control plane (never E2EE payload).
    if (typeof message === "string") {
      await this.handleControl(ws, message);
      return;
    }
    if (message.byteLength > MAX_FRAME_BYTES) {
      try {
        ws.close(WS_CLOSE_POLICY, "frame too large");
      } catch {
        /* already closing */
      }
      return;
    }

    const me = ws.deserializeAttachment() as SocketMeta | null;
    if (!me) return;
    if (me.role === "host") {
      await this.routeFromHost(message);
    } else {
      await this.routeFromClient(me.deviceId, message);
    }
  }

  /** client → host: prepend [ver][tag = sha256(senderDeviceId)[:8]] so the host can demux which
   * peer sent this frame, then deliver to the single host socket. */
  private async routeFromClient(senderDeviceId: string, payload: ArrayBuffer): Promise<void> {
    const host = this.state.getWebSockets("host")[0];
    if (!host) return; // no host present; the client will retry after reconnect
    const tag = await deviceTag(senderDeviceId);
    const framed = new Uint8Array(ENVELOPE_LEN + payload.byteLength);
    framed[0] = ENVELOPE_VERSION;
    framed.set(tag, 1);
    framed.set(new Uint8Array(payload), ENVELOPE_LEN);
    try {
      host.send(framed);
    } catch {
      /* host going away; its close handler reconciles presence */
    }
  }

  /** host → client: read the [ver][tag] envelope, strip it, and forward the raw Noise frame to
   * exactly the addressed client. An unknown/mismatched tag is dropped (and would show as a gap
   * to no one — the addressed peer simply isn't here). */
  private async routeFromHost(message: ArrayBuffer): Promise<void> {
    const bytes = new Uint8Array(message);
    if (bytes.length < ENVELOPE_LEN || bytes[0] !== ENVELOPE_VERSION) return; // malformed → drop
    const tag = bytes.subarray(1, ENVELOPE_LEN);
    const raw = bytes.subarray(ENVELOPE_LEN);
    for (const client of this.state.getWebSockets("client")) {
      const m = client.deserializeAttachment() as SocketMeta | null;
      if (!m) continue;
      if (bytesEqual(await deviceTag(m.deviceId), tag)) {
        try {
          client.send(raw);
        } catch {
          /* client going away */
        }
        return;
      }
    }
  }

  webSocketClose(ws: WebSocket, code: number): void {
    try {
      ws.close(code);
    } catch {
      /* already closed */
    }
    this.broadcastPresence();
  }

  webSocketError(): void {
    this.broadcastPresence();
  }

  // MARK: - Control plane (text JSON)

  private async handleControl(ws: WebSocket, raw: string): Promise<void> {
    let msg: { t?: string; pushToken?: unknown; env?: unknown; topic?: unknown; deviceId?: unknown; kind?: unknown };
    try {
      msg = JSON.parse(raw);
    } catch {
      return;
    }
    const me = ws.deserializeAttachment() as SocketMeta | null;

    switch (msg.t) {
      case "register": {
        // The phone hands the relay its APNS token (not secret) so the relay can wake it later.
        // Owner-tagged with this room's DO id: in-band wakes (below) are authorized by room
        // membership, and the tag keeps out-of-band hosts (push.ts) from waking it.
        if (me?.deviceId && typeof msg.pushToken === "string") {
          const env = msg.env === "production" ? "production" : "sandbox";
          const hostId = `room:${this.state.id.toString()}`;
          // Per-channel builds have suffixed bundle ids; store the phone's topic so `wake`
          // addresses APNs correctly (omitted → the wake falls back to the configured topic).
          const record: { token: string; env: string; topic?: string; hostId: string } = {
            token: msg.pushToken,
            env,
            hostId,
          };
          if (typeof msg.topic === "string" && msg.topic) record.topic = msg.topic;
          await this.env.NUCLEIC_PUSH_TOKENS.put(me.deviceId, JSON.stringify(record), {
            expirationTtl: 90 * 24 * 60 * 60,
          });
        }
        break;
      }
      case "wake": {
        // Only the host may ask the relay to wake a phone, and only when it isn't connected.
        if (me?.role === "host" && typeof msg.deviceId === "string") {
          await this.wake(msg.deviceId, msg.kind === "question");
        }
        break;
      }
      case "clear": {
        // The counterpart to "wake": recall a disconnected phone's approval tickle once the
        // approval was resolved elsewhere. Host-only, same as wake.
        if (me?.role === "host" && typeof msg.deviceId === "string") {
          await this.clear(msg.deviceId);
        }
        break;
      }
    }
  }

  private async wake(deviceId: string, question = false): Promise<void> {
    const present = this.state.getWebSockets().some((w) => {
      const m = w.deserializeAttachment() as SocketMeta | null;
      return m?.deviceId === deviceId;
    });
    if (present) return; // a live socket will deliver the frame; no push needed

    const cfg = apnsConfig(this.env);
    if (!cfg) return;
    const rec = await this.env.NUCLEIC_PUSH_TOKENS.get(deviceId);
    if (!rec) return;
    const { token, env, topic } = JSON.parse(rec) as {
      token: string;
      env?: "sandbox" | "production";
      topic?: string;
    };
    // The record's environment + topic win (set when the phone/host registered — they know the
    // build's env and per-channel bundle id); the global APNS_ENV / APNS_TOPIC only backstop
    // records that predate them. A wrong topic is `BadTopic`-rejected by APNs.
    await sendApns({ ...cfg, env: env ?? cfg.env, topic: topic ?? cfg.topic }, token, approvalPayload(question));
  }

  /** Recall a phone's approval tickle (silent background push). Unlike `wake` this is worth
   * sending even to a *present* device — a phone that saw the tickle while its socket was down and
   * only just reconnected still has the notification on screen; a live socket's `approvalResolved`
   * frame also clears it, so the redundant clear is harmless. */
  private async clear(deviceId: string): Promise<void> {
    const cfg = apnsConfig(this.env);
    if (!cfg) return;
    const rec = await this.env.NUCLEIC_PUSH_TOKENS.get(deviceId);
    if (!rec) return;
    const { token, env, topic } = JSON.parse(rec) as {
      token: string;
      env?: "sandbox" | "production";
      topic?: string;
    };
    await sendApnsClear(
      { ...cfg, env: env ?? cfg.env, topic: topic ?? cfg.topic }, token, clearPayload());
  }

  private broadcastPresence(): void {
    const sockets = this.state.getWebSockets();
    const peers = sockets.map((w) => {
      const m = w.deserializeAttachment() as SocketMeta | null;
      return { deviceId: m?.deviceId ?? "?", role: m?.role ?? "client" };
    });
    const frame = JSON.stringify({ t: "presence", peers });
    for (const w of sockets) {
      try {
        w.send(frame);
      } catch {
        /* closing */
      }
    }
  }
}
