/**
 * api-client/api-client.ts — Typed HTTP client for the AILF API.
 *
 * Handles all communication with the AILF API: submission, polling,
 * report fetching, and task validation. Uses Node's built-in `fetch`
 * (Node 22+) — no external HTTP dependencies.
 *
 * The client is intentionally thin: it builds HTTP requests, parses
 * responses into typed objects, and translates HTTP errors into typed
 * error classes. It does NOT contain business logic.
 *
 * @see packages/api/src/routes/ — server-side route handlers
 * @see docs/design-docs/cli-as-api-client.md — design doc
 */
import { JOB_EXECUTION_DEADLINE_MS, } from "../../_vendor/ailf-core/index.js";
import { ApiAuthError, ApiConnectionError, ApiError, ApiTimeoutError, } from "./errors.js";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_BASE_URL = "https://ailf-api.sanity.build";
const MAX_LONG_POLL_SECONDS = 25;
// Wait the full server-side job deadline so the client never abandons a job
// the backend is still allowed to run (the 20-minute default used to trip
// `ApiTimeoutError` on healthy long jobs that completed server-side).
const DEFAULT_TIMEOUT_MS = JOB_EXECUTION_DEADLINE_MS;
const MAX_RETRY_ATTEMPTS = 3;
const RETRY_BASE_DELAY_MS = 2000;
// ---------------------------------------------------------------------------
// ApiClient
// ---------------------------------------------------------------------------
export class ApiClient {
    apiKey;
    baseUrl;
    constructor(options) {
        this.apiKey = options.apiKey;
        this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
    }
    // ─── Submission ─────────────────────────────────────────────────
    /**
     * POST /v1/pipeline — submit an evaluation and return the job info.
     *
     * Returns HTTP 202 on success with a `SubmitResponse` containing the
     * `jobId` and `statusUrl` for polling.
     */
    async submitPipeline(request) {
        return this.post("/v1/pipeline", request);
    }
    // ─── Job status ─────────────────────────────────────────────────
    /**
     * GET /v1/jobs/:id — fetch job status.
     *
     * Supports long-polling via the `Prefer: wait=N` header. When
     * `longPollSeconds` is set, the server holds the connection open
     * for up to N seconds (max 25) waiting for a status change.
     */
    async getJob(jobId, options) {
        const headers = {};
        if (options?.longPollSeconds) {
            const wait = Math.min(options.longPollSeconds, MAX_LONG_POLL_SECONDS);
            headers["Prefer"] = `wait=${wait}`;
        }
        return this.get(`/v1/jobs/${jobId}`, headers);
    }
    /**
     * Poll until the job reaches a terminal state (completed, failed,
     * timed-out) or the timeout is exceeded.
     *
     * Uses long-polling for efficiency — the server holds the connection
     * instead of the client sleeping between requests.
     */
    async waitForCompletion(jobId, options) {
        const { timeoutMs = DEFAULT_TIMEOUT_MS, onProgress, longPollSeconds = MAX_LONG_POLL_SECONDS, } = options ?? {};
        const deadline = Date.now() + timeoutMs;
        const terminalStatuses = new Set(["completed", "failed", "timed-out"]);
        while (Date.now() < deadline) {
            let job;
            try {
                job = await this.getJob(jobId, { longPollSeconds });
            }
            catch (err) {
                // Retry on transient errors during polling
                if (err instanceof ApiConnectionError && Date.now() < deadline) {
                    await sleep(RETRY_BASE_DELAY_MS);
                    continue;
                }
                throw err;
            }
            onProgress?.(job);
            if (terminalStatuses.has(job.status)) {
                return job;
            }
            // Long-polling already handles the delay; only add a small
            // backoff if the server responded instantly (non-terminal).
        }
        throw new ApiTimeoutError(jobId, timeoutMs);
    }
    // ─── Reports ────────────────────────────────────────────────────
    /**
     * GET /v1/reports/:id/markdown — fetch the rendered markdown report.
     *
     * Returns the raw markdown string. Throws on 404 or error.
     */
    async getReportMarkdown(reportId) {
        const url = `${this.baseUrl}/v1/reports/${reportId}/markdown`;
        const response = await this.fetchWithRetry(url, {
            method: "GET",
            headers: this.headers({ Accept: "text/markdown" }),
        });
        if (!response.ok) {
            await this.handleErrorResponse(response, url);
        }
        return response.text();
    }
    // ─── Validation ─────────────────────────────────────────────────
    /**
     * POST /v1/validate/task — validate task definitions against the API.
     *
     * Returns a structured validation result. Does not throw on invalid
     * tasks — the caller checks `response.valid`.
     */
    async validateTasks(tasks) {
        return this.post("/v1/validate/task", { tasks });
    }
    // ─── Internal HTTP methods ──────────────────────────────────────
    async get(path, extraHeaders) {
        const url = `${this.baseUrl}${path}`;
        const response = await this.fetchWithRetry(url, {
            method: "GET",
            headers: this.headers(extraHeaders),
        });
        if (!response.ok) {
            await this.handleErrorResponse(response, url);
        }
        return response.json();
    }
    async post(path, body) {
        const url = `${this.baseUrl}${path}`;
        const response = await this.fetchWithRetry(url, {
            method: "POST",
            headers: this.headers({ "Content-Type": "application/json" }),
            body: JSON.stringify(body),
        });
        // 202 is success for async operations
        if (!response.ok && response.status !== 202) {
            await this.handleErrorResponse(response, url);
        }
        return response.json();
    }
    /**
     * Build standard headers. Auth is always included.
     */
    headers(extra) {
        return {
            Authorization: `Bearer ${this.apiKey}`,
            ...extra,
        };
    }
    /**
     * Fetch with retry on transient network errors.
     * Retries up to MAX_RETRY_ATTEMPTS with exponential backoff.
     * Only retries on network errors (TypeError from fetch), not HTTP errors.
     */
    async fetchWithRetry(url, init) {
        let lastError;
        for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
            try {
                return await fetch(url, init);
            }
            catch (err) {
                lastError = err;
                // Only retry on network-level errors (not HTTP status errors)
                if (attempt < MAX_RETRY_ATTEMPTS - 1) {
                    const delay = RETRY_BASE_DELAY_MS * 2 ** attempt;
                    await sleep(delay);
                }
            }
        }
        throw new ApiConnectionError(`Could not reach the AILF API at ${url}`, url, lastError);
    }
    /**
     * Parse a non-OK HTTP response and throw the appropriate typed error.
     */
    async handleErrorResponse(response, url) {
        let body;
        try {
            body = (await response.json());
        }
        catch {
            // Non-JSON error response (e.g., HTML from CDN)
        }
        const code = body?.error?.code ?? `http_${response.status}`;
        const message = body?.error?.message ??
            `HTTP ${response.status} ${response.statusText} (${url})`;
        if (response.status === 401 || response.status === 403) {
            throw new ApiAuthError(message, code, response.status);
        }
        throw new ApiError(message, code, response.status, body?.error?.param);
    }
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
