import http from "node:http";
import https from "node:https";
import os from "node:os";
import path from "node:path";
import { signPeerRequest } from "./peer-auth.js";
import { PeerRuntimeService, peerError } from "./peer-runtime-service.js";
import { PeerStore } from "./peer-store.js";
import { getObservabilityRegistry } from "../observability/observability-registry.js";
const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
const outboundRelaySnapshots = new Map();
export function startPeerOutboundRelay(options) {
    const { config, runtime, home } = options;
    if (!config.peerOutboundRelayEnabled) {
        return null;
    }
    const store = new PeerStore(home);
    const service = new PeerRuntimeService(config, runtime, { home });
    const homeKey = path.resolve(home || process.env.NORDRELAY_HOME || DEFAULT_HOME);
    let closed = false;
    const timers = new Map();
    const peerState = new Map();
    const allowedPeerIds = new Set(config.peerOutboundRelayPeerIds);
    const eventQueue = new Map();
    const eventTimers = new Map();
    const pollers = new Map();
    const observability = getObservabilityRegistry();
    const loop = async (peerId) => {
        if (closed)
            return;
        const peer = store.get(peerId);
        if (!peer || !isRelayPeerAllowed(peer, allowedPeerIds)) {
            return;
        }
        const state = relayPeerState(peerState, peer.id);
        Object.assign(state, { peerName: peer.name, enabled: peer.enabled, allowed: true, lastPollAt: new Date().toISOString() });
        writeOutboundSnapshot(homeKey, config, peerState);
        try {
            const polled = await signedPeerPost(peer, "/peer/relay/poll", { timeoutMs: config.peerOutboundRelayPollMs });
            const request = polled.request;
            if (request?.request) {
                state.lastRequestAt = new Date().toISOString();
                const result = await executeRelayRequest(service, peer, request.request).catch((error) => ({
                    ok: false,
                    error: peerError(error),
                }));
                await signedPeerPost(peer, "/peer/relay/result", { id: request.id, result });
                state.idlePolls = 0;
                state.failures = 0;
                state.lastCompletedAt = new Date().toISOString();
                state.completedRequests += 1;
                state.lastError = result.ok ? undefined : result.error;
            }
            else {
                state.idlePolls = Math.min(state.idlePolls + 1, 8);
                state.failures = 0;
                state.lastError = undefined;
            }
            store.markSeen(peer.id, { remoteStatus: "relay-polling" });
        }
        catch (error) {
            state.failures = Math.min(state.failures + 1, 8);
            state.idlePolls = 0;
            state.lastError = peerError(error);
            store.markError(peer.id, `Outbound relay poll failed: ${state.lastError}`);
        }
        finally {
            schedule(peer.id, nextDelayMs(config.peerOutboundRelayPollMs, state));
            writeOutboundSnapshot(homeKey, config, peerState);
        }
    };
    const schedule = (peerId, delayMs = config.peerOutboundRelayPollMs) => {
        if (closed)
            return;
        if (timers.has(peerId))
            return;
        const poller = relayPoller(pollers, peerId, "poll", Math.max(250, delayMs));
        poller.update({ currentDelayMs: Math.max(250, delayMs), nextRunAt: Date.now() + Math.max(250, delayMs) });
        const state = relayPeerState(peerState, peerId);
        state.nextPollAt = new Date(Date.now() + Math.max(250, delayMs)).toISOString();
        writeOutboundSnapshot(homeKey, config, peerState);
        const timer = setTimeout(() => {
            timers.delete(peerId);
            const finish = poller.start();
            void loop(peerId).then(() => finish()).catch((error) => finish(error));
        }, Math.max(250, delayMs));
        timer.unref?.();
        timers.set(peerId, timer);
    };
    const scheduleKnownPeers = () => {
        for (const peer of store.list()) {
            if (isRelayPeerAllowed(peer, allowedPeerIds))
                schedule(peer.id, 250);
        }
    };
    const scheduleEventFlush = (peerId, delayMs = 500) => {
        if (closed || eventTimers.has(peerId))
            return;
        const poller = relayPoller(pollers, peerId, "event-flush", delayMs);
        poller.update({ currentDelayMs: delayMs, nextRunAt: Date.now() + delayMs });
        const timer = setTimeout(() => {
            eventTimers.delete(peerId);
            const finish = poller.start();
            void flushEvents(peerId).then(() => finish()).catch((error) => finish(error));
        }, delayMs);
        timer.unref?.();
        eventTimers.set(peerId, timer);
    };
    const flushEvents = async (peerId) => {
        if (closed)
            return;
        const peer = store.get(peerId);
        if (!peer || !isRelayPeerAllowed(peer, allowedPeerIds))
            return;
        const events = eventQueue.get(peerId)?.splice(0, 50) ?? [];
        if (!events.length)
            return;
        try {
            await signedPeerPost(peer, "/peer/relay/event", { events });
            const state = relayPeerState(peerState, peerId);
            state.failures = 0;
            state.lastError = undefined;
            writeOutboundSnapshot(homeKey, config, peerState);
        }
        catch (error) {
            const state = relayPeerState(peerState, peerId);
            state.failures = Math.min(state.failures + 1, 8);
            state.lastError = peerError(error);
            eventQueue.set(peerId, events.concat(eventQueue.get(peerId) ?? []).slice(-100));
            scheduleEventFlush(peerId, nextDelayMs(config.peerOutboundRelayPollMs, state));
        }
    };
    const unsubscribeEvents = runtime.subscribe((event) => {
        for (const peer of store.list()) {
            if (!isRelayPeerAllowed(peer, allowedPeerIds))
                continue;
            const queue = eventQueue.get(peer.id) ?? [];
            queue.push(event);
            eventQueue.set(peer.id, queue.slice(-100));
            scheduleEventFlush(peer.id, 500);
        }
    });
    scheduleKnownPeers();
    const refreshIntervalMs = Math.max(30_000, config.peerOutboundRelayPollMs * 5);
    const refreshPoller = observability.registerPoller({
        id: "peers:outbound-relay-refresh",
        owner: "peers",
        kind: "outbound-relay-refresh",
        intervalMs: refreshIntervalMs,
        currentDelayMs: refreshIntervalMs,
        nextRunAt: Date.now() + refreshIntervalMs,
    });
    const refreshTimer = setInterval(() => {
        refreshPoller.update({ nextRunAt: Date.now() + refreshIntervalMs });
        const finish = refreshPoller.start();
        try {
            scheduleKnownPeers();
            finish();
        }
        catch (error) {
            finish(error);
        }
    }, refreshIntervalMs);
    refreshTimer.unref?.();
    console.log(`Peer outbound relay: ${allowedPeerIds.size > 0 ? [...allowedPeerIds].join(", ") : "all outbound peers"}`);
    return {
        close: () => {
            closed = true;
            clearInterval(refreshTimer);
            refreshPoller.close();
            unsubscribeEvents();
            for (const timer of timers.values())
                clearTimeout(timer);
            for (const timer of eventTimers.values())
                clearTimeout(timer);
            timers.clear();
            eventTimers.clear();
            for (const poller of pollers.values())
                poller.close();
            pollers.clear();
            outboundRelaySnapshots.set(homeKey, {
                enabled: false,
                allowedPeerIds: [...allowedPeerIds],
                peers: [...peerState.values()].map((peer) => ({ ...peer, enabled: false })),
                updatedAt: new Date().toISOString(),
            });
        },
    };
}
function relayPoller(pollers, peerId, kind, delayMs) {
    const id = `peers:outbound-relay:${kind}:${peerId}`;
    const existing = pollers.get(id);
    if (existing)
        return existing;
    const poller = getObservabilityRegistry().registerPoller({
        id,
        owner: "peers",
        kind: `outbound-relay-${kind}`,
        intervalMs: delayMs,
        currentDelayMs: delayMs,
    });
    pollers.set(id, poller);
    return poller;
}
function isRelayPeerAllowed(peer, allowedPeerIds) {
    if (!peer.enabled || !peer.url)
        return false;
    return allowedPeerIds.size === 0 || allowedPeerIds.has(peer.id) || allowedPeerIds.has(peer.nodeId);
}
export function getPeerOutboundRelaySnapshot(home = process.env.NORDRELAY_HOME || DEFAULT_HOME) {
    return outboundRelaySnapshots.get(path.resolve(home)) ?? {
        enabled: false,
        allowedPeerIds: [],
        peers: [],
        updatedAt: new Date().toISOString(),
    };
}
function relayPeerState(states, peerId) {
    const state = states.get(peerId) ?? { peerId, enabled: true, allowed: true, failures: 0, idlePolls: 0, completedRequests: 0 };
    states.set(peerId, state);
    return state;
}
function nextDelayMs(baseMs, state) {
    const base = Math.max(250, baseMs);
    if (state.failures > 0) {
        return withJitter(Math.min(base * 2 ** state.failures, 5 * 60_000));
    }
    if (state.idlePolls > 0) {
        return withJitter(Math.min(base * (state.idlePolls + 1), 60_000));
    }
    return 250;
}
function writeOutboundSnapshot(homeKey, config, states) {
    outboundRelaySnapshots.set(homeKey, {
        enabled: config.peerOutboundRelayEnabled,
        allowedPeerIds: [...config.peerOutboundRelayPeerIds],
        peers: [...states.values()].map((peer) => ({ ...peer })),
        updatedAt: new Date().toISOString(),
    });
}
function withJitter(ms) {
    const jitter = Math.floor(ms * 0.2 * Math.random());
    return ms + jitter;
}
async function executeRelayRequest(service, peer, request) {
    const data = await service.handle(peer, request);
    return { ok: true, data };
}
async function signedPeerPost(peer, route, body) {
    const bodyText = JSON.stringify(body);
    const signed = signPeerRequest(peer, "POST", route, bodyText);
    const startedAt = Date.now();
    try {
        const result = await requestJson({
            url: joinPeerUrl(requiredPeerUrl(peer), route),
            bodyText,
            headers: signed.headers,
            expectedTlsFingerprint: peer.tlsFingerprint,
        });
        getObservabilityRegistry().recordPeerRoundtrip({ peerId: peer.id, method: route, durationMs: Date.now() - startedAt, ok: true, transport: "outbound-relay" });
        return result;
    }
    catch (error) {
        getObservabilityRegistry().recordPeerRoundtrip({ peerId: peer.id, method: route, durationMs: Date.now() - startedAt, ok: false, error, transport: "outbound-relay" });
        throw error;
    }
}
async function requestJson(options) {
    const url = new URL(options.url);
    const transport = url.protocol === "https:" ? https : http;
    return await new Promise((resolve, reject) => {
        const req = transport.request({
            method: "POST",
            protocol: url.protocol,
            hostname: url.hostname,
            port: url.port,
            path: `${url.pathname}${url.search}`,
            agent: false,
            headers: {
                "content-type": "application/json",
                "content-length": Buffer.byteLength(options.bodyText),
                ...options.headers,
            },
            rejectUnauthorized: false,
        }, (res) => {
            try {
                assertTlsFingerprint(res.socket, options.expectedTlsFingerprint);
            }
            catch (error) {
                reject(error);
                req.destroy();
                return;
            }
            const chunks = [];
            res.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
            res.on("end", () => {
                const text = Buffer.concat(chunks).toString("utf8");
                let data = {};
                try {
                    data = text ? JSON.parse(text) : {};
                }
                catch {
                    reject(new Error(`Peer relay returned invalid JSON: ${text.slice(0, 200)}`));
                    return;
                }
                if ((res.statusCode ?? 500) >= 400) {
                    reject(new Error(data && typeof data === "object" && "error" in data ? String(data.error) : `HTTP ${res.statusCode}`));
                    return;
                }
                resolve(data);
            });
        });
        req.on("error", reject);
        req.setTimeout(30_000, () => req.destroy(new Error("Peer outbound relay request timed out.")));
        req.write(options.bodyText);
        req.end();
    });
}
function assertTlsFingerprint(socket, expected) {
    if (!expected || !socket.encrypted)
        return;
    const actual = socket.getPeerCertificate()?.fingerprint256?.trim().toLowerCase();
    if (actual !== expected.trim().toLowerCase()) {
        throw new Error("Peer TLS certificate fingerprint mismatch.");
    }
}
function requiredPeerUrl(peer) {
    if (!peer.url)
        throw new Error(`Peer ${peer.name} has no URL.`);
    return peer.url;
}
function joinPeerUrl(base, route) {
    const url = new URL(base);
    url.pathname = route;
    url.search = "";
    url.hash = "";
    return url.toString();
}
