// Discord WS + REST reverse proxy.
// Mounted on the hrana http.Server to proxy Discord Gateway (WebSocket)
// and REST API traffic through localhost. This enables cloud deployment
// where kimaki runs behind a reverse proxy / tunnel.
//
// The proxy is always registered on the hrana server for simplicity,
// but only matters when KIMAKI_INTERNET_REACHABLE_URL is set (which
// makes discordBaseUrl point to localhost instead of Discord directly).
//
// Upstream URL is configurable: in self-hosted mode it's https://discord.com,
// in gateway mode it's the Rust gateway-proxy (e.g. https://discord-gateway.kimaki.xyz).
// The upstream is captured at proxy creation time from the current discordBaseUrl.
//
// REST proxy: forwards /api/* to upstream /api/*
// WS proxy:   upgrades /gateway to upstream gateway WS
//
// Gateway URL rewrite: intercepts GET /api/v10/gateway/bot responses and
// rewrites the "url" field to ws://127.0.0.1:{port}/gateway so discord.js
// connects through the local proxy for WebSocket too.
//
// No retry logic — discord.js has its own reconnect/resume. The proxy is
// transparent: it pipes frames and propagates close events cleanly.
import http from 'node:http';
import stream from 'node:stream';
import WebSocket, { WebSocketServer } from 'ws';
import { createLogger } from './logger.js';
const logger = createLogger('PROXY');
const DEFAULT_REST_BASE = 'https://discord.com';
const DEFAULT_GATEWAY_BASE = 'wss://gateway.discord.gg';
/**
 * Derive an upstream WS gateway URL from a REST base URL.
 * For discord.com: returns wss://gateway.discord.gg (different host).
 * For gateway proxies: swaps https→wss / http→ws (same host).
 */
export function deriveGatewayBaseFromRest(restBase) {
    try {
        const parsed = new URL(restBase);
        // discord.com uses a different host for the gateway
        if (parsed.hostname === 'discord.com') {
            return DEFAULT_GATEWAY_BASE;
        }
        // Gateway proxies serve both REST and WS on the same host
        if (parsed.protocol === 'https:') {
            parsed.protocol = 'wss:';
        }
        else if (parsed.protocol === 'http:') {
            parsed.protocol = 'ws:';
        }
        return parsed.toString().replace(/\/$/, '');
    }
    catch {
        return DEFAULT_GATEWAY_BASE;
    }
}
/**
 * Create an HTTP request handler that proxies /api/* to an upstream REST API.
 *
 * getUpstreamBase resolves the upstream REST base URL at request time.
 * Called per-request so it picks up the correct upstream even when
 * credential resolution (gateway vs self-hosted) happens after server start.
 *
 * Intercepts GET /api/v10/gateway/bot to rewrite the gateway URL so discord.js
 * connects through the local WS proxy instead of directly to upstream.
 */
export function createDiscordRestProxyHandler({ localPort, getUpstreamBase, getGatewayToken, }) {
    return async (req, res) => {
        const url = new URL(req.url || '/', `http://127.0.0.1:${localPort}`);
        if (!url.pathname.startsWith('/api/')) {
            // Not a Discord REST path — let other handlers deal with it
            res.writeHead(404);
            res.end();
            return;
        }
        // Authenticate non-loopback REST requests with the gateway token.
        // When bound to 0.0.0.0, remote clients must prove they hold the secret
        // to prevent the proxy being used as an open relay to Discord's API.
        const remoteAddr = req.socket.remoteAddress || '';
        const isLoopback = isLoopbackAddress(remoteAddr);
        if (!isLoopback) {
            const expectedToken = getGatewayToken();
            const providedToken = req.headers['x-kimaki-auth'];
            if (!expectedToken || providedToken !== expectedToken) {
                res.writeHead(401, { 'content-type': 'application/json' });
                res.end(JSON.stringify({ error: 'unauthorized' }));
                return;
            }
        }
        const upstreamUrl = new URL(url.pathname + url.search, getUpstreamBase());
        // Forward headers, replacing Host
        const headers = {};
        for (const [key, value] of Object.entries(req.headers)) {
            if (key === 'host' || key === 'connection') {
                continue;
            }
            if (typeof value === 'string') {
                headers[key] = value;
            }
            else if (Array.isArray(value)) {
                headers[key] = value.join(', ');
            }
        }
        // Collect request body
        const chunks = [];
        for await (const chunk of req) {
            chunks.push(chunk);
        }
        const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined;
        const upstreamRes = await fetch(upstreamUrl.toString(), {
            method: req.method || 'GET',
            headers,
            body,
        }).catch((err) => {
            logger.error(`REST proxy error: ${err.message}`);
            res.writeHead(502, { 'content-type': 'application/json' });
            res.end(JSON.stringify({ error: 'upstream_error', message: err.message }));
            return null;
        });
        if (!upstreamRes) {
            return;
        }
        // Intercept /gateway/bot to rewrite the gateway URL
        const isGatewayBot = url.pathname.endsWith('/gateway/bot');
        if (isGatewayBot && upstreamRes.ok) {
            const responseBody = await upstreamRes.json();
            // Rewrite gateway URL to point to local WS proxy.
            // Embed the gateway token as a query param so discord.js authenticates
            // with the WS bridge (discord.js can't set custom headers on WS upgrade).
            const token = getGatewayToken();
            const tokenQuery = token ? `?token=${encodeURIComponent(token)}` : '';
            responseBody.url = `ws://127.0.0.1:${localPort}/gateway${tokenQuery}`;
            const rewritten = JSON.stringify(responseBody);
            // Forward relevant response headers
            const responseHeaders = {
                'content-type': 'application/json',
                'content-length': Buffer.byteLength(rewritten).toString(),
            };
            copyRateLimitHeaders(upstreamRes.headers, responseHeaders);
            res.writeHead(upstreamRes.status, responseHeaders);
            res.end(rewritten);
            return;
        }
        // Forward response as-is
        const responseHeaders = {};
        upstreamRes.headers.forEach((value, key) => {
            // Skip transfer-encoding since we buffer the full response
            if (key === 'transfer-encoding') {
                return;
            }
            responseHeaders[key] = value;
        });
        const responseBody = Buffer.from(await upstreamRes.arrayBuffer());
        responseHeaders['content-length'] = responseBody.length.toString();
        res.writeHead(upstreamRes.status, responseHeaders);
        res.end(responseBody);
    };
}
/**
 * Check if a socket remote address is loopback.
 * Accepts IPv6 loopback, IPv4 loopback range, and IPv4-mapped IPv6 loopback.
 */
export function isLoopbackAddress(remoteAddr) {
    return remoteAddr === '::1'
        || remoteAddr.startsWith('127.')
        || remoteAddr.startsWith('::ffff:127.');
}
/**
 * Copy Discord rate-limit headers from upstream response to proxy response.
 */
function copyRateLimitHeaders(source, target) {
    const rateLimitHeaders = [
        'x-ratelimit-limit',
        'x-ratelimit-remaining',
        'x-ratelimit-reset',
        'x-ratelimit-reset-after',
        'x-ratelimit-bucket',
        'x-ratelimit-global',
        'retry-after',
    ];
    for (const header of rateLimitHeaders) {
        const value = source.get(header);
        if (value) {
            target[header] = value;
        }
    }
}
// ── WebSocket bridge/relay ───────────────────────────────────────────────
//
// The WS bridge is a rendezvous point for two connections:
//   1. The gateway-proxy connects inbound (from the internet) on /gateway
//   2. discord.js connects locally on /gateway
//
// The bridge pairs the two and pipes frames bidirectionally.
// When either side closes, the other side is closed with the same code/reason.
//
// Connection ordering: the gateway-proxy may connect before or after discord.js.
// Unpaired connections wait up to 30s for their counterpart. If no match
// arrives, the connection is closed with 4000 "no counterpart connected".
//
// No retry logic — discord.js has its own reconnect/resume, and the
// gateway-proxy manages its own reconnection. The bridge is transparent.
const PAIR_TIMEOUT_MS = 30_000;
/**
 * Create a WebSocket upgrade handler that implements the bridge/relay pattern.
 * The gateway-proxy connects as "upstream" and discord.js connects as "local".
 * Both connect to /gateway on the hrana server; the bridge pairs them.
 *
 * Identification: the gateway-proxy sends a custom header
 * `X-Kimaki-Role: gateway` on its WS upgrade request. Connections without
 * this header are treated as local (discord.js) connections.
 *
 * Authentication: gateway connections must include an `Authorization` header
 * with the value `client_id:client_secret` matching the stored gateway token.
 * Local connections from 127.0.0.1 are always allowed (discord.js).
 */
export function createGatewayUpgradeHandler({ getGatewayToken, }) {
    const wss = new WebSocketServer({ noServer: true });
    // Waiting connections. At most one of each role waiting at a time.
    let waitingGateway = null;
    let waitingLocal = null;
    function tryPair() {
        if (!waitingGateway || !waitingLocal) {
            return;
        }
        const gateway = waitingGateway;
        const local = waitingLocal;
        waitingGateway = null;
        waitingLocal = null;
        clearTimeout(gateway.timer);
        clearTimeout(local.timer);
        bridgeConnections(gateway.ws, local.ws);
    }
    return (req, socket, head) => {
        const url = new URL(req.url || '/', 'http://localhost');
        if (url.pathname !== '/gateway') {
            socket.destroy();
            return;
        }
        const role = req.headers['x-kimaki-role'] === 'gateway' ? 'gateway' : 'local';
        // Authenticate ALL connections via Authorization header or query param.
        // Gateway-proxy sends token as Authorization header.
        // discord.js sends token as ?token= query param (can't set custom WS headers).
        // Reject if token is not yet configured (startup race) or doesn't match.
        const expectedToken = getGatewayToken();
        const providedToken = req.headers['authorization'] || url.searchParams.get('token');
        if (!expectedToken || providedToken !== expectedToken) {
            logger.error(`WS bridge: ${role} connection rejected — ${!expectedToken ? 'token not configured' : 'invalid auth'}`);
            socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
            socket.destroy();
            return;
        }
        wss.handleUpgrade(req, socket, head, (ws) => {
            logger.log(`WS bridge: ${role} connection established`);
            if (role === 'gateway') {
                // Close any existing waiting gateway connection
                if (waitingGateway) {
                    clearTimeout(waitingGateway.timer);
                    waitingGateway.ws.close(1000, 'replaced by new gateway connection');
                }
                const timer = setTimeout(() => {
                    logger.log('WS bridge: gateway timed out waiting for local');
                    waitingGateway = null;
                    ws.close(4000, 'no counterpart connected');
                }, PAIR_TIMEOUT_MS);
                waitingGateway = { ws, timer };
            }
            else {
                // Close any existing waiting local connection
                if (waitingLocal) {
                    clearTimeout(waitingLocal.timer);
                    waitingLocal.ws.close(1000, 'replaced by new local connection');
                }
                const timer = setTimeout(() => {
                    logger.log('WS bridge: local timed out waiting for gateway');
                    waitingLocal = null;
                    ws.close(4000, 'no counterpart connected');
                }, PAIR_TIMEOUT_MS);
                waitingLocal = { ws, timer };
            }
            // If a connection closes before being paired, clean up
            ws.on('close', () => {
                if (waitingGateway?.ws === ws) {
                    clearTimeout(waitingGateway.timer);
                    waitingGateway = null;
                }
                if (waitingLocal?.ws === ws) {
                    clearTimeout(waitingLocal.timer);
                    waitingLocal = null;
                }
            });
            tryPair();
        });
    };
}
/**
 * Bridge two WebSocket connections: pipe frames bidirectionally.
 * When either side closes, close the other with the same code/reason.
 */
function bridgeConnections(gateway, local) {
    logger.log('WS bridge: paired gateway ↔ local');
    let gatewayClosed = false;
    let localClosed = false;
    // Gateway → Local
    gateway.on('message', (data, isBinary) => {
        if (local.readyState === WebSocket.OPEN) {
            local.send(data, { binary: isBinary });
        }
    });
    // Local → Gateway
    local.on('message', (data, isBinary) => {
        if (gateway.readyState === WebSocket.OPEN) {
            gateway.send(data, { binary: isBinary });
        }
    });
    // Gateway closes → close local
    gateway.on('close', (code, reason) => {
        gatewayClosed = true;
        if (!localClosed) {
            const closeCode = isValidCloseCode(code) ? code : 1000;
            local.close(closeCode, reason);
        }
    });
    // Local closes → close gateway
    local.on('close', (code, reason) => {
        localClosed = true;
        if (!gatewayClosed) {
            const closeCode = isValidCloseCode(code) ? code : 1000;
            gateway.close(closeCode, reason);
        }
    });
    // Error handling
    gateway.on('error', (err) => {
        logger.error(`WS bridge gateway error: ${err.message}`);
        if (!localClosed) {
            local.close(1001, 'gateway error');
        }
    });
    local.on('error', (err) => {
        logger.error(`WS bridge local error: ${err.message}`);
        if (!gatewayClosed) {
            gateway.close(1001, 'local error');
        }
    });
}
/**
 * WebSocket close codes must be 1000 or in range 3000-4999 for application use.
 * discord.js uses codes like 4000-4014 for gateway-specific closes.
 */
function isValidCloseCode(code) {
    return code === 1000 || code === 1001 || (code >= 3000 && code <= 4999);
}
