/**
 * Per-box hosted webhook receivers: config + daemon host (RUSH-2548).
 *
 * `~/.agents/daemon/webhooks.yaml` declares which signed webhook receivers THIS
 * box hosts — bundle, local port, rate limit, and an optional public Tailscale
 * Funnel port. The daemon's `webhook-receiver` service reads it and binds one
 * receiver per entry, drawing each receiver's signing secret from the broker /
 * machine-local file store (no `AGENTS_SECRETS_PASSPHRASE`, no `nohup`). An
 * absent or empty file hosts nothing, so an unconfigured box binds nothing.
 *
 * This is per-box operational state (a public receiver runs on exactly one box),
 * so it is deliberately NOT part of the fleet-synced device config — it mirrors
 * `services.yaml` / daemon-services.ts, not `agents config`.
 */
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'yaml';
import { exec } from 'child_process';
import { getDaemonConfigDir, getRuntimeStateDir } from './state.js';
import { atomicWriteFileSync } from './fs-atomic.js';
import { closeServerBounded } from './secrets/agent.js';
import { readAndResolveBundleEnv } from './secrets/bundles.js';
import { startWebhookServer, createFileDeliveryStore, waitForListening } from './triggers/webhook.js';
import { buildFunnelUpCommand, FUNNEL_PORTS } from './funnel.js';
export const DEFAULT_WEBHOOK_PORT = 8787;
export const DEFAULT_WEBHOOK_RATE_LIMIT = 60;
/** Path to `~/.agents/daemon/webhooks.yaml`. */
export function getDaemonWebhooksConfigPath() {
    return path.join(getDaemonConfigDir(), 'webhooks.yaml');
}
function coerceReceiver(item) {
    if (!item || typeof item !== 'object')
        return null;
    const obj = item;
    if (typeof obj.bundle !== 'string' || obj.bundle.length === 0)
        return null;
    const rc = { bundle: obj.bundle };
    if (typeof obj.port === 'number' && Number.isInteger(obj.port) && obj.port > 0)
        rc.port = obj.port;
    if (typeof obj.rateLimit === 'number' && Number.isInteger(obj.rateLimit) && obj.rateLimit > 0)
        rc.rateLimit = obj.rateLimit;
    const funnel = obj.funnel;
    // A publicPort Funnel cannot serve is dropped rather than carried forward — the
    // receiver still binds localhost, and `daemon webhooks add` rejects it up front.
    if (funnel && typeof funnel === 'object' && FUNNEL_PORTS.includes(funnel.publicPort)) {
        rc.funnel = { publicPort: funnel.publicPort };
    }
    return rc;
}
/**
 * Read the hosted-receivers config. A missing or malformed file yields an empty
 * receiver list — never throws — so a box with no config hosts nothing.
 */
export function readDaemonWebhooksConfig() {
    try {
        const raw = fs.readFileSync(getDaemonWebhooksConfigPath(), 'utf-8');
        const parsed = yaml.parse(raw);
        const list = parsed && Array.isArray(parsed.receivers) ? parsed.receivers : [];
        const receivers = list.map(coerceReceiver).filter((r) => r !== null);
        return { receivers };
    }
    catch {
        return { receivers: [] };
    }
}
/** Write the hosted-receivers config, creating the daemon config dir if needed. */
export function writeDaemonWebhooksConfig(cfg) {
    fs.mkdirSync(getDaemonConfigDir(), { recursive: true });
    const out = yaml.stringify({ receivers: cfg.receivers }, { sortMapEntries: false });
    atomicWriteFileSync(getDaemonWebhooksConfigPath(), out, 'utf-8');
}
/** The port a receiver binds — its identity, since one port hosts one receiver. */
export function hostedReceiverPort(receiver) {
    return receiver.port ?? DEFAULT_WEBHOOK_PORT;
}
/**
 * Declare a receiver on this box, replacing any existing entry on the same port.
 * Port is the identity because two receivers cannot bind one port — a second
 * `add` on a port is an edit, never a silently ignored duplicate.
 */
export function addHostedReceiver(receiver) {
    const port = hostedReceiverPort(receiver);
    const existing = readDaemonWebhooksConfig().receivers.filter((r) => hostedReceiverPort(r) !== port);
    const next = { receivers: [...existing, receiver] };
    writeDaemonWebhooksConfig(next);
    return next;
}
/**
 * Drop the receiver bound to `port`. Returns the removed entry, or null when
 * nothing was declared there. The caller is responsible for taking down any
 * public Funnel the removed entry declared — leaving it up would keep a public
 * `https://<host>.ts.net` route pointed at a port nothing serves.
 */
export function removeHostedReceiver(port) {
    const { receivers } = readDaemonWebhooksConfig();
    const removed = receivers.find((r) => hostedReceiverPort(r) === port);
    if (!removed)
        return null;
    writeDaemonWebhooksConfig({ receivers: receivers.filter((r) => hostedReceiverPort(r) !== port) });
    return removed;
}
/**
 * Resolve a receiver's signing secrets from its bundle. Reads `agentOnly` (no
 * Touch ID — the daemon is a background service, SEC-13): a broker-held or
 * file-store bundle resolves silently; a locked bundle THROWS the actionable
 * unlock message, which fails the receiver LOUD rather than binding with no
 * verifiable signature. Throws when neither webhook secret is present.
 */
export function resolveReceiverSecrets(bundle) {
    const { env } = readAndResolveBundleEnv(bundle, { caller: 'daemon webhook-receiver', agentOnly: true });
    const secrets = {};
    if (env.GITHUB_WEBHOOK_SECRET)
        secrets.github = env.GITHUB_WEBHOOK_SECRET;
    if (env.LINEAR_WEBHOOK_SECRET)
        secrets.linear = env.LINEAR_WEBHOOK_SECRET;
    if (env.SLACK_SIGNING_SECRET)
        secrets.slack = env.SLACK_SIGNING_SECRET;
    if (!secrets.github && !secrets.linear && !secrets.slack) {
        throw new Error(`bundle '${bundle}' has none of GITHUB_WEBHOOK_SECRET, LINEAR_WEBHOOK_SECRET, or SLACK_SIGNING_SECRET`);
    }
    return secrets;
}
/** Best-effort funnel reconcile: bring the declared public port up, log on failure. */
function reconcileFunnel(publicPort, localPort, log) {
    let command;
    try {
        command = buildFunnelUpCommand(publicPort, localPort);
    }
    catch (err) {
        log('WARN', `webhook funnel skipped (port ${publicPort}): ${err.message}`);
        return;
    }
    exec(command, (err) => {
        if (err) {
            log('WARN', `webhook funnel reconcile failed (public ${publicPort} -> localhost:${localPort}); binding localhost only: ${err.message}`);
        }
        else {
            log('INFO', `webhook funnel up: public ${publicPort} -> localhost:${localPort}`);
        }
    });
}
/**
 * Start every receiver declared in `webhooks.yaml`. A receiver that cannot start
 * — an unreadable secret (locked bundle, no webhook secret) or a failed bind
 * (the port is already taken by a foreground `agents webhooks serve`, another
 * daemon, or anything else) — is skipped with a loud WARN and does NOT take the
 * others, or the daemon, down. Returns a handle that stops them all.
 *
 * This is async because a bind failure is only observable asynchronously:
 * `server.listen()` surfaces EADDRINUSE as an `'error'` event, so a `try/catch`
 * around the start call never sees it and the event reaches the process-level
 * `uncaughtException` handler (`index.ts`), which exits 1 for the supervisor to
 * restart — a crash loop that would take the secrets broker, scheduler,
 * monitors, browser IPC, and self-heal down with it. `waitForListening` is what
 * turns that into one skipped receiver.
 */
export async function startHostedWebhookReceivers(opts) {
    const { log } = opts;
    const resolveSecrets = opts.resolveSecrets ?? resolveReceiverSecrets;
    const { receivers } = readDaemonWebhooksConfig();
    const servers = [];
    for (const receiver of receivers) {
        const port = receiver.port ?? DEFAULT_WEBHOOK_PORT;
        let secrets;
        try {
            secrets = resolveSecrets(receiver.bundle);
        }
        catch (err) {
            log('WARN', `webhook receiver on :${port} skipped: ${err.message}`);
            continue;
        }
        let server = null;
        try {
            server = startWebhookServer({
                host: '127.0.0.1',
                port,
                secrets,
                rateLimitPerMinute: receiver.rateLimit ?? DEFAULT_WEBHOOK_RATE_LIMIT,
                // Per-port durable delivery dedup so replays survive a daemon restart and
                // two receivers on distinct ports never share a dedup ledger.
                deliveryStore: createFileDeliveryStore(path.join(getRuntimeStateDir(), 'webhook', `deliveries-${port}.json`)),
                // Logged at MATCH time, before dispatch — a `run.command` handler can
                // block on a shelled-out agent run for minutes, and that must never
                // delay the log that says a delivery fired (RUSH-2722).
                onMatch: (webhook, matchedJobNames, matchedHandlerNames) => {
                    const parts = [];
                    if (matchedJobNames.length)
                        parts.push(`routines ${matchedJobNames.join(', ')}`);
                    if (matchedHandlerNames.length)
                        parts.push(`handlers ${matchedHandlerNames.join(', ')}`);
                    log('INFO', `webhook ${webhook.source}:${webhook.event} ${parts.length ? `fired ${parts.join('; ')}` : 'no match'}`);
                },
                // The 202 ack means no HTTP status carries a settle failure — the daemon
                // log is where it surfaces, so it is never swallowed.
                onDeliveryError: (webhook, err) => {
                    log('WARN', `webhook ${webhook.source}:${webhook.event} dispatch failed after ack: ${err.message}`);
                },
            });
            const sockets = new Set();
            server.on('connection', (socket) => {
                sockets.add(socket);
                socket.once('close', () => sockets.delete(socket));
            });
            await waitForListening(server);
            servers.push({ server, sockets });
            log('INFO', `webhook receiver bound on 127.0.0.1:${port} (bundle ${receiver.bundle})`);
            if (receiver.funnel)
                reconcileFunnel(receiver.funnel.publicPort, port, log);
        }
        catch (err) {
            // Close the half-started server so a failed bind leaks no handle, and keep
            // going: one unusable receiver must not cost the others their ingress.
            server?.close(() => { });
            log('WARN', `webhook receiver on :${port} failed to bind: ${err.message}`);
        }
    }
    return {
        count: servers.length,
        close: () => Promise.all(servers.map(async ({ server, sockets }) => {
            // Stop accepting first, then force every persistent HTTP connection
            // closed so keep-alive cannot hold daemon shutdown open indefinitely.
            const closing = closeServerBounded(server);
            for (const socket of sockets)
                socket.destroy();
            await closing;
        })).then(() => undefined),
    };
}
