// node --experimental-strip-types --test
//
// Exercises the Room DO's per-peer routing (mesh P2): client→host frames get a sender tag, and
// host→client frames are demuxed by tag to exactly one client. `fetch` isn't tested here (it needs
// the runtime's WebSocketPair); we drive `webSocketMessage` against fake sockets pushed into a fake
// DurableObjectState, which is where the routing logic lives.
import { test } from "node:test";
import assert from "node:assert/strict";
import { Room } from "../src/room.ts";

interface Meta {
  role: "host" | "client";
  deviceId: string;
  /** Precomputed routing-tag hex (optional — old attachments lack it; routing backfills). */
  tagHex?: string;
}

class FakeWS {
  tags: string[] = [];
  sent: Array<ArrayBuffer | string> = [];
  closed: { code: number; reason?: string } | null = null;
  meta: Meta;
  constructor(meta: Meta) {
    this.meta = meta;
  }
  serializeAttachment(a: Meta): void {
    this.meta = a;
  }
  deserializeAttachment(): Meta {
    return this.meta;
  }
  send(m: ArrayBuffer | string): void {
    if (this.closed) throw new Error("closed");
    this.sent.push(m);
  }
  close(code: number, reason?: string): void {
    this.closed = { code, reason };
  }
}

class FakeState {
  sockets: FakeWS[] = [];
  id = { toString: () => "room-do-id" };
  /** Map-backed stand-in for DO storage — the room lifecycle log lives here. */
  storage = {
    map: new Map<string, unknown>(),
    async get(key: string): Promise<unknown> {
      return this.map.get(key);
    },
    async put(key: string, value: unknown): Promise<void> {
      this.map.set(key, value);
    },
  };
  add(ws: FakeWS): void {
    ws.tags = [ws.deserializeAttachment().role, ws.deserializeAttachment().deviceId];
    this.sockets.push(ws);
  }
  getWebSockets(tag?: string): FakeWS[] {
    return tag ? this.sockets.filter((s) => s.tags.includes(tag)) : this.sockets;
  }
  acceptWebSocket(): void {
    /* not exercised here */
  }
}

function room(state: FakeState): Room {
  return new Room(state as unknown as DurableObjectState, {} as never);
}

async function deviceTag(deviceId: string): Promise<Uint8Array> {
  const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(deviceId));
  return new Uint8Array(d).slice(0, 8);
}

function ws(role: "host" | "client", deviceId: string): FakeWS {
  const w = new FakeWS({ role, deviceId });
  return w;
}

// MARK: - Runner wake (relay presence → pool wake, COVALENCE_RUNNER §3)

function roomWithEnv(state: FakeState, env: Record<string, string>): Room {
  return new Room(state as unknown as DurableObjectState, env as never);
}

const RUNNER_ROOM = "a".repeat(64);

test("runner wake fires once with the bearer + roomId, then throttles", async () => {
  const r = roomWithEnv(new FakeState(), {
    RUNNER_WAKE_URL: "https://runner/v1/hook/wake",
    RUNNER_WAKE_SECRET: "s3cret",
  });
  const calls: Array<{ url: string; init: RequestInit }> = [];
  const fetcher = (async (url: RequestInfo | URL, init?: RequestInit) => {
    calls.push({ url: String(url), init: init ?? {} });
    return new Response("ok");
  }) as typeof fetch;

  assert.equal(await r.maybeWakeRunner(RUNNER_ROOM, 1_000, fetcher), true);
  assert.equal(calls.length, 1);
  assert.equal(calls[0].url, "https://runner/v1/hook/wake");
  const headers = calls[0].init.headers as Record<string, string>;
  assert.equal(headers.authorization, "Bearer s3cret");
  assert.deepEqual(JSON.parse(String(calls[0].init.body)), { roomId: RUNNER_ROOM });

  // Inside the 30s window: suppressed (a reconnect storm must not hammer the hook)…
  assert.equal(await r.maybeWakeRunner(RUNNER_ROOM, 20_000, fetcher), false);
  assert.equal(calls.length, 1);
  // …after it: fires again.
  assert.equal(await r.maybeWakeRunner(RUNNER_ROOM, 40_000, fetcher), true);
  assert.equal(calls.length, 2);
});

test("runner wake is a no-op when unconfigured or the roomId is missing", async () => {
  const fetcher = (async () => {
    throw new Error("must not be called");
  }) as unknown as typeof fetch;
  // No env at all (an ordinary relay deploy).
  assert.equal(await roomWithEnv(new FakeState(), {}).maybeWakeRunner(RUNNER_ROOM, 0, fetcher), false);
  // URL without secret, secret without URL, missing roomId.
  assert.equal(
    await roomWithEnv(new FakeState(), { RUNNER_WAKE_URL: "https://r" }).maybeWakeRunner(RUNNER_ROOM, 0, fetcher),
    false,
  );
  assert.equal(
    await roomWithEnv(new FakeState(), { RUNNER_WAKE_SECRET: "s" }).maybeWakeRunner(RUNNER_ROOM, 0, fetcher),
    false,
  );
  assert.equal(
    await roomWithEnv(new FakeState(), {
      RUNNER_WAKE_URL: "https://r",
      RUNNER_WAKE_SECRET: "s",
    }).maybeWakeRunner(null, 0, fetcher),
    false,
  );
});

test("runner wake swallows an unreachable control plane", async () => {
  const r = roomWithEnv(new FakeState(), {
    RUNNER_WAKE_URL: "https://runner/v1/hook/wake",
    RUNNER_WAKE_SECRET: "s3cret",
  });
  const fetcher = (async () => {
    throw new Error("connect refused");
  }) as unknown as typeof fetch;
  // Still counts as an attempt (throttled) and never throws into the connect path.
  assert.equal(await r.maybeWakeRunner(RUNNER_ROOM, 0, fetcher), true);
});

test("client→host frame is delivered to the host with a sender tag prepended", async () => {
  const state = new FakeState();
  const host = ws("host", "host-dev");
  const phone = ws("client", "phone-A");
  state.add(host);
  state.add(phone);

  const payload = new Uint8Array([1, 2, 3, 4]).buffer;
  await room(state).webSocketMessage(phone as never, payload);

  assert.equal(host.sent.length, 1);
  const framed = new Uint8Array(host.sent[0] as ArrayBuffer);
  assert.equal(framed[0], 0x01); // envelope version
  assert.deepEqual(framed.subarray(1, 9), await deviceTag("phone-A")); // sender tag
  assert.deepEqual(framed.subarray(9), new Uint8Array([1, 2, 3, 4])); // untouched payload
  assert.equal(phone.sent.length, 0); // not echoed back to the sender
});

test("host→client frame is demuxed by tag to exactly the addressed client", async () => {
  const state = new FakeState();
  const host = ws("host", "host-dev");
  const phoneA = ws("client", "phone-A");
  const phoneB = ws("client", "phone-B");
  state.add(host);
  state.add(phoneA);
  state.add(phoneB);

  const raw = new Uint8Array([9, 8, 7]);
  const tag = await deviceTag("phone-B");
  const framed = new Uint8Array(9 + raw.length);
  framed[0] = 0x01;
  framed.set(tag, 1);
  framed.set(raw, 9);
  await room(state).webSocketMessage(host as never, framed.buffer);

  assert.equal(phoneB.sent.length, 1);
  assert.deepEqual(new Uint8Array(phoneB.sent[0] as ArrayBuffer), raw); // envelope stripped
  assert.equal(phoneA.sent.length, 0); // NOT broadcast to the other client
  assert.equal(host.sent.length, 0);
});

test("host frame with an unknown tag is dropped, not broadcast", async () => {
  const state = new FakeState();
  const host = ws("host", "host-dev");
  const phoneA = ws("client", "phone-A");
  state.add(host);
  state.add(phoneA);

  const framed = new Uint8Array(9 + 2);
  framed[0] = 0x01;
  framed.set(await deviceTag("phone-ghost"), 1);
  framed.set(new Uint8Array([5, 5]), 9);
  await room(state).webSocketMessage(host as never, framed.buffer);

  assert.equal(phoneA.sent.length, 0);
});

test("routing backfills tagHex into a pre-tagHex attachment and then routes off it", async () => {
  const state = new FakeState();
  const host = ws("host", "host-dev");
  const phone = ws("client", "phone-A"); // attachment has no tagHex (old hibernated socket)
  state.add(host);
  state.add(phone);

  await room(state).webSocketMessage(phone as never, new Uint8Array([1]).buffer);
  const expected = [...(await deviceTag("phone-A"))].map((b) => b.toString(16).padStart(2, "0")).join("");
  assert.equal(phone.deserializeAttachment().tagHex, expected); // backfilled once

  // A precomputed (even attacker-supplied-nonsense) tagHex is what routing trusts — pin that
  // the fast path reads the attachment rather than re-deriving from deviceId.
  const phoneOdd = new FakeWS({ role: "client", deviceId: "phone-B", tagHex: "00".repeat(8) });
  state.add(phoneOdd);
  const framed = new Uint8Array(9 + 1);
  framed[0] = 0x01; // version + all-zero tag targets phoneOdd's stored tagHex
  framed.set(new Uint8Array([7]), 9);
  await room(state).webSocketMessage(host as never, framed.buffer);
  assert.equal(phoneOdd.sent.length, 1);
});

test("client→host frame is dropped when no host is present", async () => {
  const state = new FakeState();
  const phoneA = ws("client", "phone-A");
  state.add(phoneA);
  await room(state).webSocketMessage(phoneA as never, new Uint8Array([1]).buffer);
  assert.equal(phoneA.sent.length, 0); // nothing to route to; no throw
});

test("room lifecycle events land in the capped log and /roomlog serves them", async () => {
  const state = new FakeState();
  const phone = ws("client", "phone-A");
  state.add(phone);
  const r = room(state);

  await r.webSocketClose(phone as never, 1006);
  await r.webSocketError(phone as never);

  const res = await r.fetch(new Request("https://room/roomlog"));
  assert.equal(res.status, 200);
  const body = (await res.json()) as { events: Array<{ at: number; ev: string }> };
  assert.equal(body.events.length, 2);
  assert.match(body.events[0].ev, /^close client device=phone-A code=1006$/);
  assert.match(body.events[1].ev, /^error client device=phone-A$/);
  assert.ok(body.events.every((e) => typeof e.at === "number"));

  // The log is capped: flooding closes never grows it past the cap.
  for (let i = 0; i < 250; i++) await r.webSocketClose(phone as never, 1000);
  const capped = (await (await r.fetch(new Request("https://room/roomlog"))).json()) as {
    events: unknown[];
  };
  assert.ok(capped.events.length <= 200);
});

test("an oversized frame closes the socket with a policy code", async () => {
  const state = new FakeState();
  const host = ws("host", "host-dev");
  const phone = ws("client", "phone-A");
  state.add(host);
  state.add(phone);

  const huge = new ArrayBuffer(20 * 1024 * 1024 + 1);
  await room(state).webSocketMessage(phone as never, huge);
  assert.equal(phone.closed?.code, 1008);
  assert.equal(host.sent.length, 0);
});
