import type { ClientMessage, HostMessage, SignalMessage, SignalPayload } from "../types/net.js";
import { MAX_PLAYERS, PROTOCOL_VERSION } from "../types/net.js";
import { fail, isFiniteNumber, isRecord, isStringArray, ok, type ValidationResult } from "./common.js";

/**
 * Peer message validation.
 *
 * Guests are untrusted. Every `ClientMessage` the host acts on passes through
 * here first, and the validator *sanitises* rather than merely accepting:
 * movement is clamped to the unit square so a peer cannot claim a 1e9 input
 * vector and teleport, and identifiers are length-capped so a peer cannot
 * exhaust host memory with a single frame.
 */

/** Nothing legitimate approaches these; they exist to bound hostile input. */
const MAX_ID_LENGTH = 128;
const MAX_CHAT_LENGTH = 280;

const clampUnit = (value: number): number => (value < -1 ? -1 : value > 1 ? 1 : value);

const isSaneId = (value: unknown): value is string =>
  typeof value === "string" && value.length > 0 && value.length <= MAX_ID_LENGTH;

export function validateClientMessage(input: unknown): ValidationResult<ClientMessage> {
  if (!isRecord(input)) return fail("message must be an object");

  switch (input["t"]) {
    case "input": {
      const move = input["move"];
      if (!isRecord(move) || !isFiniteNumber(move["x"]) || !isFiniteNumber(move["z"])) {
        return fail("input.move must be {x, z} finite numbers");
      }
      if (!isFiniteNumber(input["tick"]) || input["tick"] < 0) {
        return fail("input.tick must be a non-negative number");
      }
      if (!isFiniteNumber(input["yaw"])) return fail("input.yaw must be finite");

      return ok({
        t: "input",
        tick: Math.floor(input["tick"]),
        move: { x: clampUnit(move["x"]), z: clampUnit(move["z"]) },
        run: input["run"] === true,
        yaw: input["yaw"],
      });
    }

    case "interact":
      if (!isSaneId(input["interactableId"])) return fail("interact.interactableId invalid");
      return ok({ t: "interact", interactableId: input["interactableId"] });

    case "combine":
      if (!isSaneId(input["itemA"]) || !isSaneId(input["itemB"])) {
        return fail("combine requires two item ids");
      }
      return ok({ t: "combine", itemA: input["itemA"], itemB: input["itemB"] });

    case "chat": {
      const text = input["text"];
      if (typeof text !== "string") return fail("chat.text must be a string");
      return ok({ t: "chat", text: text.slice(0, MAX_CHAT_LENGTH) });
    }

    default:
      return fail(`unknown client message type: ${String(input["t"])}`);
  }
}

/**
 * Guests validate host messages too. The host is authoritative over *game*
 * state, not over the guest's memory safety, and a compromised host should not
 * be able to crash the room.
 */
export function validateHostMessage(input: unknown): ValidationResult<HostMessage> {
  if (!isRecord(input)) return fail("message must be an object");

  switch (input["t"]) {
    case "snapshot": {
      if (!isFiniteNumber(input["tick"])) return fail("snapshot.tick must be finite");
      if (!Array.isArray(input["players"])) return fail("snapshot.players must be an array");
      if (input["players"].length > MAX_PLAYERS) return fail("snapshot.players exceeds MAX_PLAYERS");

      for (const player of input["players"]) {
        if (!isRecord(player) || !isFiniteNumber(player["peerId"])) {
          return fail("snapshot.players entry missing peerId");
        }
        const position = player["p"];
        if (
          !isRecord(position) ||
          !isFiniteNumber(position["x"]) ||
          !isFiniteNumber(position["y"]) ||
          !isFiniteNumber(position["z"])
        ) {
          return fail("snapshot.players entry has an invalid position");
        }
        if (!isFiniteNumber(player["yaw"])) return fail("snapshot player yaw must be finite");
      }
      return ok(input as unknown as HostMessage);
    }

    case "flags":
      if (!isStringArray(input["set"]) || !isStringArray(input["unset"])) {
        return fail("flags.set and flags.unset must be string arrays");
      }
      return ok(input as unknown as HostMessage);

    case "despawn":
      if (!isStringArray(input["interactableIds"])) {
        return fail("despawn.interactableIds must be a string array");
      }
      return ok(input as unknown as HostMessage);

    case "inventory":
      if (!isFiniteNumber(input["peerId"]) || !Array.isArray(input["items"])) {
        return fail("inventory requires peerId and items[]");
      }
      return ok(input as unknown as HostMessage);

    case "notice":
      if (typeof input["text"] !== "string") return fail("notice.text must be a string");
      return ok(input as unknown as HostMessage);

    case "chat":
      if (!isFiniteNumber(input["from"]) || typeof input["text"] !== "string") {
        return fail("chat requires from and text");
      }
      return ok({ t: "chat", from: input["from"], text: input["text"].slice(0, MAX_CHAT_LENGTH) });

    case "sync":
      if (typeof input["roomId"] !== "string") return fail("sync.roomId must be a string");
      if (!isStringArray(input["flags"]) || !isStringArray(input["despawned"])) {
        return fail("sync.flags and sync.despawned must be string arrays");
      }
      return ok(input as unknown as HostMessage);

    default:
      return fail(`unknown host message type: ${String(input["t"])}`);
  }
}

export function validateSignalMessage(input: unknown): ValidationResult<SignalMessage> {
  if (!isRecord(input)) return fail("message must be an object");

  switch (input["t"]) {
    case "hello":
      if (typeof input["token"] !== "string") return fail("hello.token must be a string");
      if (!isSaneId(input["lobbyCode"])) return fail("hello.lobbyCode invalid");
      if (input["protocol"] !== PROTOCOL_VERSION) {
        return fail(
          `protocol mismatch: peer speaks ${String(input["protocol"])}, server speaks ${PROTOCOL_VERSION}`,
        );
      }
      return ok({
        t: "hello",
        token: input["token"],
        lobbyCode: input["lobbyCode"],
        protocol: PROTOCOL_VERSION,
      });

    case "signal": {
      if (!isFiniteNumber(input["to"])) return fail("signal.to must be a peer id");
      if (!isRecord(input["payload"])) return fail("signal.payload must be an object");
      // `from` is assigned by the server from the authenticated socket, never
      // taken from the sender — otherwise a peer could impersonate another.
      return ok({
        t: "signal",
        from: -1,
        to: input["to"],
        payload: input["payload"] as SignalPayload,
      });
    }

    case "ready":
      return ok({ t: "ready", isReady: input["isReady"] === true });

    default:
      return fail(`unknown signal message type: ${String(input["t"])}`);
  }
}
