/**
 * DatadogLogsDestination -- Datadog Logs Intake output adapter.
 *
 * Delivers governance events to the Datadog Logs Intake API v2 using an
 * array-of-log-objects POST. Mirrors the structural pattern of SplunkHecDestination
 * (plain Node HTTPS, no SDK, same retry / DLQ / gzip semantics).
 *
 * Design decisions:
 *   - No Datadog SDK. Plain HTTPS POST via Node built-ins (no supply-chain risk).
 *   - API key is NEVER stored in plaintext. Only a KMS reference (apiKeyRef) is held.
 *     Resolved at send-time via the injected secret resolver.
 *   - Retry semantics mirror splunk-hec.ts and webhook-delivery.ts:
 *     429 (rate-limited) + 408 (timeout) + 5xx = retryable.
 *     4xx other than 429 / 408 = non-retryable, DLQ immediately.
 *     On 429: Retry-After header is honoured if present.
 *   - Gzip compression (built-in zlib) applied by default. Configurable.
 *   - packBinding is mandatory. Empty binding throws UnboundDataError immediately.
 *   - API key is never emitted to any log stream.
 *   - Max 1000 events per request; max 5 MB body size (uncompressed). Chunking
 *     splits oversized batches into multiple requests automatically.
 *
 * Datadog Logs Intake wire format (v2):
 *   POST https://http-intake.logs.datadoghq.com/api/v2/logs
 *   DD-API-KEY: <api-key>
 *   Content-Type: application/json
 *   Content-Encoding: gzip  (when compression enabled)
 *   Body: [{ ddsource, ddtags, service, hostname, message, timestamp }, ...]
 *
 * Supported sites:
 *   us1     -> http-intake.logs.datadoghq.com   (default)
 *   us3     -> http-intake.logs.us3.datadoghq.com
 *   us5     -> http-intake.logs.us5.datadoghq.com
 *   eu1     -> http-intake.logs.datadoghq.eu
 *   us1-fed -> http-intake.logs.ddog-gov.com
 *
 * @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('datadog-logs');
// ---------------------------------------------------------------------------
// Site -> host mapping
// ---------------------------------------------------------------------------
/**
 * Canonical Datadog Logs Intake host for each supported site.
 * Source: https://docs.datadoghq.com/logs/log_collection/
 */
export const SITE_HOSTS = {
    'us1': 'http-intake.logs.datadoghq.com',
    'us3': 'http-intake.logs.us3.datadoghq.com',
    'us5': 'http-intake.logs.us5.datadoghq.com',
    'eu1': 'http-intake.logs.datadoghq.eu',
    'us1-fed': 'http-intake.logs.ddog-gov.com',
};
const LOGS_PATH = '/api/v2/logs';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Hard maximum events per Datadog Logs request. */
const DD_MAX_EVENTS = 1_000;
/** Hard maximum uncompressed body size per Datadog Logs request (5 MB). */
const DD_MAX_BYTES = 5_000_000;
/** Conservative default max events per chunk request. */
const DEFAULT_MAX_EVENTS_PER_REQUEST = 500;
/** Conservative default max bytes per chunk (leaves safety margin). */
const DEFAULT_MAX_BYTES_PER_REQUEST = 4_500_000;
/** HTTP status codes that are retryable. */
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
/** HTTP status codes that are immediately terminal (write to DLQ, no retry). */
const NON_RETRYABLE_4XX = new Set([400, 401, 403, 404, 413]);
// ---------------------------------------------------------------------------
// Pure helper exports (usable in tests without instantiating the class)
// ---------------------------------------------------------------------------
/**
 * Resolve the resolved Datadog intake URL from config.
 * baseUrl takes precedence over site. Falls back to us1.
 */
export function resolveIntakeUrl(cfg) {
    if (cfg.baseUrl) {
        return cfg.baseUrl;
    }
    const site = cfg.site ?? 'us1';
    const host = SITE_HOSTS[site];
    if (!host) {
        throw new Error(`DatadogLogsDestination: unknown site "${site}". ` +
            `Supported values: ${Object.keys(SITE_HOSTS).join(', ')}`);
    }
    return `https://${host}${LOGS_PATH}`;
}
/**
 * Map a governance EventPayload to a Datadog log object.
 *
 * Mapping table:
 *   message    <- JSON.stringify of event (full payload; Datadog parses nested JSON)
 *   timestamp  <- epoch ms from event.timestamp
 *   ddsource   <- cfg.ddsource
 *   ddtags     <- cfg.ddtags + "event_type:<type>,source:<source>"
 *   service    <- cfg.service
 *   hostname   <- cfg.hostname
 */
export function buildLogEvent(event, ddsource, ddtags, service, hostname) {
    const epochMs = new Date(event.timestamp).getTime();
    // Enrich ddtags with per-event type and source for SIEM filtering.
    const enrichedTags = [ddtags, `event_type:${event.type}`, `governance_source:${event.source}`]
        .filter(Boolean)
        .join(',');
    return {
        ddsource,
        ddtags: enrichedTags,
        service,
        hostname,
        message: JSON.stringify(event),
        timestamp: epochMs,
    };
}
/**
 * Chunk a flat array of events into sub-arrays that respect both the per-request
 * event count limit and the per-request byte size limit.
 *
 * Strategy:
 *   1. Iterate events, accumulating a running JSON array byte count.
 *   2. When adding the next event would exceed maxEvents or maxBytes, flush the
 *      current chunk and start a new one.
 */
export function chunkEvents(events, maxEvents, maxBytes) {
    // Clamp to hard Datadog limits.
    const effectiveMaxEvents = Math.min(maxEvents, DD_MAX_EVENTS);
    const effectiveMaxBytes = Math.min(maxBytes, DD_MAX_BYTES);
    const chunks = [];
    let current = [];
    // Start with 2 for the outer array brackets [].
    let currentBytes = 2;
    for (const evt of events) {
        // Add comma separator after first element.
        const eventJson = JSON.stringify(evt);
        const eventBytes = eventJson.length + (current.length > 0 ? 1 : 0);
        if (current.length >= effectiveMaxEvents ||
            (current.length > 0 && currentBytes + eventBytes > effectiveMaxBytes)) {
            chunks.push(current);
            current = [];
            currentBytes = 2;
        }
        current.push(evt);
        currentBytes += eventBytes;
    }
    if (current.length > 0) {
        chunks.push(current);
    }
    return chunks;
}
// ---------------------------------------------------------------------------
// Endpoint validation (pure, exported for tests)
// ---------------------------------------------------------------------------
/**
 * Validate a Datadog intake URL at construction time.
 * Throws a descriptive error on malformed input (PT-DATADOG-2).
 */
export function validateIntakeUrl(url, allowInsecure) {
    let parsed;
    try {
        parsed = new URL(url);
    }
    catch {
        throw new Error(`DatadogLogsDestination: malformed intake URL "${url}". ` +
            'Provide a valid HTTPS URL, e.g. https://http-intake.logs.datadoghq.com/api/v2/logs');
    }
    if (!allowInsecure && parsed.protocol !== 'https:') {
        throw new Error(`DatadogLogsDestination: intake URL must use HTTPS. Received protocol: "${parsed.protocol}". ` +
            'Set allowInsecure:true only in test environments.');
    }
}
// ---------------------------------------------------------------------------
// Main class
// ---------------------------------------------------------------------------
export class DatadogLogsDestination {
    cfg;
    resolver;
    dlqFile;
    intakeUrl;
    constructor(config, resolver) {
        // Pack-binding primacy: empty binding throws UnboundDataError before anything else.
        if (!config.packBinding || config.packBinding.length === 0) {
            throw new UnboundDataError('DatadogLogsDestination.config', 'unknown');
        }
        // Validate site early (PT-DATADOG-2).
        if (config.site !== undefined && !(config.site in SITE_HOSTS)) {
            throw new Error(`DatadogLogsDestination: invalid site "${config.site}". ` +
                `Supported values: ${Object.keys(SITE_HOSTS).join(', ')}`);
        }
        // Resolve and validate the intake URL at construction (not at first deliver).
        const intakeUrl = resolveIntakeUrl({ site: config.site, baseUrl: config.baseUrl });
        validateIntakeUrl(intakeUrl, config.allowInsecure ?? false);
        this.intakeUrl = intakeUrl;
        this.resolver = resolver;
        this.cfg = {
            site: config.site ?? 'us1',
            baseUrl: config.baseUrl ?? '',
            ddsource: 'aiagents:governance',
            ddtags: '',
            service: 'my-cc-governance',
            hostname: process.env['HOSTNAME'] ?? 'unknown',
            maxEventsPerRequest: DEFAULT_MAX_EVENTS_PER_REQUEST,
            maxBytesPerRequest: DEFAULT_MAX_BYTES_PER_REQUEST,
            compress: true,
            allowInsecure: false,
            timeoutMs: 10_000,
            maxRetries: 3,
            retryDelayMs: 1_000,
            ...config,
        };
        if (!fs.existsSync(this.cfg.dlqDir)) {
            fs.mkdirSync(this.cfg.dlqDir, { recursive: true });
        }
        this.dlqFile = path.join(this.cfg.dlqDir, 'datadog-logs-dlq.jsonl');
    }
    // ---------------------------------------------------------------------------
    // Public API
    // ---------------------------------------------------------------------------
    /**
     * Deliver one or more governance events to Datadog Logs Intake.
     *
     * Automatically chunks oversized batches respecting Datadog's per-request
     * limits (1000 events / 5 MB body). Returns an aggregate result for all chunks.
     *
     * The API key is resolved per-call from the secret resolver (PT-DATADOG-1).
     * It is never cached in instance state.
     */
    async deliver(events) {
        if (events.length === 0) {
            return { success: true, eventCount: 0, attempts: 0 };
        }
        // Resolve API key at deliver-time; never cached on the instance.
        let apiKey;
        try {
            apiKey = await this.resolver(this.cfg.apiKeyRef);
        }
        catch (err) {
            const error = `Secret resolver failed for apiKeyRef "${this.cfg.apiKeyRef}": ${err.message}`;
            this.writeDLQ(events, error, 0, false);
            return { success: false, eventCount: events.length, attempts: 0, error, retryable: false };
        }
        const logEvents = events.map(e => buildLogEvent(e, this.cfg.ddsource, this.cfg.ddtags, this.cfg.service, this.cfg.hostname));
        const chunks = chunkEvents(logEvents, this.cfg.maxEventsPerRequest, this.cfg.maxBytesPerRequest);
        let totalAttempts = 0;
        let lastStatus;
        let firstError;
        for (const chunk of chunks) {
            const result = await this.postChunk(chunk, events, apiKey);
            totalAttempts += result.attempts;
            if (result.statusCode !== undefined) {
                lastStatus = result.statusCode;
            }
            if (!result.success) {
                firstError = result.error;
                // On first chunk failure, stop delivering subsequent chunks.
                return {
                    success: false,
                    eventCount: events.length,
                    statusCode: lastStatus,
                    attempts: totalAttempts,
                    error: firstError,
                    retryable: result.retryable,
                };
            }
        }
        return {
            success: true,
            eventCount: events.length,
            statusCode: lastStatus,
            attempts: totalAttempts,
            deliveredAt: new Date().toISOString(),
            retryable: false,
        };
    }
    /**
     * Flush alias for API symmetry with SplunkHecDestination.
     * DatadogLogsDestination does not internally buffer events (deliver is
     * always immediate), so flush() is a no-op that returns success.
     */
    async flush() {
        return { success: true, eventCount: 0, attempts: 0 };
    }
    /** 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 [];
        }
    }
    // ---------------------------------------------------------------------------
    // Private helpers
    // ---------------------------------------------------------------------------
    /**
     * POST one chunk of Datadog log events with retry + DLQ semantics.
     *
     * API key is accepted as a parameter and only used in the DD-API-KEY header.
     * It is never stored in instance state or written to any log (PT-DATADOG-1).
     */
    async postChunk(chunk, originalEvents, apiKey) {
        const bodyStr = JSON.stringify(chunk);
        let lastError = '';
        let lastStatus;
        let retryable = true;
        for (let attempt = 1; attempt <= this.cfg.maxRetries; attempt++) {
            try {
                const result = await this.httpPost(bodyStr, apiKey, this.cfg.compress);
                lastStatus = result.statusCode;
                if (result.statusCode >= 200 && result.statusCode < 300) {
                    return {
                        success: true,
                        eventCount: chunk.length,
                        statusCode: result.statusCode,
                        attempts: attempt,
                        deliveredAt: new Date().toISOString(),
                        retryable: false,
                    };
                }
                if (result.statusCode === 429) {
                    // Rate limited. Honour Retry-After if provided, else exponential backoff.
                    lastError = 'HTTP 429 (rate limited)';
                    retryable = true;
                    if (attempt < this.cfg.maxRetries) {
                        const retryAfterMs = parseRetryAfter(result.retryAfterHeader);
                        const backoff = retryAfterMs ?? exponentialDelay(attempt, this.cfg.retryDelayMs);
                        log.warn('Datadog rate limited, waiting before retry', {
                            attempt,
                            waitMs: backoff,
                            site: this.cfg.site,
                        });
                        await new Promise(r => setTimeout(r, backoff));
                    }
                    continue;
                }
                if (NON_RETRYABLE_4XX.has(result.statusCode)) {
                    lastError = `HTTP ${result.statusCode} (non-retryable)`;
                    retryable = false;
                    // Log endpoint only; never log the key.
                    log.error('Datadog Logs non-retryable error', {
                        status: result.statusCode,
                        intakeUrl: this.safeUrl(),
                    });
                    break;
                }
                if (RETRYABLE_STATUS_CODES.has(result.statusCode)) {
                    lastError = `HTTP ${result.statusCode}`;
                    retryable = true;
                }
                else {
                    // Unexpected status code -- treat as terminal.
                    lastError = `HTTP ${result.statusCode} (unexpected)`;
                    retryable = false;
                    break;
                }
            }
            catch (err) {
                lastError = err.message ?? 'Connection error';
                retryable = true;
                log.warn('Datadog Logs connection error', {
                    attempt,
                    intakeUrl: this.safeUrl(),
                    error: lastError,
                });
            }
            if (attempt < this.cfg.maxRetries && retryable) {
                const backoff = exponentialDelay(attempt, this.cfg.retryDelayMs);
                await new Promise(r => setTimeout(r, backoff));
            }
        }
        const error = lastError || `HTTP ${lastStatus}`;
        this.writeDLQ(originalEvents, error, this.cfg.maxRetries, retryable);
        return {
            success: false,
            eventCount: chunk.length,
            statusCode: lastStatus,
            attempts: this.cfg.maxRetries,
            error,
            retryable,
        };
    }
    /**
     * Execute the HTTPS POST to the Datadog Logs intake endpoint.
     * The API key is accepted as a parameter and used only in the DD-API-KEY header.
     * It is never stored in instance state or logged.
     */
    httpPost(body, apiKey, compress) {
        return new Promise((resolve, reject) => {
            const url = new URL(this.intakeUrl);
            const isHttps = url.protocol === 'https:';
            const transport = isHttps ? https : http;
            const dispatch = (finalBody, extraHeaders) => {
                const headers = {
                    'Content-Type': 'application/json',
                    'Content-Length': finalBody.length.toString(),
                    'User-Agent': '@connexum/ai-governance datadog-logs/1.0',
                    // Datadog authentication -- API key NEVER logged.
                    'DD-API-KEY': apiKey,
                    ...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 API key never surfaces in error propagation.
                    const raw = err.message ?? '';
                    if (raw.includes(apiKey)) {
                        reject(new Error('Connection error (details redacted for security)'));
                    }
                    else {
                        reject(err);
                    }
                });
                req.on('timeout', () => {
                    req.destroy(new Error('Datadog Logs request timeout'));
                });
                req.write(finalBody);
                req.end();
            };
            const bodyBuf = Buffer.from(body, 'utf-8');
            if (compress) {
                zlib.gzip(bodyBuf, (err, compressed) => {
                    if (err) {
                        reject(err);
                        return;
                    }
                    dispatch(compressed, {
                        'Content-Encoding': 'gzip',
                        'Content-Length': compressed.length.toString(),
                    });
                });
            }
            else {
                dispatch(bodyBuf, {});
            }
        });
    }
    /** Strip credentials from the intake URL before logging. */
    safeUrl() {
        try {
            const u = new URL(this.intakeUrl);
            u.password = '';
            u.username = '';
            return u.toString();
        }
        catch {
            return '[invalid-url]';
        }
    }
    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 Datadog Logs DLQ entry', { dlqFile: this.dlqFile });
        }
    }
}
// ---------------------------------------------------------------------------
// Module-level pure helpers (internal use + test access)
// ---------------------------------------------------------------------------
/**
 * 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 ("Wed, 21 Oct 2015 07:28:00 GMT").
 * 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;
}
//# sourceMappingURL=datadog-logs.js.map