/**
 * Headless RFB (VNC) keepalive client.
 *
 * TigerVNC's Xvnc only flushes pending rendering into the readable framebuffer
 * while a VNC client is actively pulling updates. The Discord broadcast reads
 * that framebuffer with x11grab (it is NOT a VNC client), so with no viewer
 * attached Xvnc idles and the captured screen updates at ~1.5 fps - the stream
 * looks badly choppy even though Chrome renders at 60 fps. (Measured: 3/30
 * distinct frames without a client, 30/30 with one.)
 *
 * This client stays connected to the VNC server and continuously requests
 * incremental framebuffer updates, keeping the framebuffer fresh for the whole
 * duration of a broadcast. It is intentionally fail-open: any connection/auth
 * problem is logged and retried, never thrown, so it can never break the stream.
 */
import net from "node:net";
import crypto from "node:crypto";
import { readFileSync } from "node:fs";
import { homedir } from "node:os";

// VNC's DES variant uses each key byte with its bits mirrored.
function revByte(b: number): number {
  let r = 0;
  for (let i = 0; i < 8; i++) r = (r << 1) | ((b >> i) & 1);
  return r & 0xff;
}
function vncKey(buf: Buffer): Buffer {
  return Buffer.from([...buf.subarray(0, 8)].map(revByte));
}
function desEcb(key: Buffer, data: Buffer, decrypt = false): Buffer {
  const c = decrypt
    ? crypto.createDecipheriv("des-ecb", key, null)
    : crypto.createCipheriv("des-ecb", key, null);
  c.setAutoPadding(false);
  return Buffer.concat([c.update(data), c.final()]);
}

// The fixed key TigerVNC/RealVNC use to obfuscate the stored password file.
const FIXED_KEY = Buffer.from([23, 82, 107, 6, 35, 78, 88, 7]);

/** Decode an 8-byte obfuscated VNC password file payload to plaintext. */
export function decodeVncPassword(obf: Buffer): Buffer {
  const pt = desEcb(vncKey(FIXED_KEY), obf.subarray(0, 8), true);
  const z = pt.indexOf(0);
  return pt.subarray(0, z < 0 ? 8 : z);
}

/** Compute the 16-byte VncAuth response for a server challenge. */
export function vncChallengeResponse(password: Buffer, challenge: Buffer): Buffer {
  const key = Buffer.alloc(8);
  password.subarray(0, 8).copy(key);
  return desEcb(vncKey(key), challenge.subarray(0, 16));
}

/** Map an X display like ":1" to its TigerVNC RFB port (5900 + n). */
export function vncPortForDisplay(display: string): number {
  const n = parseInt(String(display).replace(/^:/, ""), 10);
  return 5900 + (Number.isFinite(n) ? n : 0);
}

/**
 * Resolve the VNC password: VNC_PASSWORD (plaintext) wins, otherwise decode the
 * obfuscated passwd file (VNC_PASSWD_FILE, default ~/.config/tigervnc/passwd).
 * Returns null when nothing is available (caller then skips the keepalive).
 */
export function resolveVncPassword(env: NodeJS.ProcessEnv = process.env): Buffer | null {
  if (env.VNC_PASSWORD) return Buffer.from(env.VNC_PASSWORD, "utf8").subarray(0, 8);
  const file = env.VNC_PASSWD_FILE || `${homedir()}/.config/tigervnc/passwd`;
  try {
    const obf = readFileSync(file);
    if (obf.length >= 8) return decodeVncPassword(obf);
  } catch {
    /* no file - fall through */
  }
  return null;
}

export class VncKeepalive {
  private sock: net.Socket | null = null;
  private timer: ReturnType<typeof setInterval> | null = null;
  private retry: ReturnType<typeof setTimeout> | null = null;
  private stopped = false;

  constructor(
    private opts: { host: string; port: number; password: Buffer; fps?: number },
  ) {}

  start(): void {
    this.stopped = false;
    this.connect();
  }

  stop(): void {
    this.stopped = true;
    if (this.timer) clearInterval(this.timer);
    if (this.retry) clearTimeout(this.retry);
    this.timer = this.retry = null;
    try {
      this.sock?.destroy();
    } catch {
      /* ignore */
    }
    this.sock = null;
  }

  private scheduleReconnect(): void {
    if (this.stopped || this.retry) return;
    this.retry = setTimeout(() => {
      this.retry = null;
      if (!this.stopped) this.connect();
    }, 2000);
  }

  private connect(): void {
    const sock = net.connect(this.opts.port, this.opts.host);
    this.sock = sock;
    sock.setNoDelay(true);

    let buf = Buffer.alloc(0);
    const waiters: { n: number; res: (b: Buffer) => void }[] = [];
    const pump = () => {
      while (waiters.length && buf.length >= waiters[0].n) {
        const w = waiters.shift()!;
        const d = buf.subarray(0, w.n);
        buf = buf.subarray(w.n);
        w.res(d);
      }
    };
    const onData = (d: Buffer) => {
      buf = Buffer.concat([buf, d]);
      pump();
    };
    sock.on("data", onData);
    sock.on("error", () => {
      /* handled by close */
    });
    sock.on("close", () => {
      if (this.sock === sock) {
        this.sock = null;
        if (this.timer) {
          clearInterval(this.timer);
          this.timer = null;
        }
        this.scheduleReconnect();
      }
    });
    const read = (n: number) =>
      new Promise<Buffer>((res) => {
        waiters.push({ n, res });
        pump();
      });

    (async () => {
      try {
        await read(12); // ProtocolVersion
        sock.write("RFB 003.008\n");
        const nTypes = (await read(1))[0];
        const types = await read(nTypes);
        if (types.includes(2)) {
          sock.write(Buffer.from([2])); // VNC Auth
          const challenge = await read(16);
          sock.write(vncChallengeResponse(this.opts.password, challenge));
          if ((await read(4)).readUInt32BE(0) !== 0) return sock.destroy();
        } else if (types.includes(1)) {
          sock.write(Buffer.from([1])); // None
          if ((await read(4)).readUInt32BE(0) !== 0) return sock.destroy();
        } else {
          return sock.destroy();
        }
        sock.write(Buffer.from([1])); // ClientInit (shared)
        const si = await read(24);
        const w = si.readUInt16BE(0);
        const h = si.readUInt16BE(2);
        await read(si.readUInt32BE(20)); // desktop name
        sock.write(Buffer.from([2, 0, 0, 1, 0, 0, 0, 0])); // SetEncodings: Raw

        // Past the handshake: stop buffering and just drain whatever the server
        // sends so its send buffer never blocks (we never decode the pixels).
        sock.removeListener("data", onData);
        sock.on("data", () => {});
        buf = Buffer.alloc(0);

        const req = Buffer.from([3, 1, 0, 0, 0, 0, 0, 0, 0, 0]);
        req.writeUInt16BE(w, 6);
        req.writeUInt16BE(h, 8);
        const interval = Math.max(1, Math.round(1000 / (this.opts.fps ?? 60)));
        this.timer = setInterval(() => {
          try {
            if (this.sock === sock && sock.writable) sock.write(req);
          } catch {
            /* ignore */
          }
        }, interval);
      } catch {
        try {
          sock.destroy();
        } catch {
          /* ignore */
        }
      }
    })();
  }
}
