/**
 * rc-notifications-bun
 *
 * Bun port of the rc-notifications-proxy Cloudflare Worker.
 * Stateless thin HTTP proxy: forwards RingCentral webhook POSTs to a
 * downstream URL (configured via RC_DOWNSTREAM_URL) and echoes the RC
 * webhook-validation handshake header (Validation-Token).
 *
 * Routes:
 *   GET|POST /health  -> {"ok":true,"proxy":"rc-notifications"}
 *   POST     /        -> handshake echo OR forward to RC_DOWNSTREAM_URL
 *
 * Listens on PORT (default 18803). Behind Tailscale Funnel at
 * https://claude-cloud.tail053faf.ts.net/rc.
 */

const PORT = Number(process.env.PORT ?? 18803);
const RC_DOWNSTREAM_URL = process.env.RC_DOWNSTREAM_URL ?? "";
const WEBHOOK_SECRET = process.env.RC_WEBHOOK_SECRET ?? "";
// Static shared secret RingCentral sends as `Verification-Token` on every
// non-handshake delivery (configured when the webhook subscription is
// created). When set, the proxy rejects requests that don't carry a
// matching token. This is the primary inbound auth for the public Funnel.
const RC_EXPECTED_VERIFICATION_TOKEN =
  process.env.RC_EXPECTED_VERIFICATION_TOKEN ?? "";
// Hard cap on inbound webhook bodies. RC payloads are typically a few KB;
// anything multi-MB is almost certainly abuse on a public endpoint.
const MAX_BODY_BYTES = 1024 * 1024; // 1 MiB
// Abort downstream forward after this many ms so a hung tunnel can't pin
// connections in the proxy.
const DOWNSTREAM_TIMEOUT_MS = 30_000;

function log(msg: string): void {
  process.stderr.write(`[rc-notifications] ${new Date().toISOString()} ${msg}\n`);
}

async function hmacHex(secret: string, body: ArrayBuffer): Promise<string> {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  // Sign the raw payload bytes (not a re-encoded UTF-8 string) so the
  // X-RC-Webhook-Signature covers the exact bytes forwarded downstream.
  const sig = await crypto.subtle.sign("HMAC", key, body);
  return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}

const server = Bun.serve({
  port: PORT,
  // Bind to loopback only. Tailscale Funnel terminates locally on this VM
  // and proxies to 127.0.0.1, so binding all interfaces would expose port
  // 18803 on the VM's public network surface for no functional reason.
  hostname: "127.0.0.1",
  // Bun defaults to 128 MB; cap at MAX_BODY_BYTES so the runtime itself
  // rejects oversized requests before the handler runs.
  maxRequestBodySize: MAX_BODY_BYTES,
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/health" || url.pathname === "/rc/health") {
      return new Response(
        JSON.stringify({ ok: true, proxy: "rc-notifications" }),
        { headers: { "Content-Type": "application/json" } },
      );
    }

    // Only accept POST for the main webhook endpoint. Allow trailing-slash and
    // /rc prefix in case Tailscale serve passes the original path through.
    const isRoot =
      url.pathname === "/" ||
      url.pathname === "/rc" ||
      url.pathname === "/rc/";

    if (!isRoot) {
      return new Response("not found", { status: 404 });
    }

    // RingCentral webhook validation handshake — must respond immediately,
    // echoing the Validation-Token in both body and header.
    const validationToken = request.headers.get("Validation-Token");
    if (validationToken) {
      log(`handshake: echoing Validation-Token (${validationToken.length} chars)`);
      return new Response(validationToken, {
        status: 200,
        headers: {
          "Validation-Token": validationToken,
          "Content-Type": "text/plain",
        },
      });
    }

    if (request.method !== "POST") {
      return new Response("method not allowed", { status: 405 });
    }

    // Inbound auth: when RC_EXPECTED_VERIFICATION_TOKEN is configured, every
    // non-handshake request must carry a matching Verification-Token header.
    // Funnel exposes the endpoint publicly so this is the primary defense
    // against random POSTs being forwarded downstream.
    if (RC_EXPECTED_VERIFICATION_TOKEN) {
      const incoming = request.headers.get("Verification-Token") ?? "";
      if (incoming !== RC_EXPECTED_VERIFICATION_TOKEN) {
        log(
          `rejected: Verification-Token mismatch (len=${incoming.length}, ua="${request.headers.get("user-agent") ?? ""}")`,
        );
        return new Response("unauthorized", { status: 401 });
      }
    }

    if (!RC_DOWNSTREAM_URL) {
      log("RC_DOWNSTREAM_URL not configured — dropping request");
      return new Response(
        JSON.stringify({ error: "downstream_not_configured" }),
        { status: 503, headers: { "Content-Type": "application/json" } },
      );
    }

    // Reject oversize bodies up front via the advertised Content-Length so
    // we never buffer multi-MB payloads in memory on a public endpoint.
    const declaredLen = Number(request.headers.get("content-length") ?? "0");
    if (Number.isFinite(declaredLen) && declaredLen > MAX_BODY_BYTES) {
      log(`rejected: content-length ${declaredLen} exceeds ${MAX_BODY_BYTES}`);
      return new Response("payload too large", { status: 413 });
    }

    // Read as raw bytes (not text). Decoding via request.text() would
    // re-encode the payload as UTF-8, which can mangle non-UTF-8 byte
    // sequences and break the HMAC signature on the receiving end. We
    // want the bytes RC sent us to be the bytes we sign and forward.
    const body = await request.arrayBuffer();
    // Defense-in-depth: also enforce the cap after read (handles chunked
    // requests that don't declare Content-Length).
    if (body.byteLength > MAX_BODY_BYTES) {
      log(`rejected: body length ${body.byteLength} exceeds ${MAX_BODY_BYTES}`);
      return new Response("payload too large", { status: 413 });
    }

    // Strip hop-by-hop and host headers; pass everything else through.
    // Use append() rather than set() so multi-value headers (e.g. Via,
    // Forwarded) are preserved in their original order.
    const headers = new Headers();
    for (const [k, v] of request.headers) {
      const lower = k.toLowerCase();
      if (
        lower === "host" ||
        lower === "content-length" ||
        lower === "connection" ||
        lower === "transfer-encoding"
      ) {
        continue;
      }
      headers.append(k, v);
    }
    headers.set("X-Forwarded-Host", url.hostname);

    if (WEBHOOK_SECRET) {
      headers.set("X-RC-Webhook-Signature", await hmacHex(WEBHOOK_SECRET, body));
    }

    // Bound the downstream call so a hung tunnel can't accumulate sockets
    // in the proxy.
    const ac = new AbortController();
    const timer = setTimeout(() => ac.abort(), DOWNSTREAM_TIMEOUT_MS);
    try {
      const downstream = await fetch(RC_DOWNSTREAM_URL, {
        method: "POST",
        headers,
        body,
        signal: ac.signal,
      });
      log(`forwarded -> ${RC_DOWNSTREAM_URL} (${downstream.status})`);
      // Pass body+status straight back. Strip downstream's content-encoding/length
      // so Bun re-frames the response cleanly.
      const respHeaders = new Headers();
      for (const [k, v] of downstream.headers) {
        const lower = k.toLowerCase();
        if (
          lower === "content-encoding" ||
          lower === "content-length" ||
          lower === "transfer-encoding" ||
          lower === "connection"
        ) {
          continue;
        }
        respHeaders.append(k, v);
      }
      return new Response(downstream.body, {
        status: downstream.status,
        statusText: downstream.statusText,
        headers: respHeaders,
      });
    } catch (err) {
      const aborted = (err as { name?: string })?.name === "AbortError";
      log(`downstream error${aborted ? " (timeout)" : ""}: ${String(err)}`);
      return new Response(
        JSON.stringify({
          error: aborted ? "downstream_timeout" : "tunnel_unreachable",
        }),
        {
          status: aborted ? 504 : 502,
          headers: { "Content-Type": "application/json" },
        },
      );
    } finally {
      clearTimeout(timer);
    }
  },
  error(err: Error): Response {
    log(`server error: ${err.message}`);
    return new Response("internal error", { status: 500 });
  },
});

log(`listening on http://${server.hostname}:${server.port} (downstream=${RC_DOWNSTREAM_URL || "<unset>"})`);

// Graceful shutdown
const shutdown = (sig: string) => {
  log(`received ${sig}, shutting down`);
  server.stop();
  process.exit(0);
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
