#!/usr/bin/env node

import { webcrypto } from "node:crypto";
import assert from "node:assert/strict";

const wsUrl = process.argv[2] ?? process.env.WS_URL ?? "ws://localhost:8080/ws";
const apiBaseUrl = process.argv[3] ?? process.env.API_BASE_URL;
const protocolVersion = 1;

const encoder = new TextEncoder();
const decoder = new TextDecoder();

function bytesToBase64(bytes) {
  return Buffer.from(bytes).toString("base64");
}

function base64ToBytes(value) {
  return Buffer.from(value, "base64");
}

async function createSession() {
  const key = await webcrypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 },
    true,
    ["encrypt", "decrypt"],
  );
  const rawKey = await webcrypto.subtle.exportKey("raw", key);

  return {
    key,
    keyId: webcrypto.randomUUID(),
    exportedKey: bytesToBase64(new Uint8Array(rawKey)),
  };
}

async function encryptMessage(session, message) {
  const nonce = webcrypto.getRandomValues(new Uint8Array(12));
  const plaintext = encoder.encode(JSON.stringify(message));
  const ciphertext = await webcrypto.subtle.encrypt(
    { name: "AES-GCM", iv: nonce },
    session.key,
    plaintext,
  );

  return {
    enc: `aes-256-gcm-v${protocolVersion}`,
    key_id: session.keyId,
    nonce: bytesToBase64(nonce),
    ciphertext: bytesToBase64(new Uint8Array(ciphertext)),
  };
}

async function decryptMessage(session, envelope) {
  const plaintext = await webcrypto.subtle.decrypt(
    { name: "AES-GCM", iv: base64ToBytes(envelope.nonce) },
    session.key,
    base64ToBytes(envelope.ciphertext),
  );

  return JSON.parse(decoder.decode(plaintext));
}

function waitForOpen(socket) {
  return new Promise((resolve, reject) => {
    socket.addEventListener("open", resolve, { once: true });
    socket.addEventListener("error", reject, { once: true });
  });
}

function waitForRawMessage(socket, timeoutMs = 5000) {
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      socket.removeEventListener("message", onMessage);
      reject(new Error(`timeout after ${timeoutMs}ms`));
    }, timeoutMs);

    function onMessage(event) {
      clearTimeout(timeout);
      resolve(String(event.data));
    }

    socket.addEventListener("message", onMessage, { once: true });
  });
}

async function waitForDecryptedType(client, expectedType, timeoutMs = 5000) {
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const raw = await waitForRawMessage(client.socket, Math.max(100, deadline - Date.now()));
    const wire = JSON.parse(raw);

    assert.equal(wire.type, "encrypted", `${client.name}: expected encrypted wire message`);
    assert.equal(wire.payload.key_id, client.session.keyId, `${client.name}: key id mismatch`);
    assertNoPlaintext(raw, client.name);

    const message = await decryptMessage(client.session, wire.payload);
    if (message.type === expectedType) {
      return message;
    }
  }

  throw new Error(`${client.name}: did not receive ${expectedType}`);
}

async function waitForRecordChanged(client, expectedTitle, timeoutMs = 5000) {
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const message = await waitForDecryptedType(
      client,
      "record_changed",
      Math.max(100, deadline - Date.now()),
    );
    if (!expectedTitle || message.payload.record?.title === expectedTitle) {
      return message;
    }
  }

  throw new Error(`${client.name}: did not receive record_changed ${expectedTitle}`);
}

function assertNoPlaintext(raw, clientName) {
  for (const forbidden of ["snapshot", "record_changed", "Erster Datensatz", "records"]) {
    assert.equal(
      raw.includes(forbidden),
      false,
      `${clientName}: raw frame leaked plaintext marker ${forbidden}`,
    );
  }
}

async function connectClient(name) {
  const session = await createSession();
  const socket = new WebSocket(wsUrl);

  await waitForOpen(socket);
  socket.send(JSON.stringify({
    type: "hello",
    payload: {
      protocol_version: protocolVersion,
      key_id: session.keyId,
      session_key: session.exportedKey,
    },
  }));

  const ack = JSON.parse(await waitForRawMessage(socket));
  assert.equal(ack.type, "hello_ack", `${name}: expected hello_ack`);
  assert.equal(ack.payload.protocol_version, protocolVersion, `${name}: protocol mismatch`);
  assert.equal(ack.payload.key_id, session.keyId, `${name}: ack key mismatch`);

  const client = { name, socket, session };
  const snapshot = await waitForDecryptedType(client, "snapshot");
  assert.ok(Array.isArray(snapshot.payload.records), `${name}: snapshot records missing`);

  await sendEncrypted(client, {
    type: "subscribe",
    payload: { topic: "records" },
  });

  return client;
}

async function sendEncrypted(client, message) {
  const envelope = await encryptMessage(client.session, message);
  client.socket.send(JSON.stringify({ type: "encrypted", payload: envelope }));
}

function closeClient(client) {
  if (client.socket.readyState === WebSocket.OPEN) {
    client.socket.close();
  }
}

async function request(method, path, body, token) {
  const response = await fetch(`${apiBaseUrl}${path}`, {
    method,
    headers: {
      "Content-Type": "application/json",
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const text = await response.text();
  const data = text ? JSON.parse(text) : {};

  if (!response.ok) {
    throw new Error(`${method} ${path} failed: ${response.status} ${JSON.stringify(data)}`);
  }

  return data;
}

async function createLiveEventViaApi() {
  const email = `live-event-${Date.now()}@example.test`;
  const bootstrap = await request("POST", "/api/v1/dev/bootstrap-local", {
    organization_name: "Live Event Test GmbH",
    email,
  });
  const login = await request("POST", "/api/v1/auth/login", {
    email,
    password: bootstrap.password,
  });
  const token = login.access_token;
  await request("POST", "/api/v1/auth/select-organization", {
    organization_id: login.organization_id,
  }, token);

  await request("POST", "/api/v1/activities", {
    activity_number: null,
    activity_type: "task",
    title: "Live-Event testen",
    body: "Änderung muss an alle Clients gehen.",
    status: "open",
    priority: "normal",
    due_at: null,
  }, token);
}

async function main() {
  console.log(`testing encrypted communication via ${wsUrl}`);

  const clientA = await connectClient("client-a");
  console.log("client-a handshake, encrypted snapshot and subscribe ok");

  const clientB = await connectClient("client-b");
  console.log("client-b handshake, encrypted snapshot and subscribe ok");

  await sendEncrypted(clientA, { type: "ping" });
  const [pongA, pongB] = await Promise.all([
    waitForDecryptedType(clientA, "pong"),
    waitForDecryptedType(clientB, "pong"),
  ]);

  assert.equal(pongA.type, "pong");
  assert.equal(pongB.type, "pong");
  assert.notEqual(clientA.session.keyId, clientB.session.keyId, "clients must use different keys");

  if (apiBaseUrl) {
    console.log(`testing api-triggered live event via ${apiBaseUrl}`);
    const waitA = waitForRecordChanged(clientA, "Aktivität angelegt");
    const waitB = waitForRecordChanged(clientB, "Aktivität angelegt");
    await createLiveEventViaApi();
    await Promise.all([waitA, waitB]);
    console.log("api-triggered live event reached both clients");
  }

  closeClient(clientA);
  closeClient(clientB);

  console.log("encrypted multi-client communication test ok");
  process.exit(0);
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});
