// Minimal rosbridge v2 stand-in: hand-rolled RFC6455 (no deps) + synthetic
// FAST-LIVO2-shaped topics, so the viewer's live path can be exercised for real.
import http from 'node:http';
import crypto from 'node:crypto';
import zlib from 'node:zlib';

const PORT = 9090;

// ── websocket framing ────────────────────────────────────────────────────
function accept(key) {
  return crypto.createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
}
function encodeFrame(str) {
  const payload = Buffer.from(str, 'utf8');
  const len = payload.length;
  let header;
  if (len < 126) { header = Buffer.alloc(2); header[1] = len; }
  else if (len < 65536) { header = Buffer.alloc(4); header[1] = 126; header.writeUInt16BE(len, 2); }
  else { header = Buffer.alloc(10); header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2); }
  header[0] = 0x81;                   // FIN + text
  return Buffer.concat([header, payload]);
}
function* decodeFrames(buf) {         // client → server frames are masked
  let off = 0;
  while (off + 2 <= buf.length) {
    const b1 = buf[off + 1];
    let len = b1 & 127, p = off + 2;
    if (len === 126) { len = buf.readUInt16BE(p); p += 2; }
    else if (len === 127) { len = Number(buf.readBigUInt64BE(p)); p += 8; }
    const masked = b1 & 128;
    const mask = masked ? buf.subarray(p, p + 4) : null;
    if (masked) p += 4;
    if (p + len > buf.length) return;  // partial frame: wait for more
    const data = Buffer.from(buf.subarray(p, p + len));
    if (mask) for (let i = 0; i < data.length; i++) data[i] ^= mask[i % 4];
    yield { opcode: buf[off] & 15, text: data.toString('utf8') };
    off = p + len;
  }
}

// ── a tiny PNG encoder, to stand in for the camera's compressed frames ───
function crc32(buf) {
  let c, crc = 0xffffffff;
  for (let n = 0; n < buf.length; n++) {
    c = (crc ^ buf[n]) & 0xff;
    for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
    crc = (crc >>> 8) ^ c;
  }
  return (crc ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
  const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
  const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
  const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
  return Buffer.concat([len, td, crc]);
}
function png(w, h, paint) {
  const raw = Buffer.alloc(h * (w * 3 + 1));
  for (let y = 0; y < h; y++) {
    const row = y * (w * 3 + 1);
    raw[row] = 0;
    for (let x = 0; x < w; x++) {
      const [r, g, b] = paint(x, y);
      raw[row + 1 + x * 3] = r; raw[row + 2 + x * 3] = g; raw[row + 3 + x * 3] = b;
    }
  }
  const ihdr = Buffer.alloc(13);
  ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
  ihdr[8] = 8; ihdr[9] = 2;           // 8-bit RGB
  return Buffer.concat([
    Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
    chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))
  ]);
}

// ── synthetic FAST-LIVO2 session ─────────────────────────────────────────
const TOPICS = [
  ['/cloud_registered', 'sensor_msgs/msg/PointCloud2'],
  ['/cloud_effected',   'sensor_msgs/msg/PointCloud2'],
  ['/aft_mapped_to_init', 'nav_msgs/msg/Odometry'],
  ['/path',             'nav_msgs/msg/Path'],
  ['/origin_img',       'sensor_msgs/msg/CompressedImage'],
  ['/rosout',           'rcl_interfaces/msg/Log']
];

let t0 = Date.now();
const trail = [];
const pose = () => {
  const t = (Date.now() - t0) / 1000;
  return { x: 6 * Math.cos(t * 0.25), y: 6 * Math.sin(t * 0.25), z: 0.4 + 0.1 * Math.sin(t) };
};
const stamp = () => ({ sec: Math.floor(Date.now() / 1000), nanosec: (Date.now() % 1000) * 1e6 });

function cloudMsg(n = 2500) {
  const step = 16;                    // x,y,z float32 + rgb packed
  const buf = Buffer.alloc(n * step);
  const p = pose();
  for (let i = 0; i < n; i++) {
    const a = Math.random() * Math.PI * 2, r = 2 + Math.random() * 3;
    const x = p.x + r * Math.cos(a), y = p.y + r * Math.sin(a), z = Math.random() * 2.4;
    const o = i * step;
    buf.writeFloatLE(x, o); buf.writeFloatLE(y, o + 4); buf.writeFloatLE(z, o + 8);
    const t = Math.min(1, z / 2.4);
    const rgb = ((60 + 180 * t | 0) << 16) | ((90 + 100 * (1 - t) | 0) << 8) | (200 - 140 * t | 0);
    buf.writeUInt32LE(rgb >>> 0, o + 12);
  }
  return {
    header: { stamp: stamp(), frame_id: 'camera_init' },
    height: 1, width: n,
    fields: [
      { name: 'x', offset: 0, datatype: 7, count: 1 },
      { name: 'y', offset: 4, datatype: 7, count: 1 },
      { name: 'z', offset: 8, datatype: 7, count: 1 },
      { name: 'rgb', offset: 12, datatype: 7, count: 1 }
    ],
    is_bigendian: false, point_step: step, row_step: step * n,
    data: buf.toString('base64'), is_dense: true
  };
}
function odomMsg() {
  const p = pose();
  trail.push(p);
  if (trail.length > 600) trail.shift();
  return { header: { stamp: stamp(), frame_id: 'camera_init' }, child_frame_id: 'body',
    pose: { pose: { position: p, orientation: { x: 0, y: 0, z: 0, w: 1 } } } };
}
function pathMsg() {
  return { header: { stamp: stamp(), frame_id: 'camera_init' },
    poses: trail.map(p => ({ header: { stamp: stamp(), frame_id: 'camera_init' },
      pose: { position: p, orientation: { x: 0, y: 0, z: 0, w: 1 } } })) };
}
function imgMsg() {
  const t = (Date.now() - t0) / 400;
  const buf = png(320, 180, (x, y) => {
    const v = 40 + 90 * Math.sin(x / 22 + t) * Math.cos(y / 18 - t * 0.4);
    return [Math.max(0, v * 1.4) | 0, Math.max(0, v) | 0, Math.max(0, v * 0.7) | 0];
  });
  return { header: { stamp: stamp(), frame_id: 'camera' }, format: 'rgb8; png compressed bgr8',
    data: buf.toString('base64') };
}

// ── server ───────────────────────────────────────────────────────────────
const server = http.createServer((_, res) => res.end('mock rosbridge'));
server.on('upgrade', (req, socket) => {
  socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
    `Sec-WebSocket-Accept: ${accept(req.headers['sec-websocket-key'])}\r\n\r\n`);
  const send = o => socket.write(encodeFrame(JSON.stringify(o)));
  const timers = [];
  const pub = (topic, make, hz) => timers.push(setInterval(() => send({ op: 'publish', topic, msg: make() }), 1000 / hz));

  let acc = Buffer.alloc(0);
  socket.on('data', d => {
    acc = Buffer.concat([acc, d]);
    let consumed = 0;
    for (const fr of decodeFrames(acc)) {
      consumed = acc.length;                      // our client only sends small whole frames
      if (fr.opcode === 8) { socket.end(); return; }
      let m; try { m = JSON.parse(fr.text); } catch { continue; }
      console.log('→', m.op, m.service || m.topic || '');
      if (m.op === 'call_service' && m.service === '/rosapi/topics') {
        send({ op: 'service_response', service: m.service, id: m.id, result: true,
          values: { topics: TOPICS.map(t => t[0]), types: TOPICS.map(t => t[1]) } });
      } else if (m.op === 'subscribe') {
        if (m.topic === '/cloud_registered' || m.topic === '/cloud_effected') pub(m.topic, () => cloudMsg(), 10);
        else if (m.topic === '/aft_mapped_to_init') pub(m.topic, odomMsg, 20);
        else if (m.topic === '/path') pub(m.topic, pathMsg, 5);
        else if (m.topic === '/origin_img') pub(m.topic, imgMsg, 5);
      }
    }
    acc = acc.subarray(consumed);
  });
  socket.on('close', () => timers.forEach(clearInterval));
  socket.on('error', () => timers.forEach(clearInterval));
});
server.listen(PORT, () => console.log('mock rosbridge on ws://localhost:' + PORT));
