/**
 * ThreadlineEndpoints — HTTP route handlers for the Threadline protocol.
 *
 * Routes:
 *   GET  /threadline/health              — Health check + version
 *   POST /threadline/handshake/hello     — Initiate handshake (unauthenticated)
 *   POST /threadline/handshake/confirm   — Complete handshake (unauthenticated)
 *   POST /threadline/messages/receive    — Receive a message (authenticated)
 *   GET  /threadline/messages/thread/:id — Get thread messages (authenticated)
 *   GET  /threadline/blobs/:id           — Fetch blob content (authenticated)
 *
 * Authentication: Threadline-Relay token + signed headers for authenticated routes.
 * Content-Type: application/threadline+json; version=1.0
 *
 * Part of Threadline Protocol Phase 3.
 */
import { Router } from 'express';
import crypto from 'node:crypto';
import { verify } from './ThreadlineCrypto.js';
import { IdentityManager } from './client/IdentityManager.js';
import { recordInboundAck } from './recordInboundAck.js';
import { computeFingerprint } from './client/MessageEncryptor.js';
import { recordThreadMessage } from './recordThreadMessage.js';
import { honorPeerThreadSync, serveBackfill } from './threadSymmetry.js';
// ── Error Codes ──────────────────────────────────────────────────────
const ERROR_CODES = {
    TL_AUTH_FAILED: 'TL_AUTH_FAILED',
    TL_AUTH_MISSING: 'TL_AUTH_MISSING',
    TL_INVALID_PAYLOAD: 'TL_INVALID_PAYLOAD',
    TL_HANDSHAKE_FAILED: 'TL_HANDSHAKE_FAILED',
    TL_RATE_LIMITED: 'TL_RATE_LIMITED',
    TL_NOT_FOUND: 'TL_NOT_FOUND',
    TL_INTERNAL_ERROR: 'TL_INTERNAL_ERROR',
    TL_REPLAY_DETECTED: 'TL_REPLAY_DETECTED',
    TL_TIMESTAMP_EXPIRED: 'TL_TIMESTAMP_EXPIRED',
};
const CONTENT_TYPE = 'application/threadline+json; version=1.0';
const TIMESTAMP_WINDOW_MS = 30_000; // 30 seconds
// ── Nonce Store (replay protection for authenticated endpoints) ──────
class ThreadlineNonceStore {
    nonces = new Set();
    pruneTimer;
    constructor() {
        // Prune every 60 seconds
        this.pruneTimer = setInterval(() => {
            this.nonces.clear(); // Simple approach: clear all every minute
        }, 60_000);
        if (this.pruneTimer.unref)
            this.pruneTimer.unref();
    }
    check(nonce) {
        if (this.nonces.has(nonce))
            return false;
        this.nonces.add(nonce);
        return true;
    }
    destroy() {
        clearInterval(this.pruneTimer);
    }
}
// ── Route Factory ────────────────────────────────────────────────────
export function createThreadlineRoutes(handshakeManager, threadlineRouter, config, 
// Robustness Phase 1 (D-E / F4): the delivery tracker + thread-owner lookup so
// the verified E2E relay inbound path records the implicit ack through the
// shared recordInboundAck funnel. Optional — absent in tests that don't need it.
inboundAckDeps, 
// Robustness Phase 2 (D-B/D-D): the canonical-history funnel + symmetry surfaces.
canonicalDeps) {
    const router = Router();
    const nonceStore = new ThreadlineNonceStore();
    // ── Content-Type middleware for all threadline routes ───────────
    router.use('/threadline', (_req, res, next) => {
        res.setHeader('Content-Type', CONTENT_TYPE);
        next();
    });
    // ── Health ─────────────────────────────────────────────────────
    router.get('/threadline/health', (_req, res) => {
        // Report the canonical routing identity (the address the relay answers to),
        // resolved via the SAME read-only API the relay client uses. When it
        // resolves, identityPub + fingerprint correspond
        // (fingerprint === computeFingerprint(identityPub)) so a peer discovering us
        // via health obtains a routable address. When no routing identity resolves
        // (none on disk, or canonical identity.json is locked-encrypted), fall back
        // to the handshake-layer key and omit the fingerprint — never fabricate one.
        const routingIdentity = new IdentityManager(config.stateDir).get();
        res.json({
            status: 'ok',
            protocol: 'threadline',
            version: config.version,
            agent: config.localAgent,
            identityPub: routingIdentity
                ? routingIdentity.publicKey.toString('hex')
                : handshakeManager.getIdentityPublicKey(),
            fingerprint: routingIdentity?.fingerprint,
            pairedAgents: handshakeManager.listPairedAgents().length,
            // Secure A2A Verified Pairing (§3.6): count of mutual-verified pairings.
            // Omitted when no count callback is wired (legacy behavior).
            ...(config.mutualVerifiedCount
                ? { mutualVerifiedCount: config.mutualVerifiedCount() }
                : {}),
            timestamp: new Date().toISOString(),
        });
    });
    // ── Handshake: Hello (UNAUTHENTICATED) ─────────────────────────
    router.post('/threadline/handshake/hello', (req, res) => {
        try {
            const body = req.body;
            // Validate payload
            if (!body || !body.agent || !body.identityPub || !body.ephemeralPub || !body.nonce) {
                res.status(400).json(makeError(ERROR_CODES.TL_INVALID_PAYLOAD, 'Missing required fields: agent, identityPub, ephemeralPub, nonce', false));
                return;
            }
            // Validate hex encoding
            if (!isHex(body.identityPub, 64) || !isHex(body.ephemeralPub, 64)) {
                res.status(400).json(makeError(ERROR_CODES.TL_INVALID_PAYLOAD, 'identityPub and ephemeralPub must be 32-byte hex-encoded values', false));
                return;
            }
            const hello = {
                agent: body.agent,
                identityPub: body.identityPub,
                ephemeralPub: body.ephemeralPub,
                nonce: body.nonce,
                challengeResponse: body.challengeResponse,
            };
            // Check if this is a hello-response (has challengeResponse) to our initiated hello
            if (hello.challengeResponse) {
                const result = handshakeManager.handleHelloResponse(hello);
                if ('error' in result) {
                    const statusCode = result.error.includes('Rate limited') ? 429 : 400;
                    res.status(statusCode).json(makeError(result.error.includes('Rate limited') ? ERROR_CODES.TL_RATE_LIMITED : ERROR_CODES.TL_HANDSHAKE_FAILED, result.error, result.error.includes('Rate limited'), result.error.includes('Rate limited') ? 60 : undefined));
                    return;
                }
                res.json({
                    type: 'confirm-ack',
                    agent: config.localAgent,
                    status: 'paired',
                });
                return;
            }
            // Standard hello — they're initiating
            const result = handshakeManager.handleHello(hello);
            if ('error' in result) {
                const statusCode = result.error.includes('Rate limited') ? 429 : 400;
                res.status(statusCode).json(makeError(result.error.includes('Rate limited') ? ERROR_CODES.TL_RATE_LIMITED : ERROR_CODES.TL_HANDSHAKE_FAILED, result.error, result.error.includes('Rate limited'), result.error.includes('Rate limited') ? 60 : undefined));
                return;
            }
            res.json({
                type: 'hello-response',
                ...result.payload,
            });
        }
        catch (err) {
            console.error('[ThreadlineEndpoints] Handshake hello error:', err);
            res.status(500).json(makeError(ERROR_CODES.TL_INTERNAL_ERROR, 'Internal error during handshake', true, 5));
        }
    });
    // ── Handshake: Confirm (UNAUTHENTICATED) ───────────────────────
    router.post('/threadline/handshake/confirm', (req, res) => {
        try {
            const body = req.body;
            if (!body || !body.agent || !body.challengeResponse) {
                res.status(400).json(makeError(ERROR_CODES.TL_INVALID_PAYLOAD, 'Missing required fields: agent, challengeResponse', false));
                return;
            }
            const confirm = {
                agent: body.agent,
                challengeResponse: body.challengeResponse,
            };
            const result = handshakeManager.handleConfirm(confirm);
            if ('error' in result) {
                const statusCode = result.error.includes('Rate limited') ? 429 :
                    result.error.includes('timed out') ? 408 : 400;
                res.status(statusCode).json(makeError(result.error.includes('Rate limited') ? ERROR_CODES.TL_RATE_LIMITED : ERROR_CODES.TL_HANDSHAKE_FAILED, result.error, result.error.includes('Rate limited') || result.error.includes('timed out'), result.error.includes('Rate limited') ? 60 : undefined));
                return;
            }
            res.json({
                type: 'confirm-ack',
                agent: config.localAgent,
                status: 'paired',
            });
        }
        catch (err) {
            console.error('[ThreadlineEndpoints] Handshake confirm error:', err);
            res.status(500).json(makeError(ERROR_CODES.TL_INTERNAL_ERROR, 'Internal error during handshake', true, 5));
        }
    });
    // ── Auth Middleware for authenticated endpoints ────────────────
    const threadlineAuth = (req, res, next) => {
        const authHeader = req.headers.authorization;
        if (!authHeader || !authHeader.startsWith('Threadline-Relay ')) {
            res.status(401).json(makeError(ERROR_CODES.TL_AUTH_MISSING, 'Missing Authorization: Threadline-Relay <token> header', false));
            return;
        }
        const token = authHeader.slice('Threadline-Relay '.length);
        const agentName = req.headers['x-threadline-agent'];
        const nonce = req.headers['x-threadline-nonce'];
        const timestamp = req.headers['x-threadline-timestamp'];
        const signature = req.headers['x-threadline-signature'];
        if (!agentName || !nonce || !timestamp || !signature) {
            res.status(401).json(makeError(ERROR_CODES.TL_AUTH_MISSING, 'Missing required headers: X-Threadline-Agent, X-Threadline-Nonce, X-Threadline-Timestamp, X-Threadline-Signature', false));
            return;
        }
        // Validate relay token
        if (!handshakeManager.validateRelayToken(agentName, token)) {
            res.status(403).json(makeError(ERROR_CODES.TL_AUTH_FAILED, 'Invalid relay token for agent', false));
            return;
        }
        // Validate timestamp freshness
        const tsMs = new Date(timestamp).getTime();
        if (isNaN(tsMs) || Math.abs(Date.now() - tsMs) > TIMESTAMP_WINDOW_MS) {
            res.status(401).json(makeError(ERROR_CODES.TL_TIMESTAMP_EXPIRED, 'Timestamp outside acceptable window', true, 1));
            return;
        }
        // Replay protection via nonce
        if (!nonceStore.check(nonce)) {
            res.status(401).json(makeError(ERROR_CODES.TL_REPLAY_DETECTED, 'Nonce already used (replay detected)', false));
            return;
        }
        // Verify signature: Ed25519 sign over (method + path + nonce + timestamp + body_hash)
        const bodyHash = crypto.createHash('sha256')
            .update(JSON.stringify(req.body ?? ''))
            .digest();
        const signedData = Buffer.concat([
            Buffer.from(`${req.method}\n${req.path}\n${nonce}\n${timestamp}\n`, 'utf-8'),
            bodyHash,
        ]);
        // We need the agent's identity pub to verify
        // It's stored in the relay tokens via handshakeManager
        // For now we accept the signature check against the stored identity
        try {
            const identityPub = getAgentIdentityPub(handshakeManager, agentName);
            if (!identityPub) {
                res.status(403).json(makeError(ERROR_CODES.TL_AUTH_FAILED, 'Unknown agent identity', false));
                return;
            }
            const isValid = verify(Buffer.from(identityPub, 'hex'), signedData, Buffer.from(signature, 'hex'));
            if (!isValid) {
                res.status(403).json(makeError(ERROR_CODES.TL_AUTH_FAILED, 'Signature verification failed', false));
                return;
            }
            // Robustness Phase 2 (SA1): the verified principal is the fingerprint DERIVED
            // from the Ed25519 identity pubkey the signature verified against — never the
            // x-threadline-agent NAME header, never a body/envelope `from` field. Every
            // participant-authorization check (threadSync, backfill) keys on THIS.
            try {
                req.threadlineVerifiedFingerprint = computeFingerprint(Buffer.from(identityPub, 'hex'));
            }
            catch { /* @silent-fallback-ok: a malformed pubkey already failed verify() above; leave the principal unset (fails participant checks CLOSED) */ }
        }
        catch {
            res.status(403).json(makeError(ERROR_CODES.TL_AUTH_FAILED, 'Signature verification error', false));
            return;
        }
        next();
    };
    // ── Messages: Receive (AUTHENTICATED) ──────────────────────────
    router.post('/threadline/messages/receive', threadlineAuth, async (req, res) => {
        try {
            const body = req.body;
            if (!body || !body.message) {
                res.status(400).json(makeError(ERROR_CODES.TL_INVALID_PAYLOAD, 'Missing required field: message', false));
                return;
            }
            if (!threadlineRouter) {
                res.status(503).json(makeError(ERROR_CODES.TL_INTERNAL_ERROR, 'Threadline router not available', true, 5));
                return;
            }
            // Route the message through the ThreadlineRouter
            // Build a minimal MessageEnvelope from the inbound payload
            const envelope = {
                schemaVersion: 1,
                message: body.message,
                transport: body.transport ?? {
                    originServer: req.headers['x-threadline-agent'],
                    nonce: req.headers['x-threadline-nonce'],
                    timestamp: req.headers['x-threadline-timestamp'],
                },
                delivery: body.delivery ?? {
                    status: 'pending',
                    attempts: 0,
                    createdAt: new Date().toISOString(),
                },
            };
            // Accept-boundary (#3 / issue-580): respond the instant the message is
            // accepted + authenticated, then run the (typically 9-30s) spawn in the
            // BACKGROUND. The sole senders (MessageRouter cross-machine relay and
            // AgentBus.httpSend) read only `response.ok` within a ~10s timeout and
            // fall back to a durable queue on failure — so AWAITING the spawn meant
            // the sender timed out before we replied, treated delivery as failed, and
            // retried with a FRESH nonce/id that slipped past the nonce + id dedup,
            // causing a DUPLICATE spawn/reply. This mirrors the proven co-located fix
            // (#581, /messages/relay-agent). The reply itself flows back via the
            // decoupled reply-waiter, not this HTTP response, so no caller's
            // correctness depends on the synchronous spawn outcome; a genuine
            // background failure is logged (it cannot 500 a response that already
            // returned). The former 422-retryable path was already unreachable — the
            // sender aborts at ~10s, long before the 9-30s spawn produced it.
            res.json({ accepted: true, async: true, threadId: body.message?.threadId });
            // Robustness Phase 1 (D-E / closes F4): record the implicit delivery ack on
            // the VERIFIED E2E relay inbound path — the path that previously lacked it,
            // producing the permanent false `stale: true` noise. threadlineAuth has
            // already validated the relay token + Ed25519 signature for the agent named
            // in x-threadline-agent, so it is the authenticated sender. Funnelled through
            // the shared recordInboundAck so the wiring-integrity test can enforce it.
            try {
                const verifiedSender = req.headers['x-threadline-agent'];
                recordInboundAck(inboundAckDeps ?? {}, {
                    threadId: body.message?.threadId,
                    senderFingerprint: verifiedSender,
                    senderName: verifiedSender ?? null,
                });
            }
            catch { /* @silent-fallback-ok: recording-only — never block inbound routing */ }
            // Robustness Phase 2 (D-B): append THIS inbound leg to the canonical log via
            // the single funnel (the 4th and final message-persisting site — the F4 path).
            // The verified principal is the DERIVED fingerprint (SA1), never the name.
            try {
                const verifiedFp = req.threadlineVerifiedFingerprint;
                if (canonicalDeps?.threadMessageRecorder && body.message?.threadId && body.message?.id) {
                    const rtmBody = typeof body.message?.body === 'string'
                        ? body.message.body
                        : String(body.message?.body?.content ?? body.message?.body?.text ?? '');
                    recordThreadMessage(canonicalDeps.threadMessageRecorder, {
                        threadId: body.message.threadId,
                        messageId: body.message.id,
                        direction: 'inbound',
                        body: rtmBody,
                        createdAt: typeof body.message?.createdAt === 'string' ? body.message.createdAt : new Date().toISOString(),
                        peerFingerprint: verifiedFp,
                        author: verifiedFp ? { agentFingerprint: verifiedFp } : undefined,
                        subject: typeof body.message?.subject === 'string' ? body.message.subject : undefined,
                    });
                }
                // Honor a piggybacked peer threadSync (verified-participant, monotonic) and
                // refresh this thread's advisory symmetry state (D-D, detection is CORE).
                if (canonicalDeps && body.threadSync && body.message?.threadId && verifiedFp) {
                    void honorPeerThreadSync(canonicalDeps, body.message.threadId, verifiedFp, body.threadSync).catch(() => { });
                }
            }
            catch { /* @silent-fallback-ok: recording-only — never block inbound routing */ }
            void threadlineRouter.handleInboundMessage(envelope)
                .then((result) => {
                if (result?.error) {
                    console.warn(`[ThreadlineEndpoints] Background handleInboundMessage reported: ${result.error}`);
                }
            })
                .catch((err) => {
                console.error('[ThreadlineEndpoints] Background handleInboundMessage failed:', err);
            });
            return;
        }
        catch (err) {
            console.error('[ThreadlineEndpoints] Message receive error:', err);
            res.status(500).json(makeError(ERROR_CODES.TL_INTERNAL_ERROR, 'Internal error processing message', true, 5));
        }
    });
    // ── Convergence backfill (AUTHENTICATED, participant-authorized) ──────
    //
    // Robustness Phase 2 (D-D / SA1-SA4): the bounded, terminating, participant-
    // authorized backfill exchange. The placeholder GET /threadline/messages/thread/:id
    // that returned a hard `messageCount: 0` (a second F3 hard-zero source) is DELETED —
    // canonical history is read via GET /threadline/threads/:id (bearer-gated, on the
    // agent server), never this relay-auth surface.
    //
    // CRITICAL authorization (SA1): the responder serves records ONLY for threads where
    // the DERIVED-verified-fingerprint requester (never a name/body claim) is a recorded
    // participant, and only contentDigests already in that thread's log. A request for a
    // non-participant thread returns empty + is counted (closes cross-thread exfiltration).
    router.post('/threadline/threads/backfill', threadlineAuth, (req, res) => {
        const verifiedFp = req.threadlineVerifiedFingerprint;
        const body = req.body;
        if (!verifiedFp || !canonicalDeps?.threadLog) {
            // Fail CLOSED — no verified principal or no canonical log → empty.
            res.json({ kind: 'thread-backfill-resp', threadId: body?.threadId ?? '', records: [], refused: true });
            return;
        }
        const records = serveBackfill(canonicalDeps, body?.threadId ?? '', verifiedFp, Array.isArray(body?.missingDigests) ? body.missingDigests : []);
        res.json({ kind: 'thread-backfill-resp', threadId: body?.threadId ?? '', records });
    });
    // ── Blobs: Fetch (AUTHENTICATED) ──────────────────────────────
    router.get('/threadline/blobs/:id', threadlineAuth, (_req, res) => {
        // Blob retrieval — returns binary content for a blob ID.
        // Placeholder for Phase 3. Full blob storage is a future phase.
        const blobId = _req.params.id;
        res.status(404).json(makeError(ERROR_CODES.TL_NOT_FOUND, `Blob ${blobId} not found`, false));
    });
    return router;
}
// ── Helpers ──────────────────────────────────────────────────────────
function makeError(code, message, retryable, retryAfterSeconds) {
    return {
        error: {
            code,
            message,
            retryable,
            ...(retryAfterSeconds !== undefined && { retryAfterSeconds }),
        },
    };
}
function isHex(value, expectedLength) {
    return typeof value === 'string' &&
        value.length === expectedLength &&
        /^[0-9a-f]+$/i.test(value);
}
/**
 * Get an agent's identity public key from the relay token store.
 * This is stored when the handshake completes.
 */
function getAgentIdentityPub(handshakeManager, agentName) {
    // Access the stored identity pub through the handshake manager.
    // The relay tokens store includes theirIdentityPub.
    // We expose this through a method on HandshakeManager.
    return handshakeManager.relayTokens?.[agentName]?.theirIdentityPub ?? null;
}
//# sourceMappingURL=ThreadlineEndpoints.js.map