import { HarnessApiError } from "../utils/errors.js";
import { RateLimiter } from "../utils/rate-limiter.js";
import { createLogger } from "../utils/logger.js";
import { redactJsonString } from "../utils/redact.js";
const log = createLogger("harness-client");
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
/**
 * Whether the caller explicitly set a body (including empty string).
 * `""` must be preserved: `if (options.body)` treats it as absent, which drops the
 * YAML body on pipeline execute — Harness then ignores `inputSetIdentifiers` and
 * fails with "Value not provided for required variable".
 */
function hasExplicitBody(body) {
    return body !== undefined && body !== null;
}
function serializeRequestBody(body) {
    if (!hasExplicitBody(body))
        return undefined;
    return typeof body === "string" ? body : JSON.stringify(body);
}
const BASE_BACKOFF_MS = 1000;
/** Strip HTML tags, script/style contents, and collapse whitespace. */
function stripHtml(html) {
    return html
        .replace(/<script[\s>][\s\S]*?<\/script>/gi, " ")
        .replace(/<style[\s>][\s\S]*?<\/style>/gi, " ")
        .replace(/<[^>]*>/g, " ")
        .replace(/\s+/g, " ")
        .trim();
}
/** True when body is an HTML page (redirect, WAF block, proxy error). */
function isHtmlBody(body) {
    return /^\s*</.test(body) || /<!doctype/i.test(body.slice(0, 100));
}
/** Produce a clean, actionable error message for non-JSON HTTP error responses. */
function humanizeHttpError(status, rawBody) {
    const html = isHtmlBody(rawBody);
    switch (status) {
        case 401:
            return "HTTP 401 Unauthorized — API key is invalid or expired. Verify HARNESS_API_KEY is a valid PAT or Service Account token.";
        case 403:
            return "HTTP 403 Forbidden — access denied. Possible causes: wrong HARNESS_ACCOUNT_ID, IP restrictions, missing RBAC permissions, or corporate proxy/WAF blocking the request.";
        case 404:
            return `HTTP 404 Not Found — the API endpoint or resource does not exist. Verify the base URL and resource identifiers.`;
        default: {
            if (html)
                return `HTTP ${status}: Harness returned an HTML error page (possible proxy, WAF, or redirect). Check HARNESS_BASE_URL and network connectivity.`;
            const hint = rawBody.slice(0, 200);
            return `HTTP ${status}: ${hint || "empty response"}`;
        }
    }
}
/**
 * Detect when parsed.message contains HTML/JS from a redirect page
 * rather than a real Harness API error message.
 */
function isGarbageMessage(message) {
    if (!message)
        return true;
    if (isHtmlBody(message))
        return true;
    if (/function\s+\w+\s*\(/.test(message))
        return true;
    if (/redirectPage|signInPath|encodeURIComponent/.test(message))
        return true;
    return false;
}
/**
 * Per-path-prefix error field enrichments.
 * When an API error response contains the named field AND the request path
 * matches the prefix, the field value is appended to the error message.
 * Add entries here for endpoint groups that return extra context fields.
 */
const ERROR_FIELD_ENRICHMENTS = [
    { pathPrefix: "/chaos/", field: "description" },
    { pathPrefix: "/loadTest/", field: "description" },
];
function enrichErrorMessage(rawMessage, parsed, path) {
    let message = rawMessage;
    for (const { pathPrefix, field } of ERROR_FIELD_ENRICHMENTS) {
        const value = parsed[field];
        if (path.startsWith(pathPrefix) && typeof value === "string" && value) {
            message += ` — ${value}`;
        }
    }
    return message;
}
export class HarnessClient {
    baseUrl;
    token;
    accountId;
    timeout;
    maxRetries;
    rateLimiter;
    logUnsafeBodies;
    accountIdResolver;
    currentUserId;
    currentUserPromise;
    constructor(config) {
        this.baseUrl = config.HARNESS_BASE_URL.replace(/\/$/, "");
        this.token = config.HARNESS_API_KEY;
        this.accountId = config.HARNESS_ACCOUNT_ID;
        this.timeout = config.HARNESS_API_TIMEOUT_MS;
        this.maxRetries = config.HARNESS_MAX_RETRIES;
        this.rateLimiter = new RateLimiter(config.HARNESS_RATE_LIMIT_RPS);
        this.logUnsafeBodies = config.HARNESS_LOG_UNSAFE_BODIES;
    }
    /**
     * Set a per-request account ID resolver. When set, resolveAccountId()
     * calls this first, falling back to the static config value.
     */
    setAccountIdResolver(resolver) {
        this.accountIdResolver = resolver;
    }
    /** Resolve the account ID: per-request override → static config fallback. */
    resolveAccountId() {
        return this.accountIdResolver?.() ?? this.accountId;
    }
    get account() {
        return this.resolveAccountId();
    }
    get baseURL() {
        return this.baseUrl;
    }
    /**
     * Resolve the UUID of the user authenticated by the current PAT.
     * Cached for the lifetime of the process and inflight-deduped so concurrent
     * callers share a single GET /ng/api/user/currentUser request.
     *
     * Used by callers (e.g. STO exemption approve/reject/promote) that need to
     * stamp the authenticated user as the actor without forcing the LLM to ask
     * the human for a UUID.
     */
    async getCurrentUserId() {
        if (this.currentUserId)
            return this.currentUserId;
        if (this.currentUserPromise)
            return this.currentUserPromise;
        this.currentUserPromise = (async () => {
            const resp = await this.request({
                method: "GET",
                path: "/ng/api/user/currentUser",
            });
            const uuid = resp?.data?.uuid;
            if (!uuid) {
                throw new HarnessApiError("Could not resolve current user UUID via /ng/api/user/currentUser. " +
                    "The PAT may belong to a service account without a user identity.", 500);
            }
            this.currentUserId = uuid;
            return uuid;
        })();
        try {
            return await this.currentUserPromise;
        }
        catch (err) {
            this.currentUserPromise = undefined;
            throw err;
        }
    }
    async request(options) {
        await this.rateLimiter.acquire();
        const method = options.method ?? "GET";
        const url = this.buildUrl(options);
        const isFme = options.product === "fme";
        const accountId = this.resolveAccountId();
        const headers = {
            ...(isFme ? {} : { "Harness-Account": accountId }),
            ...options.headers,
        };
        // Only inject x-api-key when the caller hasn't already set auth.
        // When service-routing handles auth (bearer-jwt, remote-mcp), it sets
        // Authorization directly — sending x-api-key alongside would cause
        // downstream services to attempt API-key validation on the dummy token.
        if (!headers["Authorization"] && !headers["x-api-key"]) {
            headers["x-api-key"] = this.token;
        }
        if (hasExplicitBody(options.body)) {
            if (typeof options.body === "string") {
                headers["Content-Type"] = headers["Content-Type"] ?? "application/yaml";
            }
            else {
                headers["Content-Type"] = headers["Content-Type"] ?? "application/json";
            }
        }
        let lastError;
        for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
            if (attempt > 0) {
                const backoff = BASE_BACKOFF_MS * Math.pow(2, attempt - 1) * (0.5 + Math.random() * 0.5);
                log.debug(`Retry attempt ${attempt}/${this.maxRetries}`, { backoffMs: Math.round(backoff) });
                await new Promise((r) => setTimeout(r, backoff));
            }
            try {
                // Check if already aborted before starting the request
                if (options.signal?.aborted) {
                    throw options.signal.reason ?? new DOMException("The operation was aborted", "AbortError");
                }
                const timeoutController = new AbortController();
                const effectiveTimeout = options.timeoutMs ?? this.timeout;
                const timer = setTimeout(() => timeoutController.abort(), effectiveTimeout);
                // Merge external signal (client disconnect) with timeout signal
                const signal = options.signal
                    ? AbortSignal.any([options.signal, timeoutController.signal])
                    : timeoutController.signal;
                const bodyString = serializeRequestBody(options.body);
                log.debug(`${method} ${url}`);
                if (bodyString !== undefined) {
                    log.debug("Request body", {
                        body: this.logUnsafeBodies ? bodyString.slice(0, 1000) : redactJsonString(bodyString),
                    });
                }
                const response = await fetch(url, {
                    method,
                    headers,
                    body: bodyString,
                    signal,
                });
                clearTimeout(timer);
                if (!response.ok) {
                    const body = await response.text();
                    let parsed = {};
                    try {
                        parsed = JSON.parse(body);
                    }
                    catch {
                        // Non-JSON error (HTML proxy page, WAF block, etc.)
                        // Provide actionable messages instead of leaking raw HTML to the LLM
                    }
                    const rawMessage = isGarbageMessage(parsed.message)
                        ? humanizeHttpError(response.status, body)
                        : parsed.message;
                    const message = enrichErrorMessage(rawMessage, parsed, options.path);
                    log.debug(`HTTP ${response.status} error`, {
                        body: this.logUnsafeBodies ? body.slice(0, 1000) : redactJsonString(body),
                    });
                    const error = new HarnessApiError(message, response.status, parsed.code, parsed.correlationId);
                    if (RETRYABLE_STATUS_CODES.has(response.status) &&
                        attempt < this.maxRetries &&
                        options.retryPolicy !== "do_not_retry") {
                        lastError = error;
                        continue;
                    }
                    throw error;
                }
                // 204 No Content — valid success response (e.g. PATCH/DELETE on PM API)
                if (response.status === 204) {
                    return { status: "SUCCESS", message: "No content" };
                }
                // Binary response mode — return raw ArrayBuffer (used for ZIP downloads)
                if (options.responseType === "buffer") {
                    const buffer = await response.arrayBuffer();
                    log.debug("Binary response", { bytes: buffer.byteLength });
                    return buffer;
                }
                const text = await response.text();
                if (!text) {
                    throw new HarnessApiError(`Empty response body from ${method} ${options.path}`, 502);
                }
                let data;
                try {
                    data = JSON.parse(text);
                }
                catch (parseErr) {
                    throw new HarnessApiError(`Non-JSON response from ${method} ${options.path}: ${text.slice(0, 200)}`, 502, undefined, undefined, parseErr);
                }
                log.debug("Response body", {
                    body: this.logUnsafeBodies ? text.slice(0, 1000) : redactJsonString(text),
                });
                return data;
            }
            catch (err) {
                if (err instanceof HarnessApiError)
                    throw err;
                if (err instanceof Error && err.name === "AbortError") {
                    // External signal (client disconnect) — stop immediately, don't retry
                    if (options.signal?.aborted) {
                        throw new HarnessApiError("Request cancelled", 499, undefined, undefined, err);
                    }
                    // Timeout — retry if allowed (and policy permits)
                    lastError = new HarnessApiError("Request timed out", 408, undefined, undefined, err);
                    if (attempt < this.maxRetries && options.retryPolicy !== "do_not_retry")
                        continue;
                    throw lastError;
                }
                throw new HarnessApiError(`Request failed: ${err.message ?? String(err)}`, 502, undefined, undefined, err);
            }
        }
        throw lastError ?? new HarnessApiError("Max retries exceeded", 500);
    }
    /**
     * Make a request and return the raw Response (for streaming).
     * Reuses auth, URL building, rate limiting, and retry on non-OK status
     * (before body consumption). Caller is responsible for reading the body.
     */
    async requestStream(options) {
        await this.rateLimiter.acquire();
        const method = options.method ?? "POST";
        const url = this.buildUrl(options);
        const isFme = options.product === "fme";
        const accountId = this.resolveAccountId();
        const headers = {
            ...(isFme ? {} : { "Harness-Account": accountId }),
            ...options.headers,
        };
        // Same auth-header guard as request() — see comment there.
        if (!headers["Authorization"] && !headers["x-api-key"]) {
            headers["x-api-key"] = this.token;
        }
        if (hasExplicitBody(options.body)) {
            if (typeof options.body === "string") {
                headers["Content-Type"] = headers["Content-Type"] ?? "application/yaml";
            }
            else {
                headers["Content-Type"] = headers["Content-Type"] ?? "application/json";
            }
        }
        let lastError;
        for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
            if (attempt > 0) {
                const backoff = BASE_BACKOFF_MS * Math.pow(2, attempt - 1) * (0.5 + Math.random() * 0.5);
                log.debug(`Stream retry attempt ${attempt}/${this.maxRetries}`, { backoffMs: Math.round(backoff) });
                await new Promise((r) => setTimeout(r, backoff));
            }
            try {
                if (options.signal?.aborted) {
                    throw options.signal.reason ?? new DOMException("The operation was aborted", "AbortError");
                }
                const timeoutController = new AbortController();
                const effectiveTimeout = options.timeoutMs ?? this.timeout;
                const timer = setTimeout(() => timeoutController.abort(), effectiveTimeout);
                const signal = options.signal
                    ? AbortSignal.any([options.signal, timeoutController.signal])
                    : timeoutController.signal;
                const bodyString = serializeRequestBody(options.body);
                log.debug(`STREAM ${method} ${url}`);
                const response = await fetch(url, { method, headers, body: bodyString, signal });
                clearTimeout(timer);
                if (!response.ok) {
                    const body = await response.text();
                    let parsed = {};
                    try {
                        parsed = JSON.parse(body);
                    }
                    catch { /* non-JSON */ }
                    const rawMessage = isGarbageMessage(parsed.message)
                        ? humanizeHttpError(response.status, body)
                        : parsed.message;
                    const message = enrichErrorMessage(rawMessage, parsed, options.path);
                    const error = new HarnessApiError(message, response.status, parsed.code, parsed.correlationId);
                    if (RETRYABLE_STATUS_CODES.has(response.status) &&
                        attempt < this.maxRetries &&
                        options.retryPolicy !== "do_not_retry") {
                        lastError = error;
                        continue;
                    }
                    throw error;
                }
                return response;
            }
            catch (err) {
                if (err instanceof HarnessApiError)
                    throw err;
                if (err instanceof Error && err.name === "AbortError") {
                    if (options.signal?.aborted) {
                        throw new HarnessApiError("Request cancelled", 499, undefined, undefined, err);
                    }
                    lastError = new HarnessApiError("Request timed out", 408, undefined, undefined, err);
                    if (attempt < this.maxRetries && options.retryPolicy !== "do_not_retry")
                        continue;
                    throw lastError;
                }
                throw new HarnessApiError(`Request failed: ${err.message ?? String(err)}`, 502, undefined, undefined, err);
            }
        }
        throw lastError ?? new HarnessApiError("Max retries exceeded", 500);
    }
    buildUrl(options) {
        const baseUrl = (options.baseUrl ?? this.baseUrl).replace(/\/$/, "");
        let path = options.path;
        const queryIndex = path.indexOf("?");
        const pathQuery = queryIndex === -1 ? "" : path.slice(queryIndex + 1);
        if (queryIndex !== -1) {
            path = path.slice(0, queryIndex);
        }
        const pathParams = new URLSearchParams(pathQuery);
        const basePath = new URL(baseUrl).pathname.replace(/\/$/, "");
        if (basePath && basePath !== "/" && path.startsWith(`${basePath}/`)) {
            path = path.slice(basePath.length);
        }
        // Inject accountIdentifier into query params (used by most Harness APIs).
        // Some APIs (e.g. SEI) use only the Harness-Account header — skip when told.
        // FME/Split API uses neither Harness account query params nor Harness-Account header.
        const params = new URLSearchParams();
        if (!options.headerBasedScoping && options.product !== "fme") {
            const accountId = this.resolveAccountId();
            params.set("accountIdentifier", accountId);
            params.set("routingId", accountId);
            // Log-service gateway expects accountID (capital ID) in query params
            if (path.includes("/log-service/")) {
                params.set("accountID", accountId);
            }
        }
        for (const key of pathParams.keys()) {
            params.delete(key);
        }
        for (const [key, value] of pathParams) {
            params.append(key, value);
        }
        if (options.params) {
            for (const [key, value] of Object.entries(options.params)) {
                if (value === undefined || value === "")
                    continue;
                if (Array.isArray(value)) {
                    for (const item of value) {
                        if (item !== undefined && item !== "") {
                            params.append(key, String(item));
                        }
                    }
                }
                else {
                    params.set(key, String(value));
                }
            }
        }
        const queryParts = [];
        params.forEach((value, key) => {
            queryParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
        });
        const queryString = queryParts.join('&');
        const url = queryString ? `${baseUrl}${path}?${queryString}` : `${baseUrl}${path}`;
        log.debug(`Built URL: ${url}`);
        return url;
    }
}
//# sourceMappingURL=harness-client.js.map