import { createServer as createHttpServer, } from "node:http";
import { createServer as createHttpsServer } from "node:https";
import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH, handleA2uiHttpRequest, } from "../canvas-host/a2ui.js";
import { loadConfig } from "../config/config.js";
import { resolveAgentAvatar } from "../agents/identity-avatar.js";
import { safeEqualSecret } from "../security/secret-equal.js";
import { isTailnetIPv4, isTailnetIPv6 } from "../infra/tailnet.js";
import { handleSlackHttpRequest } from "../slack/http/index.js";
import { authorizeGatewayConnect, isLocalDirectRequest } from "./auth.js";
import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js";
import { applyHookMappings } from "./hooks-mapping.js";
import { extractHookToken, getHookChannelError, normalizeAgentPayload, normalizeHookHeaders, normalizeWakePayload, readJsonBody, resolveHookChannel, resolveHookDeliver, } from "./hooks.js";
import { sendUnauthorized } from "./http-common.js";
import { getBearerToken, getHeader } from "./http-utils.js";
import { isPrivateOrLoopbackAddress, resolveGatewayClientIp } from "./net.js";
import { handleOpenAiHttpRequest } from "./openai-http.js";
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
const HOOK_AUTH_FAILURE_LIMIT = 20;
const HOOK_AUTH_FAILURE_WINDOW_MS = 60_000;
const HOOK_AUTH_FAILURE_TRACK_MAX = 2048;
function sendJson(res, status, body) {
    res.statusCode = status;
    res.setHeader("Content-Type", "application/json; charset=utf-8");
    res.end(JSON.stringify(body));
}
function isCanvasPath(pathname) {
    return (pathname === A2UI_PATH ||
        pathname.startsWith(`${A2UI_PATH}/`) ||
        pathname === CANVAS_HOST_PATH ||
        pathname.startsWith(`${CANVAS_HOST_PATH}/`) ||
        pathname === CANVAS_WS_PATH);
}
function hasAuthorizedWsClientForIp(clients, clientIp) {
    for (const client of clients) {
        if (client.clientIp && client.clientIp === clientIp) {
            return true;
        }
    }
    return false;
}
async function authorizeCanvasRequest(params) {
    const { req, auth, trustedProxies, clients } = params;
    if (isLocalDirectRequest(req, trustedProxies)) {
        return true;
    }
    const token = getBearerToken(req);
    if (token) {
        const authResult = await authorizeGatewayConnect({
            auth: { ...auth, allowTailscale: false },
            connectAuth: { token, password: token },
            req,
            trustedProxies,
        });
        if (authResult.ok) {
            return true;
        }
    }
    const clientIp = resolveGatewayClientIp({
        remoteAddr: req.socket?.remoteAddress ?? "",
        forwardedFor: getHeader(req, "x-forwarded-for"),
        realIp: getHeader(req, "x-real-ip"),
        trustedProxies,
    });
    if (!clientIp) {
        return false;
    }
    // IP-based fallback is only safe for machine-scoped addresses.
    // Only allow IP-based fallback for private/loopback addresses to prevent
    // cross-session access in shared-IP environments (corporate NAT, cloud).
    if (!isPrivateOrLoopbackAddress(clientIp)) {
        return false;
    }
    return hasAuthorizedWsClientForIp(clients, clientIp);
}
export function createHooksRequestHandler(opts) {
    const { getHooksConfig, bindHost, port, logHooks, dispatchAgentHook, dispatchWakeHook } = opts;
    const hookAuthFailures = new Map();
    const resolveHookClientKey = (req) => {
        return req.socket?.remoteAddress?.trim() || "unknown";
    };
    const recordHookAuthFailure = (clientKey, nowMs) => {
        if (!hookAuthFailures.has(clientKey) && hookAuthFailures.size >= HOOK_AUTH_FAILURE_TRACK_MAX) {
            hookAuthFailures.clear();
        }
        const current = hookAuthFailures.get(clientKey);
        const expired = !current || nowMs - current.windowStartedAtMs >= HOOK_AUTH_FAILURE_WINDOW_MS;
        const next = expired
            ? { count: 1, windowStartedAtMs: nowMs }
            : { count: current.count + 1, windowStartedAtMs: current.windowStartedAtMs };
        hookAuthFailures.set(clientKey, next);
        if (next.count <= HOOK_AUTH_FAILURE_LIMIT) {
            return { throttled: false };
        }
        const retryAfterMs = Math.max(1, next.windowStartedAtMs + HOOK_AUTH_FAILURE_WINDOW_MS - nowMs);
        return {
            throttled: true,
            retryAfterSeconds: Math.ceil(retryAfterMs / 1000),
        };
    };
    const clearHookAuthFailure = (clientKey) => {
        hookAuthFailures.delete(clientKey);
    };
    return async (req, res) => {
        const hooksConfig = getHooksConfig();
        if (!hooksConfig)
            return false;
        const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`);
        const basePath = hooksConfig.basePath;
        if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
            return false;
        }
        if (url.searchParams.has("token")) {
            res.statusCode = 400;
            res.setHeader("Content-Type", "text/plain; charset=utf-8");
            res.end("Hook token must be provided via Authorization: Bearer <token> or X-OpenClaw-Token header (query parameters are not allowed).");
            return true;
        }
        const { token } = extractHookToken(req, url);
        const clientKey = resolveHookClientKey(req);
        if (!safeEqualSecret(token, hooksConfig.token)) {
            const throttle = recordHookAuthFailure(clientKey, Date.now());
            if (throttle.throttled) {
                const retryAfter = throttle.retryAfterSeconds ?? 1;
                res.statusCode = 429;
                res.setHeader("Retry-After", String(retryAfter));
                res.setHeader("Content-Type", "text/plain; charset=utf-8");
                res.end("Too Many Requests");
                logHooks.warn(`hook auth throttled for ${clientKey}; retry-after=${retryAfter}s`);
                return true;
            }
            res.statusCode = 401;
            res.setHeader("Content-Type", "text/plain; charset=utf-8");
            res.end("Unauthorized");
            return true;
        }
        clearHookAuthFailure(clientKey);
        if (req.method !== "POST") {
            res.statusCode = 405;
            res.setHeader("Allow", "POST");
            res.setHeader("Content-Type", "text/plain; charset=utf-8");
            res.end("Method Not Allowed");
            return true;
        }
        const subPath = url.pathname.slice(basePath.length).replace(/^\/+/, "");
        if (!subPath) {
            res.statusCode = 404;
            res.setHeader("Content-Type", "text/plain; charset=utf-8");
            res.end("Not Found");
            return true;
        }
        const body = await readJsonBody(req, hooksConfig.maxBodyBytes);
        if (!body.ok) {
            const status = body.error === "payload too large" ? 413 : 400;
            sendJson(res, status, { ok: false, error: body.error });
            return true;
        }
        const payload = typeof body.value === "object" && body.value !== null ? body.value : {};
        const headers = normalizeHookHeaders(req);
        if (subPath === "wake") {
            const normalized = normalizeWakePayload(payload);
            if (!normalized.ok) {
                sendJson(res, 400, { ok: false, error: normalized.error });
                return true;
            }
            dispatchWakeHook(normalized.value);
            sendJson(res, 200, { ok: true, mode: normalized.value.mode });
            return true;
        }
        if (subPath === "agent") {
            const normalized = normalizeAgentPayload(payload);
            if (!normalized.ok) {
                sendJson(res, 400, { ok: false, error: normalized.error });
                return true;
            }
            const runId = dispatchAgentHook(normalized.value);
            sendJson(res, 202, { ok: true, runId });
            return true;
        }
        if (hooksConfig.mappings.length > 0) {
            try {
                const mapped = await applyHookMappings(hooksConfig.mappings, {
                    payload: payload,
                    headers,
                    url,
                    path: subPath,
                });
                if (mapped) {
                    if (!mapped.ok) {
                        sendJson(res, 400, { ok: false, error: mapped.error });
                        return true;
                    }
                    if (mapped.action === null) {
                        res.statusCode = 204;
                        res.end();
                        return true;
                    }
                    if (mapped.action.kind === "wake") {
                        dispatchWakeHook({
                            text: mapped.action.text,
                            mode: mapped.action.mode,
                        });
                        sendJson(res, 200, { ok: true, mode: mapped.action.mode });
                        return true;
                    }
                    const channel = resolveHookChannel(mapped.action.channel);
                    if (!channel) {
                        sendJson(res, 400, { ok: false, error: getHookChannelError() });
                        return true;
                    }
                    const runId = dispatchAgentHook({
                        message: mapped.action.message,
                        name: mapped.action.name ?? "Hook",
                        wakeMode: mapped.action.wakeMode,
                        sessionKey: mapped.action.sessionKey ?? "",
                        deliver: resolveHookDeliver(mapped.action.deliver),
                        channel,
                        to: mapped.action.to,
                        model: mapped.action.model,
                        thinking: mapped.action.thinking,
                        timeoutSeconds: mapped.action.timeoutSeconds,
                        allowUnsafeExternalContent: mapped.action.allowUnsafeExternalContent,
                    });
                    sendJson(res, 202, { ok: true, runId });
                    return true;
                }
            }
            catch (err) {
                logHooks.warn(`hook mapping failed: ${String(err)}`);
                sendJson(res, 500, { ok: false, error: "hook mapping failed" });
                return true;
            }
        }
        res.statusCode = 404;
        res.setHeader("Content-Type", "text/plain; charset=utf-8");
        res.end("Not Found");
        return true;
    };
}
export function createGatewayHttpServer(opts) {
    const { canvasHost, clients, controlUiEnabled, controlUiBasePath, openAiChatCompletionsEnabled, openResponsesEnabled, openResponsesConfig, handleHooksRequest, handlePluginRequest, resolvedAuth, } = opts;
    const httpServer = opts.tlsOptions
        ? createHttpsServer(opts.tlsOptions, (req, res) => {
            void handleRequest(req, res);
        })
        : createHttpServer((req, res) => {
            void handleRequest(req, res);
        });
    async function handleRequest(req, res) {
        // Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
        if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket")
            return;
        try {
            const configSnapshot = loadConfig();
            const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
            const requestPath = new URL(req.url ?? "/", "http://localhost").pathname;
            if (await handleHooksRequest(req, res))
                return;
            if (await handleToolsInvokeHttpRequest(req, res, {
                auth: resolvedAuth,
                trustedProxies,
            }))
                return;
            if (await handleSlackHttpRequest(req, res))
                return;
            if (handlePluginRequest) {
                // Channel HTTP endpoints are gateway-auth protected by default.
                // Non-channel plugin routes remain plugin-owned and must enforce
                // their own auth when exposing sensitive functionality.
                if (requestPath.startsWith("/api/channels/")) {
                    const token = getBearerToken(req);
                    const authResult = await authorizeGatewayConnect({
                        auth: resolvedAuth,
                        connectAuth: token ? { token, password: token } : null,
                        req,
                        trustedProxies,
                    });
                    if (!authResult.ok) {
                        sendUnauthorized(res);
                        return;
                    }
                }
                if (await handlePluginRequest(req, res))
                    return;
            }
            if (openResponsesEnabled) {
                if (await handleOpenResponsesHttpRequest(req, res, {
                    auth: resolvedAuth,
                    config: openResponsesConfig,
                    trustedProxies,
                }))
                    return;
            }
            if (openAiChatCompletionsEnabled) {
                if (await handleOpenAiHttpRequest(req, res, {
                    auth: resolvedAuth,
                    trustedProxies,
                }))
                    return;
            }
            if (canvasHost) {
                if (isCanvasPath(requestPath)) {
                    const ok = await authorizeCanvasRequest({
                        req,
                        auth: resolvedAuth,
                        trustedProxies,
                        clients,
                    });
                    if (!ok) {
                        sendUnauthorized(res);
                        return;
                    }
                }
                if (await handleA2uiHttpRequest(req, res)) {
                    return;
                }
                if (await canvasHost.handleHttpRequest(req, res)) {
                    return;
                }
            }
            if (controlUiEnabled) {
                if (handleControlUiAvatarRequest(req, res, {
                    basePath: controlUiBasePath,
                    resolveAvatar: (agentId) => resolveAgentAvatar(configSnapshot, agentId),
                }))
                    return;
                if (handleControlUiHttpRequest(req, res, {
                    basePath: controlUiBasePath,
                    config: configSnapshot,
                }))
                    return;
            }
            res.statusCode = 404;
            res.setHeader("Content-Type", "text/plain; charset=utf-8");
            res.end("Not Found");
        }
        catch (err) {
            res.statusCode = 500;
            res.setHeader("Content-Type", "text/plain; charset=utf-8");
            res.end(String(err));
        }
    }
    return httpServer;
}
/**
 * Validate WebSocket Origin header to prevent Cross-Site WebSocket Hijacking (CSWSH).
 * See CVE: GHSA-g8p2-7wf7-98mq
 *
 * Rules:
 * - Localhost origins (http://localhost:*, http://127.0.0.1:*, etc.) are always allowed
 * - Tailscale origins (*.ts.net domains, 100.64.0.0/10 IPv4, fd7a:115c:a1e0::/48 IPv6) are allowed
 * - Null origin (file:// protocol, privacy mode) is allowed for local clients
 * - Empty origin (non-browser clients) is allowed
 * - All other origins are rejected
 */
function isValidWebSocketOrigin(req) {
    const origin = req.headers.origin;
    // Non-browser clients may not send Origin header - allow
    if (origin === undefined || origin === "")
        return true;
    // Some browsers send "null" for file:// or privacy mode - allow for local access
    if (origin === "null")
        return true;
    try {
        const parsed = new URL(origin);
        const hostname = parsed.hostname.toLowerCase();
        // Allow localhost and loopback addresses
        if (hostname === "localhost")
            return true;
        if (hostname === "127.0.0.1")
            return true;
        if (hostname === "::1")
            return true;
        if (hostname === "[::1]")
            return true;
        if (hostname.startsWith("127."))
            return true;
        // Allow Tailscale serve domains
        if (hostname.endsWith(".ts.net"))
            return true;
        // Allow Tailscale IP addresses (100.64.0.0/10 IPv4, fd7a:115c:a1e0::/48 IPv6)
        if (isTailnetIPv4(hostname))
            return true;
        if (isTailnetIPv6(hostname))
            return true;
        return false;
    }
    catch {
        // Invalid URL - reject
        return false;
    }
}
export function attachGatewayUpgradeHandler(opts) {
    const { httpServer, wss, canvasHost, clients, resolvedAuth } = opts;
    httpServer.on("upgrade", (req, socket, head) => {
        void (async () => {
            if (canvasHost) {
                const url = new URL(req.url ?? "/", "http://localhost");
                if (url.pathname === CANVAS_WS_PATH) {
                    const configSnapshot = loadConfig();
                    const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
                    const ok = await authorizeCanvasRequest({
                        req,
                        auth: resolvedAuth,
                        trustedProxies,
                        clients,
                    });
                    if (!ok) {
                        socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
                        socket.destroy();
                        return;
                    }
                }
                if (canvasHost.handleUpgrade(req, socket, head)) {
                    return;
                }
            }
            // Security: Validate Origin header to prevent CSWSH attacks
            // See CVE: GHSA-g8p2-7wf7-98mq
            if (!isValidWebSocketOrigin(req)) {
                socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
                socket.destroy();
                return;
            }
            wss.handleUpgrade(req, socket, head, (ws) => {
                wss.emit("connection", ws, req);
            });
        })().catch(() => {
            socket.destroy();
        });
    });
}
