/**
 * SumoLogicHttpDestination -- Sumo Logic HTTP Source output adapter.
 *
 * Delivers governance events to a Sumo Logic HTTP Source endpoint using
 * newline-delimited JSON (NDJSON). Mirrors the structural pattern of
 * SplunkHecDestination, DatadogLogsDestination, and SentinelDestination.
 *
 * Design decisions:
 *   - No Sumo Logic SDK. Plain HTTPS POST via Node built-ins (no supply-chain risk).
 *   - The endpoint URL is itself the secret: it contains an embedded token.
 *     The full URL is NEVER stored after construction validation; only the resolver
 *     reference is held. The resolved URL is used only in the HTTPS request and
 *     is NEVER logged, emitted to errors, or written to DLQ entries (PT-SUMO-1).
 *   - Retry semantics mirror sentinel.ts and splunk-hec.ts:
 *     429 (rate-limited) + 5xx = retryable. Retry-After honored on 429.
 *     4xx other than 429 = non-retryable; DLQ immediately.
 *   - Gzip compression (built-in zlib) applied by default. Configurable.
 *     Recommended for batches > a few KB per Sumo Logic documentation.
 *   - packBinding is mandatory. Empty binding throws UnboundDataError before any
 *     HTTP request (pack-binding primacy, Thomas Directive 2026-04-22).
 *   - Endpoint URL (including embedded token) is NEVER emitted to any log stream,
 *     error message, or DLQ entry. Sanitised to domain-only fingerprint (PT-SUMO-1).
 *   - Body chunking by serialised byte size: max 1 MB per request (hard Sumo limit).
 *     Events too large to fit alone are individually DLQ'd -- no silent truncation.
 *   - Optional X-Sumo-* override headers: Name, Category, Host, Fields.
 *
 * Sumo Logic HTTP Source wire format:
 *   POST https://endpoint<N>.collection.<region>.sumologic.com/receiver/v1/http/<token>
 *   Content-Type: application/x-ndjson
 *   Content-Encoding: gzip  (when compression enabled)
 *   X-Sumo-Name: <sourceName>  (optional)
 *   X-Sumo-Category: <category>  (optional)
 *   X-Sumo-Host: <host>  (optional)
 *   X-Sumo-Fields: key1=val1,key2=val2  (optional)
 *   Body: <json-line1>\n<json-line2>\n...
 *
 * Returns 200 on accept, 4xx on validation error, 429 on rate limit, 5xx on transient.
 *
 * @connexum/ai-governance
 */
import * as https from 'https';
import * as http from 'http';
import * as zlib from 'zlib';
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../../util/log.js';
import { UnboundDataError } from '../../errors/index.js';
const log = createLogger('sumo-logic');
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Sumo Logic HTTP Source hard maximum body size (1 MB). */
const SUMO_MAX_BYTES = 1_000_000;
/** Conservative default batch byte ceiling (950 KB -- leaves safety margin). */
const SUMO_DEFAULT_MAX_BATCH_BYTES = 950_000;
/** HTTP status codes indicating a transient server error. Retryable. */
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
/** HTTP status codes indicating a permanent client error. Non-retryable. */
const NON_RETRYABLE_STATUS_CODES = new Set([400, 401, 403, 404]);
// ---------------------------------------------------------------------------
// Pure helpers (exported for test use)
// ---------------------------------------------------------------------------
/**
 * Serialise a batch of EventPayload objects to NDJSON format.
 * Each event is JSON.stringify'd and separated by a newline.
 * Single-event batches produce one line (no trailing newline required
 * but Sumo Logic accepts both).
 */
export function serializeToNdjson(events) {
    return events.map(e => JSON.stringify(e)).join('\n');
}
/**
 * Chunk events into batches where each batch's NDJSON serialisation
 * fits within maxBytesPerBatch. Events whose individual serialisation
 * exceeds the limit are returned in the `oversized` array.
 *
 * Byte accounting uses the raw UTF-8 byte length of each serialised
 * line plus one byte for the newline separator between lines.
 */
export function chunkEventsByBytes(events, maxBytesPerBatch) {
    const chunks = [];
    const oversized = [];
    let current = [];
    let currentBytes = 0;
    for (const evt of events) {
        const line = JSON.stringify(evt);
        // UTF-8 byte length of the line plus newline separator (except for the first line).
        const lineBytes = Buffer.byteLength(line, 'utf8') + (current.length > 0 ? 1 : 0);
        // Event too large to fit in any batch on its own.
        if (lineBytes > maxBytesPerBatch) {
            oversized.push(evt);
            continue;
        }
        if (current.length > 0 && currentBytes + lineBytes > maxBytesPerBatch) {
            chunks.push(current);
            current = [];
            currentBytes = 0;
        }
        current.push(evt);
        currentBytes += lineBytes;
    }
    if (current.length > 0) {
        chunks.push(current);
    }
    return { chunks, oversized };
}
/**
 * Compute exponential backoff with 25% jitter.
 *   delay = base * 2^(attempt-1) + random(0, base * 0.25)
 */
export function exponentialDelay(attempt, baseMs) {
    const base = baseMs * Math.pow(2, attempt - 1);
    const jitter = Math.floor(Math.random() * base * 0.25);
    return base + jitter;
}
/**
 * Parse the Retry-After header value.
 * Accepts: integer seconds ("30") or HTTP-date.
 * Returns milliseconds to wait, or undefined if unparseable.
 */
export function parseRetryAfter(headerValue) {
    if (!headerValue)
        return undefined;
    const seconds = parseInt(headerValue, 10);
    if (!Number.isNaN(seconds) && seconds > 0) {
        return seconds * 1_000;
    }
    const date = new Date(headerValue);
    if (!Number.isNaN(date.getTime())) {
        const diff = date.getTime() - Date.now();
        return diff > 0 ? diff : undefined;
    }
    return undefined;
}
/**
 * Validate the endpoint URL at construction time.
 * Throws a descriptive error for malformed or non-HTTPS URLs (PT-SUMO-2).
 */
export function validateEndpointUrl(url, allowInsecure, label) {
    let parsed;
    try {
        parsed = new URL(url);
    }
    catch {
        throw new Error(`${label}: malformed endpoint URL. ` +
            'Provide a valid HTTPS Sumo Logic HTTP Source URL, e.g. ' +
            'https://endpoint1.collection.us2.sumologic.com/receiver/v1/http/<token>');
    }
    if (!allowInsecure && parsed.protocol !== 'https:') {
        throw new Error(`${label}: endpoint URL must use HTTPS. ` +
            `Received protocol: "${parsed.protocol}". ` +
            'Set allowInsecure:true only in test environments.');
    }
}
/**
 * Sanitise an endpoint URL for safe use in logs and error messages.
 * Returns only the scheme + host (strips the embedded token path).
 * NEVER returns the full URL which contains the secret token (PT-SUMO-1).
 */
export function sanitizeEndpointUrl(rawUrl) {
    try {
        const u = new URL(rawUrl);
        return `${u.protocol}//${u.hostname}`;
    }
    catch {
        return '[invalid-endpoint]';
    }
}
// ---------------------------------------------------------------------------
// Main class
// ---------------------------------------------------------------------------
export class SumoLogicHttpDestination {
    cfg;
    resolver;
    dlqFile;
    constructor(config, resolver) {
        // Pack-binding primacy: enforce BEFORE any other validation (PT-SUMO-3).
        if (!config.packBinding || config.packBinding.length === 0) {
            throw new UnboundDataError('SumoLogicHttpDestination.config', 'unknown');
        }
        // Validate config at construction time (PT-SUMO-2).
        if (!config.endpointUrlRef || typeof config.endpointUrlRef !== 'string') {
            throw new Error('SumoLogicHttpDestination: endpointUrlRef is required. ' +
                'Provide a KMS/secret-manager reference that resolves to the HTTP Source URL.');
        }
        const maxBatchBytes = config.maxBatchBytes ?? SUMO_DEFAULT_MAX_BATCH_BYTES;
        if (maxBatchBytes <= 0 || maxBatchBytes > SUMO_MAX_BYTES) {
            throw new Error(`SumoLogicHttpDestination: maxBatchBytes must be between 1 and ${SUMO_MAX_BYTES}. ` +
                `Received: ${maxBatchBytes}.`);
        }
        if (typeof resolver !== 'function') {
            throw new Error('SumoLogicHttpDestination: resolver must be a function returning Promise<string>.');
        }
        this.resolver = resolver;
        this.cfg = {
            kind: 'sumo-logic-http',
            endpointUrlRef: config.endpointUrlRef,
            compress: config.compress ?? true,
            maxBatchBytes,
            packBinding: config.packBinding,
            dlqDir: config.dlqDir,
            allowInsecure: config.allowInsecure ?? false,
            timeoutMs: config.timeoutMs ?? 10_000,
            maxRetries: config.maxRetries ?? 3,
            retryDelayMs: config.retryDelayMs ?? 1_000,
            sourceName: config.sourceName,
            sourceCategory: config.sourceCategory,
            sourceHost: config.sourceHost,
            sourceFields: config.sourceFields,
        };
        if (!fs.existsSync(this.cfg.dlqDir)) {
            fs.mkdirSync(this.cfg.dlqDir, { recursive: true });
        }
        this.dlqFile = path.join(this.cfg.dlqDir, 'sumo-logic-dlq.jsonl');
    }
    // ---------------------------------------------------------------------------
    // Public API
    // ---------------------------------------------------------------------------
    /**
     * Deliver an array of governance events to Sumo Logic.
     *
     * Events are chunked by serialised byte size to stay under the 1 MB limit.
     * Each chunk is posted in sequence. Oversized individual events are DLQ'd.
     * Returns an aggregated DeliveryResult.
     */
    async deliver(events) {
        if (events.length === 0) {
            return { success: true, eventCount: 0, attempts: 0 };
        }
        // Resolve the endpoint URL at call time. Never cached on the instance.
        let endpointUrl;
        try {
            endpointUrl = await this.resolver(this.cfg.endpointUrlRef);
        }
        catch (err) {
            // Do NOT include endpointUrlRef in error -- it may itself be sensitive context.
            const error = `Secret resolver failed: ${err.message}`;
            this.writeDLQ(events, error, 0, false);
            return { success: false, eventCount: events.length, attempts: 0, error, retryable: false };
        }
        // Validate the resolved URL (HTTPS enforcement). Test environments may use HTTP
        // when allowInsecure is true.
        try {
            validateEndpointUrl(endpointUrl, this.cfg.allowInsecure, 'SumoLogicHttpDestination.deliver');
        }
        catch (err) {
            // Sanitise before logging -- URL may contain token.
            const error = `Invalid endpoint URL: ${err.message}`;
            this.writeDLQ(events, error, 0, false);
            return { success: false, eventCount: events.length, attempts: 0, error, retryable: false };
        }
        const { chunks, oversized } = chunkEventsByBytes(events, this.cfg.maxBatchBytes);
        // DLQ individual events that exceed the per-request limit.
        for (const evt of oversized) {
            this.writeDLQ([evt], `Event too large to fit in a single request (maxBatchBytes=${this.cfg.maxBatchBytes})`, 0, false);
        }
        if (chunks.length === 0) {
            // All events were oversized.
            return {
                success: false,
                eventCount: events.length,
                attempts: 0,
                error: 'All events exceeded maxBatchBytes; written to DLQ',
                retryable: false,
            };
        }
        // Post each chunk and aggregate results.
        let totalAttempts = 0;
        let lastStatus;
        let lastError;
        let allSuccess = true;
        for (const chunk of chunks) {
            const result = await this.postChunk(chunk, endpointUrl);
            totalAttempts += result.attempts;
            lastStatus = result.statusCode;
            if (!result.success) {
                allSuccess = false;
                lastError = result.error;
            }
        }
        if (allSuccess) {
            return {
                success: true,
                eventCount: events.length,
                statusCode: lastStatus,
                attempts: totalAttempts,
                deliveredAt: new Date().toISOString(),
                retryable: false,
            };
        }
        return {
            success: false,
            eventCount: events.length,
            statusCode: lastStatus,
            attempts: totalAttempts,
            error: lastError,
            retryable: true,
        };
    }
    /**
     * Flush alias for API compatibility with Splunk/Datadog/Sentinel adapters.
     * SumoLogicHttpDestination is not buffered -- deliver() sends immediately.
     * Calling flush() is a no-op that returns a success result.
     */
    async flush() {
        return { success: true, eventCount: 0, attempts: 0 };
    }
    /**
     * Close alias for API compatibility.
     * No persistent connections to tear down.
     */
    async close() {
        // Nothing to do: each deliver() call uses a fresh HTTP request.
    }
    /** Retrieve dead-letter queue entries (most recent `limit` entries). */
    getDLQ(limit = 100) {
        if (!fs.existsSync(this.dlqFile))
            return [];
        try {
            const lines = fs.readFileSync(this.dlqFile, 'utf-8')
                .trim()
                .split('\n')
                .filter(Boolean);
            return lines
                .slice(-limit)
                .map(line => {
                try {
                    return JSON.parse(line);
                }
                catch {
                    return null;
                }
            })
                .filter((e) => e !== null);
        }
        catch {
            return [];
        }
    }
    // ---------------------------------------------------------------------------
    // Internal helpers
    // ---------------------------------------------------------------------------
    /**
     * POST a single chunk to Sumo Logic with retry + DLQ semantics.
     *
     * Security: the endpointUrl parameter is passed in per-call and NEVER
     * stored on the instance. It is used only inside this method and httpPost().
     * Any logging uses sanitizeEndpointUrl() which strips the embedded token.
     */
    async postChunk(events, endpointUrl) {
        const bodyStr = serializeToNdjson(events);
        let lastError = '';
        let lastStatus;
        let retryable = true;
        for (let attempt = 1; attempt <= this.cfg.maxRetries; attempt++) {
            try {
                const result = await this.httpPost(bodyStr, endpointUrl);
                lastStatus = result.statusCode;
                if (result.statusCode >= 200 && result.statusCode < 300) {
                    return {
                        success: true,
                        eventCount: events.length,
                        statusCode: result.statusCode,
                        attempts: attempt,
                        deliveredAt: new Date().toISOString(),
                        retryable: false,
                    };
                }
                if (NON_RETRYABLE_STATUS_CODES.has(result.statusCode)) {
                    lastError = `HTTP ${result.statusCode} (non-retryable)`;
                    retryable = false;
                    // Sanitised endpoint logged -- embedded token never appears.
                    log.error('Sumo Logic non-retryable error', {
                        status: result.statusCode,
                        endpoint: sanitizeEndpointUrl(endpointUrl),
                    });
                    break;
                }
                if (RETRYABLE_STATUS_CODES.has(result.statusCode)) {
                    lastError = `HTTP ${result.statusCode}`;
                    retryable = true;
                    // Honour Retry-After on 429.
                    if (result.statusCode === 429 && attempt < this.cfg.maxRetries) {
                        const retryAfterMs = parseRetryAfter(result.retryAfterHeader);
                        if (retryAfterMs !== undefined) {
                            await new Promise(r => setTimeout(r, retryAfterMs));
                            continue;
                        }
                    }
                }
                else {
                    lastError = `HTTP ${result.statusCode} (unexpected)`;
                    retryable = false;
                    break;
                }
            }
            catch (err) {
                lastError = err.message;
                retryable = true;
                log.warn('Sumo Logic connection error', {
                    attempt,
                    endpoint: sanitizeEndpointUrl(endpointUrl),
                    error: lastError,
                });
            }
            if (attempt < this.cfg.maxRetries && retryable) {
                await new Promise(r => setTimeout(r, exponentialDelay(attempt, this.cfg.retryDelayMs)));
            }
        }
        const error = lastError || `HTTP ${lastStatus}`;
        this.writeDLQ(events, error, this.cfg.maxRetries, retryable);
        return {
            success: false,
            eventCount: events.length,
            statusCode: lastStatus,
            attempts: this.cfg.maxRetries,
            error,
            retryable,
        };
    }
    /**
     * Execute the HTTPS POST to the Sumo Logic HTTP Source endpoint.
     *
     * Security contract (PT-SUMO-1):
     *   - endpointUrl is accepted as a parameter and used only inside this method.
     *   - It is NEVER stored on the instance.
     *   - It is NEVER written to any log. Only sanitizeEndpointUrl() output may be logged.
     *   - If it surfaces in a Node.js error message (e.g. ECONNREFUSED with URL),
     *     that error is sanitised before propagation.
     */
    httpPost(body, endpointUrl) {
        return new Promise((resolve, reject) => {
            const url = new URL(endpointUrl);
            const isHttps = url.protocol === 'https:';
            const transport = isHttps ? https : http;
            const optionalHeaders = {};
            if (this.cfg.sourceName) {
                optionalHeaders['X-Sumo-Name'] = this.cfg.sourceName;
            }
            if (this.cfg.sourceCategory) {
                optionalHeaders['X-Sumo-Category'] = this.cfg.sourceCategory;
            }
            if (this.cfg.sourceHost) {
                optionalHeaders['X-Sumo-Host'] = this.cfg.sourceHost;
            }
            else if (process.env['HOSTNAME']) {
                optionalHeaders['X-Sumo-Host'] = process.env['HOSTNAME'];
            }
            if (this.cfg.sourceFields) {
                optionalHeaders['X-Sumo-Fields'] = this.cfg.sourceFields;
            }
            const dispatch = (finalBody, extraHeaders) => {
                const headers = {
                    'Content-Type': 'application/x-ndjson',
                    'Content-Length': finalBody.length.toString(),
                    'User-Agent': '@connexum/ai-governance sumo-logic/1.0',
                    ...optionalHeaders,
                    ...extraHeaders,
                };
                const req = transport.request({
                    hostname: url.hostname,
                    port: url.port || (isHttps ? 443 : 80),
                    path: url.pathname + url.search,
                    method: 'POST',
                    headers,
                    timeout: this.cfg.timeoutMs,
                }, (res) => {
                    let rb = '';
                    res.on('data', (chunk) => { rb += chunk.toString(); });
                    res.on('end', () => {
                        resolve({
                            statusCode: res.statusCode ?? 0,
                            body: rb,
                            retryAfterHeader: res.headers['retry-after'],
                        });
                    });
                });
                req.on('error', (err) => {
                    // Sanitise error message: ensure the endpoint URL (containing the token)
                    // never surfaces in error propagation (PT-SUMO-1).
                    const raw = err.message ?? '';
                    if (raw.includes(endpointUrl) || raw.includes(url.pathname)) {
                        reject(new Error('Connection error (details redacted for security)'));
                    }
                    else {
                        reject(err);
                    }
                });
                req.on('timeout', () => {
                    req.destroy(new Error('Sumo Logic request timeout'));
                });
                req.write(finalBody);
                req.end();
            };
            const bodyBuf = Buffer.from(body, 'utf-8');
            if (this.cfg.compress) {
                zlib.gzip(bodyBuf, (err, compressed) => {
                    if (err) {
                        reject(err);
                        return;
                    }
                    dispatch(compressed, {
                        'Content-Encoding': 'gzip',
                        'Content-Length': compressed.length.toString(),
                    });
                });
            }
            else {
                dispatch(bodyBuf, {});
            }
        });
    }
    writeDLQ(events, error, attempts, retryable) {
        const entry = {
            events,
            error,
            attempts,
            failedAt: new Date().toISOString(),
            retryable,
        };
        try {
            fs.appendFileSync(this.dlqFile, JSON.stringify(entry) + '\n');
        }
        catch {
            log.error('Failed to write Sumo Logic DLQ entry', { dlqFile: this.dlqFile });
        }
    }
}
//# sourceMappingURL=sumo-logic.js.map