import { gunzipSync, inflateRawSync } from "node:zlib";
import { HarnessApiError } from "./errors.js";
import { createLogger } from "./logger.js";
const log = createLogger("log-resolver");
const DEFAULT_POLL_ATTEMPTS = 3;
const DEFAULT_POLL_INTERVAL_MS = 3000;
const DEFAULT_MAX_LOG_BYTES = 10 * 1024 * 1024; // 10 MB
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30_000;
/** Hosts that serve blob content directly — never rewrite. */
const EXTERNAL_STORAGE_HOSTS = new Set([
    "storage.googleapis.com",
    "storage.cloud.google.com",
    "s3.amazonaws.com",
    "s3.us-east-1.amazonaws.com",
    "s3.us-east-2.amazonaws.com",
    "s3.us-west-1.amazonaws.com",
    "s3.us-west-2.amazonaws.com",
    "s3.eu-west-1.amazonaws.com",
    "s3.eu-central-1.amazonaws.com",
    "s3.ap-southeast-1.amazonaws.com",
    "s3.ap-northeast-1.amazonaws.com",
]);
/** S3-style host pattern: bucket.s3.amazonaws.com or bucket.s3.region.amazonaws.com */
const S3_BUCKET_HOST_RE = /^[a-z0-9][a-z0-9.-]*\.s3([.-][a-z0-9-]+)?\.amazonaws\.com$/i;
function safeParseUrl(raw) {
    try {
        return new URL(raw);
    }
    catch {
        return undefined;
    }
}
function isExternalStorageHost(host) {
    const h = host.toLowerCase();
    if (EXTERNAL_STORAGE_HOSTS.has(h))
        return true;
    if (S3_BUCKET_HOST_RE.test(h))
        return true;
    if (h.endsWith(".storage.googleapis.com"))
        return true;
    return false;
}
/**
 * Detect Harness-internal hosts that are never publicly routable.
 * Even when these URLs carry pre-signed query params, they must route
 * through the client so internal deployments can rewrite the host.
 */
function isHarnessHost(host) {
    const h = host.toLowerCase();
    return h === "harness.io" || h.endsWith(".harness.io");
}
/**
 * Detect pre-signed URLs that should be fetched directly without auth.
 * GCS and S3 signed URLs carry their credentials in query params — adding
 * extra auth headers or rewriting the host invalidates the signature.
 */
function isPresignedUrl(url) {
    if (url.searchParams.has("X-Goog-Signature"))
        return true;
    if (url.searchParams.has("X-Amz-Signature"))
        return true;
    return false;
}
/**
 * True when the URL's signature covers the HTTP `Host` header.
 * Rewriting the hostname for CDN fetch would invalidate the signature (401).
 */
function signedHeadersIncludeHost(url) {
    const goog = url.searchParams.get("X-Goog-SignedHeaders");
    if (goog) {
        return goog
            .split(";")
            .map((s) => s.trim().toLowerCase())
            .includes("host");
    }
    const amz = url.searchParams.get("X-Amz-SignedHeaders");
    if (amz) {
        return amz
            .split(";")
            .map((s) => s.trim().toLowerCase())
            .includes("host");
    }
    return false;
}
// ─── ANSI / log parsing helpers ─────────────────────────────────────────────
const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g;
function stripAnsi(text) {
    return text.replace(ANSI_RE, "");
}
/**
 * Parse Harness JSON log lines into human-readable text.
 * Each line may be `{"level":"INFO","time":"...","out":"actual text"}`.
 * Non-JSON lines are passed through as-is.
 */
function parseLogLines(raw) {
    const lines = raw.split("\n");
    const parsed = [];
    for (const line of lines) {
        const trimmed = line.trim();
        if (!trimmed)
            continue;
        if (trimmed.startsWith("{")) {
            try {
                const entry = JSON.parse(trimmed);
                const out = String(entry.out ?? entry.message ?? entry.msg ?? "");
                const time = String(entry.time ?? entry.timestamp ?? entry.ts ?? "");
                const level = String(entry.level ?? "");
                const prefix = time ? `[${time}]${level ? ` ${level.toLowerCase()}:` : ""}` : "";
                parsed.push({ time, text: stripAnsi(`${prefix} ${out}`.trim()) });
            }
            catch {
                parsed.push({ time: "", text: stripAnsi(trimmed) });
            }
        }
        else {
            parsed.push({ time: "", text: stripAnsi(trimmed) });
        }
    }
    // Sort by timestamp if available
    parsed.sort((a, b) => (a.time && b.time ? a.time.localeCompare(b.time) : 0));
    return parsed.map((p) => p.text).join("\n");
}
// ─── ZIP extraction (minimal reader) ────────────────────────────────────────
// ZIP format signature bytes (defined by the PKWARE ZIP specification).
// These are fixed binary markers present in every ZIP file — they identify
// the type of record, not file contents or directory structure.
const ZIP_MAGIC = 0x04034b50; // PK\x03\x04 — Local file header signature
const GZIP_MAGIC_0 = 0x1f; // First byte of gzip magic number
const GZIP_MAGIC_1 = 0x8b; // Second byte of gzip magic number
const EOCD_SIG = 0x06054b50; // PK\x05\x06 — End of Central Directory record signature
const CD_SIG = 0x02014b50; // PK\x01\x02 — Central Directory file header signature
/**
 * Minimal ZIP reader — extracts all files from a ZIP archive.
 *
 * Why Central Directory instead of local headers?
 * When a ZIP is created in streaming mode (as the Harness log-service does),
 * the compressor doesn't know sizes upfront. It sets bit 3 (data descriptor flag)
 * in local headers and writes compressedSize=0, uncompressedSize=0 as placeholders.
 * The real sizes are only available in the Central Directory at the end of the file.
 *
 * Only supports DEFLATE (method 8) and STORED (method 0) entries.
 */
function extractZipEntries(buf) {
    // Step 1: Find the End of Central Directory (EOCD) record by scanning backwards.
    // The EOCD is always the last record in a ZIP. Its minimum size is 22 bytes:
    //   4 (signature) + 2 (disk#) + 2 (disk w/ CD) + 2 (CD entries this disk)
    //   + 2 (total CD entries) + 4 (CD size) + 4 (CD offset) + 2 (comment length)
    // So the earliest it can start is at buf.length - 22.
    let eocdOffset = -1;
    for (let i = buf.length - 22; i >= 0; i--) {
        if (buf.readUInt32LE(i) === EOCD_SIG) {
            eocdOffset = i;
            break;
        }
    }
    if (eocdOffset === -1) {
        log.warn("ZIP EOCD not found, falling back to local-header-only parsing");
        return extractZipEntriesFromLocalHeaders(buf);
    }
    // Step 2: Read Central Directory location from the EOCD record.
    //   EOCD+12 = size of the Central Directory (4 bytes)
    //   EOCD+16 = offset where the Central Directory starts (4 bytes)
    const cdOffset = buf.readUInt32LE(eocdOffset + 16);
    const cdSize = buf.readUInt32LE(eocdOffset + 12);
    if (cdOffset + cdSize > buf.length) {
        log.warn("ZIP Central Directory extends beyond buffer", { cdOffset, cdSize, bufferLength: buf.length });
        return extractZipEntriesFromLocalHeaders(buf);
    }
    // Step 3: Parse Central Directory entries to get accurate sizes and local header offsets
    const entries = [];
    let pos = cdOffset;
    // Each CD entry has a 46-byte fixed header followed by variable-length fields.
    // Key offsets within the CD entry:
    //   +10  compression method (2 bytes): 0 = STORED, 8 = DEFLATE
    //   +20  compressed size (4 bytes) — always accurate here, unlike local headers
    //   +24  uncompressed size (4 bytes)
    //   +28  file name length (2 bytes)
    //   +30  extra field length (2 bytes)
    //   +32  file comment length (2 bytes)
    //   +42  offset to corresponding local file header (4 bytes)
    //   +46  file name (variable length)
    while (pos + 46 <= cdOffset + cdSize) {
        const sig = buf.readUInt32LE(pos);
        if (sig !== CD_SIG)
            break;
        const method = buf.readUInt16LE(pos + 10);
        const compressedSize = buf.readUInt32LE(pos + 20);
        const uncompressedSize = buf.readUInt32LE(pos + 24);
        const nameLen = buf.readUInt16LE(pos + 28);
        const extraLen = buf.readUInt16LE(pos + 30);
        const commentLen = buf.readUInt16LE(pos + 32);
        const localHeaderOffset = buf.readUInt32LE(pos + 42);
        const fileName = buf.subarray(pos + 46, pos + 46 + nameLen).toString("utf-8");
        // Advance past this CD entry (46-byte header + variable-length fields)
        pos += 46 + nameLen + extraLen + commentLen;
        // Read the local file header (30-byte fixed header) to compute where
        // the actual compressed data starts. We need the local header's own
        // nameLen and extraLen since they may differ from the CD entry's values.
        //   localHeader+26 = file name length (2 bytes)
        //   localHeader+28 = extra field length (2 bytes)
        //   data starts at: localHeader + 30 + nameLen + extraLen
        if (localHeaderOffset + 30 > buf.length)
            continue;
        const localNameLen = buf.readUInt16LE(localHeaderOffset + 26);
        const localExtraLen = buf.readUInt16LE(localHeaderOffset + 28);
        const dataStart = localHeaderOffset + 30 + localNameLen + localExtraLen;
        if (dataStart + compressedSize > buf.length) {
            log.warn("ZIP entry data extends beyond buffer", { fileName, dataStart, compressedSize, bufferLength: buf.length });
            continue;
        }
        const compressedData = buf.subarray(dataStart, dataStart + compressedSize);
        let fileData;
        if (method === 0) {
            // STORED — data is uncompressed, use as-is
            fileData = compressedData;
        }
        else if (method === 8) {
            // DEFLATE — decompress using raw inflate (no zlib header)
            try {
                fileData = inflateRawSync(compressedData, { maxOutputLength: uncompressedSize || undefined });
            }
            catch (err) {
                log.warn("Failed to decompress ZIP entry", { fileName, method, compressedSize, uncompressedSize, error: String(err) });
                continue;
            }
        }
        else {
            log.warn("Unsupported ZIP compression method", { method, fileName });
            continue;
        }
        entries.push({ fileName, data: fileData });
    }
    return entries;
}
/**
 * Fallback: parse ZIP using local file headers only (no data-descriptor support).
 */
function extractZipEntriesFromLocalHeaders(buf) {
    const entries = [];
    let offset = 0;
    while (offset + 30 <= buf.length) {
        const sig = buf.readUInt32LE(offset);
        if (sig !== ZIP_MAGIC)
            break;
        const method = buf.readUInt16LE(offset + 8);
        const compressedSize = buf.readUInt32LE(offset + 18);
        const uncompressedSize = buf.readUInt32LE(offset + 22);
        const nameLen = buf.readUInt16LE(offset + 26);
        const extraLen = buf.readUInt16LE(offset + 28);
        const fileName = buf.subarray(offset + 30, offset + 30 + nameLen).toString("utf-8");
        const dataStart = offset + 30 + nameLen + extraLen;
        if (dataStart + compressedSize > buf.length)
            break;
        const compressedData = buf.subarray(dataStart, dataStart + compressedSize);
        let fileData;
        if (method === 0) {
            fileData = compressedData;
        }
        else if (method === 8) {
            try {
                fileData = inflateRawSync(compressedData, { maxOutputLength: uncompressedSize || undefined });
            }
            catch (err) {
                log.warn("Failed to decompress ZIP entry (fallback)", { fileName, error: String(err) });
                offset = dataStart + compressedSize;
                continue;
            }
        }
        else {
            offset = dataStart + compressedSize;
            continue;
        }
        entries.push({ fileName, data: fileData });
        offset = dataStart + compressedSize;
    }
    return entries;
}
/**
 * Decompress a downloaded blob — handles gzip, zip, or plain text.
 */
function decompressBlob(buf) {
    if (buf.length === 0)
        return "";
    // Gzip
    if (buf[0] === GZIP_MAGIC_0 && buf[1] === GZIP_MAGIC_1) {
        const decompressed = gunzipSync(buf, { maxOutputLength: DEFAULT_MAX_LOG_BYTES * 10 });
        return decompressed.toString("utf-8");
    }
    // ZIP
    if (buf.length >= 4 && buf.readUInt32LE(0) === ZIP_MAGIC) {
        const entries = extractZipEntries(buf);
        // Sort by filename (typically contains timestamps)
        entries.sort((a, b) => a.fileName.localeCompare(b.fileName));
        return entries.map((e) => e.data.toString("utf-8")).join("\n");
    }
    // Plain text
    return buf.toString("utf-8");
}
// ─── Blob download ──────────────────────────────────────────────────────────
const LOG_SERVICE_GATEWAY_PREFIX = "/gateway/log-service";
/**
 * Download the blob content from the link returned by log-service.
 *
 * External storage URLs (GCS, S3) are pre-signed and fetched directly.
 * Harness-hosted URLs are routed through the client so that auth headers
 * (PAT, service JWT, or log-service token) are injected by the client or
 * any proxy installed on it (e.g. mcpServerInternal's service routing).
 */
async function downloadBlobContent(client, blobLink, prefix, signal) {
    const blobUrl = safeParseUrl(blobLink);
    // Three routing strategies based on the blob URL:
    //
    // 1. True external storage (S3, GCS domains) → direct fetch, no auth headers.
    //    The URL is publicly routable with embedded signature params; routing through
    //    the client or adding extra headers would invalidate the AWS/GCS signature.
    //
    // 2. *.harness.io pre-signed CDN blob URLs → usually rewrite hostname to match
    //    HARNESS_BASE_URL host, then direct fetch.
    //    The Harness log-service always returns blob links pointing to app.harness.io/storage/...
    //    regardless of the configured base URL. On self-managed deployments app.harness.io is
    //    not directly reachable, but the CDN is accessible via the configured host
    //    (e.g. self-managed.example.com/storage/...). The pre-signed params authenticate the request
    //    so no API key header is needed — and adding one would not help anyway since this is
    //    a CDN path, not an API gateway path (/gateway/... would 403 for /storage/... paths).
    //    Exception: if SignedHeaders includes `host`, the signature is bound to the link's
    //    hostname — do not rewrite; fetch the original URL (e.g. QA SaaS with qa.harness.io base).
    //
    // 3. Standard log-service paths → client.requestStream() with gateway prefix so
    //    PAT/JWT auth headers are injected by the client proxy.
    if (blobUrl && isExternalStorageHost(blobUrl.hostname)) {
        // Strategy 1: true external storage — always direct fetch
        log.debug("Downloading log blob (direct, external storage)", { prefix, url: blobLink.slice(0, 80) });
        try {
            return await fetch(blobLink, { signal });
        }
        catch (err) {
            const cause = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
            throw new Error(`Log download fetch failed for ${blobUrl.hostname}: ${cause}`);
        }
    }
    if (blobUrl && isPresignedUrl(blobUrl) && isHarnessHost(blobUrl.hostname) && blobUrl.pathname.startsWith("/storage/")) {
        // Strategy 2: *.harness.io CDN blob — rewrite hostname and direct fetch.
        // Guarded by /storage/ prefix to avoid misrouting signed API paths (e.g. /gateway/log-service/...)
        // that happen to carry pre-signed params — those must go through client.requestStream() for auth.
        // The blob link always points to app.harness.io/storage/... regardless of HARNESS_BASE_URL.
        // Rewrite to the configured host so self-managed deployments can reach it.
        if (signedHeadersIncludeHost(blobUrl)) {
            log.debug("Downloading log blob (direct, host-bound presigned CDN)", {
                prefix,
                url: blobLink.slice(0, 80),
            });
            try {
                return await fetch(blobLink, { signal });
            }
            catch (err) {
                const cause = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
                throw new Error(`Log download fetch failed for ${blobUrl.hostname}: ${cause}`);
            }
        }
        const baseUrl = safeParseUrl(client.baseURL);
        if (!baseUrl) {
            // If baseURL is unparseable we cannot safely rewrite — throw rather than
            // direct-fetching the original app.harness.io URL (which is blocked on self-managed nets).
            throw new Error(`Cannot rewrite Harness CDN blob URL: HARNESS_BASE_URL "${client.baseURL}" is not a valid URL`);
        }
        blobUrl.hostname = baseUrl.hostname;
        blobUrl.protocol = baseUrl.protocol;
        // Preserve non-default port from baseURL (e.g. https://gateway.example.com:8443/...)
        blobUrl.port = baseUrl.port;
        const rewrittenUrl = blobUrl.toString();
        log.debug("Downloading log blob (direct, host-rewritten)", { prefix, url: rewrittenUrl.slice(0, 80) });
        try {
            return await fetch(rewrittenUrl, { signal });
        }
        catch (err) {
            const cause = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
            throw new Error(`Log download fetch failed for ${blobUrl.hostname}: ${cause}`);
        }
    }
    // Strategy 3: standard log-service path — route through client for auth injection.
    const rawPath = blobUrl
        ? blobUrl.pathname + blobUrl.search
        : blobLink.startsWith("/") ? blobLink : `/${blobLink}`;
    const downloadPath = rawPath.startsWith(LOG_SERVICE_GATEWAY_PREFIX)
        ? rawPath
        : `${LOG_SERVICE_GATEWAY_PREFIX}${rawPath}`;
    log.debug("Downloading log blob (client)", { prefix, path: downloadPath.slice(0, 80) });
    try {
        return await client.requestStream({
            method: "GET",
            path: downloadPath,
            signal,
        });
    }
    catch (err) {
        if (err instanceof HarnessApiError)
            throw err;
        const cause = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
        throw new Error(`Log download fetch failed for ${blobUrl?.hostname ?? "harness"}: ${cause}`);
    }
}
// ─── Public API ─────────────────────────────────────────────────────────────
/**
 * Resolve execution log content from the Harness log-service.
 *
 * Full pipeline: initiate blob download → poll until ready → download zip →
 * extract → parse JSON log entries → return clean text.
 */
export async function resolveLogContent(client, prefix, options) {
    const maxAttempts = options?.maxPollAttempts ?? DEFAULT_POLL_ATTEMPTS;
    const pollInterval = options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
    const maxBytes = options?.maxLogSizeBytes ?? DEFAULT_MAX_LOG_BYTES;
    const signal = options?.signal;
    // Step 1 & 2: Initiate and poll until status is "success"
    let blob;
    for (let attempt = 0; attempt < maxAttempts; attempt++) {
        if (signal?.aborted)
            throw new Error("Log download cancelled");
        log.debug("Requesting log blob", { prefix, attempt });
        blob = await client.request({
            method: "POST",
            path: "/gateway/log-service/blob/download",
            params: { prefix },
            signal,
        });
        if (blob?.status === "success" && blob.link) {
            break;
        }
        if (attempt < maxAttempts - 1) {
            log.debug("Log blob not ready, polling", { status: blob?.status, attempt });
            await new Promise((r) => setTimeout(r, pollInterval));
        }
    }
    if (!blob?.link) {
        throw new Error(`Log blob not ready after ${maxAttempts} attempts (status: ${blob?.status ?? "unknown"}). Logs may still be processing or have expired.`);
    }
    // Step 3: Download the zip/gzip from the signed URL
    const downloadSignal = signal
        ? AbortSignal.any([signal, AbortSignal.timeout(DEFAULT_DOWNLOAD_TIMEOUT_MS)])
        : AbortSignal.timeout(DEFAULT_DOWNLOAD_TIMEOUT_MS);
    const response = await downloadBlobContent(client, blob.link, prefix, downloadSignal);
    if (!response.ok) {
        const errBody = await response.text().catch(() => "");
        throw new Error(`Log download failed: HTTP ${response.status} — ${errBody.slice(0, 200)}`);
    }
    const contentLength = Number(response.headers.get("content-length") ?? 0);
    if (contentLength > maxBytes) {
        throw new Error(`Log file too large (${Math.round(contentLength / 1024 / 1024)}MB). Maximum: ${Math.round(maxBytes / 1024 / 1024)}MB.`);
    }
    const arrayBuf = await response.arrayBuffer();
    if (arrayBuf.byteLength > maxBytes) {
        throw new Error(`Log file too large (${Math.round(arrayBuf.byteLength / 1024 / 1024)}MB). Maximum: ${Math.round(maxBytes / 1024 / 1024)}MB.`);
    }
    const buf = Buffer.from(arrayBuf);
    log.debug("Downloaded blob", {
        bytes: buf.length,
        contentType: response.headers.get("content-type"),
        magic: buf.length >= 4 ? buf.subarray(0, 4).toString("hex") : "empty",
    });
    // Step 4 & 5: Extract and parse
    const rawText = decompressBlob(buf);
    const parsed = parseLogLines(rawText);
    if (!parsed.trim()) {
        return "(empty log output)";
    }
    return parsed;
}
//# sourceMappingURL=log-resolver.js.map