// The relay room (docs/CLOUD_INFRA.md §2): one Durable Object per `roomId`, holding the host's
// and the phone's WebSockets and brokering frames between them. It is **E2EE-opaque** — binary
// frames 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.
//
// 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";
import { apnsConfig, approvalPayload, sendApns } from "./apns";

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

export class Room implements DurableObject {
  constructor(
    private readonly state: DurableObjectState,
    private readonly 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";

    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;
    }
    // Binary = opaque Noise frame. Forward to every other socket in the room, untouched.
    for (const peer of this.state.getWebSockets()) {
      if (peer !== ws) {
        try {
          peer.send(message);
        } catch {
          /* peer going away; close handler will reconcile presence */
        }
      }
    }
  }

  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; deviceId?: 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.
        if (me?.deviceId && typeof msg.pushToken === "string") {
          const env = msg.env === "production" ? "production" : "sandbox";
          await this.env.NUCLEIC_PUSH_TOKENS.put(me.deviceId, JSON.stringify({ token: msg.pushToken, env }), {
            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);
        }
        break;
      }
    }
  }

  private async wake(deviceId: string): 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 } = JSON.parse(rec) as { token: string };
    await sendApns(cfg, token, approvalPayload());
  }

  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 */
      }
    }
  }
}
