#!/usr/bin/env node
"use strict";

/*
  Shadow of the Fell Crown LAN relay
  No npm packages required. Works with modern Node.js.
  Start it on the host machine:

      node fell-crown-lan-server.js

  Players join the host's LAN address on port 8787, for example:
      192.168.1.42:8787
*/

const http = require("http");
const crypto = require("crypto");

const PORT = Number(process.env.PORT || 8787);
const HOST = process.env.HOST || "0.0.0.0";
const clients = new Map();
let hostClient = null;
let nextId = 1;

function frameText(text) {
  const payload = Buffer.from(text);
  let header;

  if (payload.length < 126) {
    header = Buffer.from([0x81, payload.length]);
  } else if (payload.length < 65536) {
    header = Buffer.alloc(4);
    header[0] = 0x81;
    header[1] = 126;
    header.writeUInt16BE(payload.length, 2);
  } else {
    header = Buffer.alloc(10);
    header[0] = 0x81;
    header[1] = 127;
    header.writeBigUInt64BE(BigInt(payload.length), 2);
  }
  return Buffer.concat([header, payload]);
}

function send(client, object) {
  if (!client.socket.destroyed) {
    client.socket.write(frameText(JSON.stringify(object)));
  }
}

function broadcast(object, except = null) {
  for (const client of clients.values()) {
    if (client !== except) send(client, object);
  }
}

function parseFrames(client, chunk) {
  client.buffer = Buffer.concat([client.buffer, chunk]);

  while (client.buffer.length >= 2) {
    const first = client.buffer[0];
    const second = client.buffer[1];
    const opcode = first & 0x0f;
    const masked = Boolean(second & 0x80);
    let length = second & 0x7f;
    let offset = 2;

    if (length === 126) {
      if (client.buffer.length < 4) return;
      length = client.buffer.readUInt16BE(2);
      offset = 4;
    } else if (length === 127) {
      if (client.buffer.length < 10) return;
      const big = client.buffer.readBigUInt64BE(2);
      if (big > BigInt(Number.MAX_SAFE_INTEGER)) {
        client.socket.destroy();
        return;
      }
      length = Number(big);
      offset = 10;
    }

    const maskBytes = masked ? 4 : 0;
    if (client.buffer.length < offset + maskBytes + length) return;

    let payload;
    if (masked) {
      const mask = client.buffer.subarray(offset, offset + 4);
      offset += 4;
      payload = Buffer.alloc(length);
      for (let i = 0; i < length; i++) {
        payload[i] = client.buffer[offset + i] ^ mask[i % 4];
      }
    } else {
      payload = client.buffer.subarray(offset, offset + length);
    }

    client.buffer = client.buffer.subarray(offset + length);

    if (opcode === 0x8) {
      client.socket.end();
      return;
    }
    if (opcode === 0x9) {
      client.socket.write(Buffer.concat([Buffer.from([0x8a, payload.length]), payload]));
      continue;
    }
    if (opcode !== 0x1) continue;

    try {
      handleMessage(client, JSON.parse(payload.toString("utf8")));
    } catch {
      send(client, { type: "error", message: "Invalid network packet." });
    }
  }
}

function handleMessage(client, message) {
  if (!message || typeof message.type !== "string") return;

  if (message.type === "claim-host") {
    if (hostClient && hostClient !== client) {
      send(client, { type: "error", message: "A host is already active on this LAN server." });
      return;
    }
    hostClient = client;
    client.role = "host";
    send(client, { type: "host-accepted", id: client.id });
    return;
  }

  if (message.type === "join") {
    if (!hostClient) {
      send(client, { type: "error", message: "No game is currently being hosted." });
      return;
    }
    client.role = "client";
    send(client, { type: "join-accepted", id: client.id });
    send(hostClient, { type: "peer-joined", id: client.id });
    return;
  }

  if (!client.role) {
    send(client, { type: "error", message: "Choose Host or Join first." });
    return;
  }

  const relayed = { ...message, id: client.id };

  if (message.type === "snapshot") {
    if (client !== hostClient) return;
    for (const other of clients.values()) {
      if (other.role === "client") send(other, relayed);
    }
    return;
  }

  if (message.type === "pose" || message.type === "cast") {
    broadcast(relayed, client);
  }
}

const server = http.createServer((req, res) => {
  res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
  res.end("Shadow of the Fell Crown LAN relay is running.\n");
});

server.on("upgrade", (req, socket) => {
  const key = req.headers["sec-websocket-key"];
  if (!key) {
    socket.destroy();
    return;
  }

  const accept = crypto
    .createHash("sha1")
    .update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
    .digest("base64");

  socket.write(
    "HTTP/1.1 101 Switching Protocols\r\n" +
    "Upgrade: websocket\r\n" +
    "Connection: Upgrade\r\n" +
    `Sec-WebSocket-Accept: ${accept}\r\n\r\n`
  );

  const client = {
    id: `player-${nextId++}`,
    socket,
    buffer: Buffer.alloc(0),
    role: null
  };
  clients.set(socket, client);
  send(client, { type: "welcome", id: client.id });

  socket.on("data", chunk => parseFrames(client, chunk));
  socket.on("error", () => socket.destroy());
  socket.on("close", () => {
    clients.delete(socket);
    broadcast({ type: "peer-left", id: client.id }, client);

    if (hostClient === client) {
      hostClient = null;
      broadcast({ type: "host-left" });
      for (const other of clients.values()) other.role = null;
    }
  });
});

server.listen(PORT, HOST, () => {
  console.log(`Shadow of the Fell Crown LAN relay listening on ws://${HOST}:${PORT}`);
  console.log("Keep this terminal open while playing.");
});