// WebSocket exchanges with the rendezvous server (hbbs:21118).
//
// Mirrors the desktop client's two-step relay setup:
//   1. PunchHoleRequest -> PunchHoleResponse — harvests the SIGNED peer
//      Curve25519 pk and the relay-server address. The peer responds with
//      PunchHoleSent which the rendezvous server transforms into
//      PunchHoleResponse with `pk` filled in via get_pk()
//      (rendezvous_server.rs:707).
//   2. RequestRelay -> RelayResponse — books a relay session under a uuid
//      we generate. The peer-forwarded RelayResponse from THIS path has
//      empty pk (peer's create_relay(initiate=false) doesn't set id, so
//      the rendezvous's set_pk replacement is skipped). That's fine: we
//      already have the signed pk from step 1.
//
// Each step is a fresh short-lived WS connection — same pattern the
// desktop client uses (request_relay() in client.rs opens a new TCP).
// Wire frames are raw protobuf bytes inside WS binary frames; no length
// prefix.

import { hbb } from "../proto/generated.js";

export interface PunchHoleResult {
  /** Peer's signed Curve25519 sign-pk (verify with the rendezvous server's Ed25519 pk). */
  signedIdPk: Uint8Array;
  /** Hostname/IP of the relay (hbbr) the peer chose. */
  relayServer: string;
  /**
   * UUID the **peer** generated for this relay session. Empty when the
   * response was a PunchHoleResponse (direct path); set when the peer went
   * straight to relay via RelayResponse — which is what happens whenever
   * we send `nat_type=SYMMETRIC`. Caller MUST use this uuid (not a fresh
   * one) when connecting to the relay; it's the only value the peer's own
   * relay leg agreed on.
   */
  peerUuid: string;
  /** Mangled peer socket address — ignored by the browser (no direct connect). */
  peerSocketAddr: Uint8Array;
}

/**
 * Send PunchHoleRequest, receive PunchHoleResponse. Throws on peer-offline,
 * license-mismatch, timeout, etc. Sets `nat_type=SYMMETRIC` so the peer
 * relay flow is preferred.
 */
export function punchHole(
  wsUrl: string,
  peerId: string,
  licenceKey: string,
  clientVersion: string,
  timeoutMs = 10_000,
): Promise<PunchHoleResult> {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(wsUrl);
    ws.binaryType = "arraybuffer";

    const timer = setTimeout(() => {
      cleanup();
      reject(new Error(`punchHole: timeout after ${timeoutMs}ms`));
    }, timeoutMs);

    function cleanup(): void {
      clearTimeout(timer);
      try { ws.close(); } catch { /* ignore */ }
    }

    ws.onopen = () => {
      const msg = hbb.RendezvousMessage.create({
        punch_hole_request: hbb.PunchHoleRequest.create({
          id: peerId,
          // SYMMETRIC NAT — combined with browser-as-controller, the peer
          // recognizes "you can't punch through" and prefers the relay
          // path. This is also what desktop's --force-relay sets.
          nat_type: hbb.NatType.SYMMETRIC,
          licence_key: licenceKey,
          conn_type: hbb.ConnType.DEFAULT_CONN,
          version: clientVersion,
        }),
      });
      const bytes = hbb.RendezvousMessage.encode(msg).finish();
      ws.send(bytes);
    };

    ws.onmessage = (ev: MessageEvent) => {
      const buf = new Uint8Array(ev.data as ArrayBuffer);
      let parsed: hbb.RendezvousMessage;
      try {
        parsed = hbb.RendezvousMessage.decode(buf);
      } catch (e) {
        cleanup();
        reject(new Error(`punchHole: decode failure: ${e}`));
        return;
      }
      const ph = parsed.punch_hole_response;
      if (ph) {
        // Peer-not-reachable / failure path: socket_addr is empty.
        if (!ph.socket_addr || ph.socket_addr.length === 0) {
          cleanup();
          reject(new Error(failureMessage(ph)));
          return;
        }
        cleanup();
        resolve({
          signedIdPk: (ph.pk as Uint8Array) || new Uint8Array(0),
          relayServer: ph.relay_server || "",
          peerUuid: "",   // direct path — uuid not relevant
          peerSocketAddr: (ph.socket_addr as Uint8Array) || new Uint8Array(0),
        });
        return;
      }
      // Peer chose relay path immediately (the SYMMETRIC nat_type we send
      // forces this). The peer generated its OWN uuid in the create_relay
      // initiate=true path — capture it; that's the only uuid the peer
      // listens for on the relay side.
      const rr = parsed.relay_response;
      if (rr) {
        cleanup();
        resolve({
          signedIdPk: (rr.pk as Uint8Array) || new Uint8Array(0),
          relayServer: rr.relay_server || "",
          peerUuid: rr.uuid || "",
          peerSocketAddr: (rr.socket_addr as Uint8Array) || new Uint8Array(0),
        });
        return;
      }
      // Other message types (config_update, etc.) — keep waiting.
    };

    ws.onerror = () => {
      cleanup();
      reject(new Error(`punchHole: WebSocket error connecting to ${wsUrl}`));
    };

    ws.onclose = (ev: CloseEvent) => {
      clearTimeout(timer);
      reject(new Error(`punchHole: socket closed (code=${ev.code} reason=${ev.reason || "n/a"})`));
    };
  });
}

/**
 * Send RequestRelay to rendezvous (after having already harvested the
 * signed pk via PunchHole). Receive RelayResponse confirming the peer is
 * paired with our uuid on the relay. RelayResponse.pk is empty on this
 * path — caller must use the signedIdPk from the prior PunchHole result.
 */
export function requestRelayViaRendezvous(
  wsUrl: string,
  peerId: string,
  uuid: string,
  licenceKey: string,
  clientVersion: string,
  timeoutMs = 10_000,
): Promise<{ uuid: string; relayServer: string }> {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(wsUrl);
    ws.binaryType = "arraybuffer";

    const timer = setTimeout(() => {
      cleanup();
      reject(new Error(`requestRelay: timeout after ${timeoutMs}ms`));
    }, timeoutMs);

    function cleanup(): void {
      clearTimeout(timer);
      try { ws.close(); } catch { /* ignore */ }
    }

    ws.onopen = () => {
      const msg = hbb.RendezvousMessage.create({
        request_relay: hbb.RequestRelay.create({
          id: peerId,
          uuid,
          licence_key: licenceKey,
          secure: true,
          conn_type: hbb.ConnType.DEFAULT_CONN,
        }),
      });
      // RequestRelay has no version field, but adding it via __unknown
      // would just be dropped by the proto. The server's get_pk() check
      // only matters for the response *from the peer*, which already
      // carries the peer's own version.
      void clientVersion;
      const bytes = hbb.RendezvousMessage.encode(msg).finish();
      ws.send(bytes);
    };

    ws.onmessage = (ev: MessageEvent) => {
      const buf = new Uint8Array(ev.data as ArrayBuffer);
      let parsed: hbb.RendezvousMessage;
      try {
        parsed = hbb.RendezvousMessage.decode(buf);
      } catch (e) {
        cleanup();
        reject(new Error(`requestRelay: decode failure: ${e}`));
        return;
      }
      const rr = parsed.relay_response;
      if (rr) {
        if (rr.refuse_reason) {
          cleanup();
          reject(new Error(`relay refused: ${rr.refuse_reason}`));
          return;
        }
        cleanup();
        resolve({
          uuid,
          relayServer: rr.relay_server || "",
        });
      }
      // Otherwise keep the WS open; another message might be coming.
      // (A 30s onclose timer will fire if not.)
    };

    ws.onerror = () => {
      cleanup();
      reject(new Error(`requestRelay: WebSocket error connecting to ${wsUrl}`));
    };

    ws.onclose = (ev: CloseEvent) => {
      clearTimeout(timer);
      reject(new Error(`requestRelay: socket closed (code=${ev.code} reason=${ev.reason || "n/a"})`));
    };
  });
}

function failureMessage(ph: hbb.IPunchHoleResponse): string {
  if (ph.other_failure) return ph.other_failure;
  switch (ph.failure) {
    case hbb.PunchHoleResponse.Failure.ID_NOT_EXIST: return "Peer ID not registered with this server";
    case hbb.PunchHoleResponse.Failure.OFFLINE:      return "Peer is offline";
    case hbb.PunchHoleResponse.Failure.LICENSE_MISMATCH: return "License key mismatch";
    case hbb.PunchHoleResponse.Failure.LICENSE_OVERUSE:  return "License overuse";
    default: return "Rendezvous failed (peer offline or unknown reason)";
  }
}

/** RFC 4122 v4 UUID generator (browser crypto.randomUUID where available). */
export function genUuid(): string {
  const c = globalThis.crypto;
  if (typeof c.randomUUID === "function") {
    return c.randomUUID();
  }
  const b = new Uint8Array(16);
  c.getRandomValues(b);
  b[6] = (b[6] & 0x0f) | 0x40;
  b[8] = (b[8] & 0x3f) | 0x80;
  const h = Array.from(b, (x) => x.toString(16).padStart(2, "0"));
  return `${h.slice(0, 4).join("")}-${h.slice(4, 6).join("")}-${h.slice(6, 8).join("")}-${h.slice(8, 10).join("")}-${h.slice(10, 16).join("")}`;
}

export function rendezvousUrl(host: string, port = 21118): string {
  const proto = location.protocol === "https:" ? "wss" : "ws";
  return `${proto}://${host}:${port}/`;
}

export function relayUrl(host: string, port = 21119): string {
  const proto = location.protocol === "https:" ? "wss" : "ws";
  return `${proto}://${host}:${port}/`;
}
