// Zero-dependency OpenAI-compatible HTTP adapter built on globalThis.fetch and
// AbortSignal. fetch, clock, request-id, and cost class are injected so tests run
// with no network I/O and no real time. The raw provider body is never echoed into
// an error; only a redacted, status-level summary is surfaced.
import { AuthenticationError, CancelledError, ContextOverflowError, ModelRefusalError, ProviderError, RateLimitError, TimeoutError, TransportError, } from "@oscharko-dev/keiko-security/errors/gateway";
import { apiKeyHeaderValue, DEFAULT_API_KEY_HEADER_NAME } from "./config.js";
import { gatewayFetch, readJsonCapped, readSseStream } from "./http.js";
import { normalizeChatResponse } from "./normalize.js";
import { redact } from "@oscharko-dev/keiko-security";
function buildMessage(message) {
    const toolCalls = message.toolCalls?.map((call) => ({
        id: call.id,
        type: "function",
        function: { name: call.name, arguments: JSON.stringify(call.arguments) },
    }));
    return {
        role: message.role,
        content: message.role === "assistant" && toolCalls !== undefined && toolCalls.length > 0
            ? null
            : message.content,
        ...(message.role === "tool" && message.toolCallId !== undefined
            ? { tool_call_id: message.toolCallId }
            : {}),
        ...(toolCalls !== undefined && toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
    };
}
function buildBody(request) {
    const messages = request.messages.map(buildMessage);
    const base = { model: request.modelId, messages };
    const tools = request.tools === undefined
        ? undefined
        : request.tools.map((t) => ({
            type: "function",
            function: { name: t.name, description: t.description, parameters: t.parameters },
        }));
    const responseFormat = request.responseFormat?.type === "json_schema"
        ? { type: "json_schema", json_schema: { schema: request.responseFormat.schema } }
        : undefined;
    return {
        ...base,
        ...(tools ? { tools } : {}),
        ...(responseFormat ? { response_format: responseFormat } : {}),
        ...(request.seed !== undefined ? { seed: request.seed } : {}),
    };
}
// Streaming body: identical to buildBody plus the OpenAI/Azure streaming flags.
// `include_usage` requests a final usage-only chunk so token accounting survives.
function buildStreamBody(request) {
    return {
        ...buildBody(request),
        stream: true,
        stream_options: { include_usage: true },
    };
}
const FINISH_REASONS = new Set([
    "stop",
    "tool_calls",
    "length",
    "content_filter",
    "error",
    "cancelled",
]);
function firstStreamChoice(chunk) {
    if (!isRecord(chunk) || !Array.isArray(chunk.choices)) {
        return undefined;
    }
    const choices = chunk.choices;
    const choice = choices[0];
    return isRecord(choice) ? choice : undefined;
}
// Extracts the assistant content delta from a streaming chunk, when present.
function deltaFromChunk(chunk) {
    const choice = firstStreamChoice(chunk);
    const delta = choice !== undefined && isRecord(choice.delta) ? choice.delta : undefined;
    return delta !== undefined && typeof delta.content === "string" ? delta.content : undefined;
}
function finishReasonFromChunk(chunk) {
    const choice = firstStreamChoice(chunk);
    const raw = choice?.finish_reason;
    return typeof raw === "string" && FINISH_REASONS.has(raw)
        ? raw
        : undefined;
}
function nonNegativeCount(value) {
    return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
}
// Extracts prompt/completion token counts from the include_usage final chunk.
function usageFromChunk(chunk) {
    if (!isRecord(chunk) || !isRecord(chunk.usage)) {
        return undefined;
    }
    return {
        prompt: nonNegativeCount(chunk.usage.prompt_tokens),
        completion: nonNegativeCount(chunk.usage.completion_tokens),
    };
}
function retryAfterMs(response) {
    const header = response.headers.get("retry-after");
    if (header === null) {
        return null;
    }
    const seconds = Number(header);
    return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null;
}
function isRecord(value) {
    return typeof value === "object" && value !== null && !Array.isArray(value);
}
function redactUnknown(value, secrets) {
    if (typeof value === "string") {
        return redact(value, secrets);
    }
    if (Array.isArray(value)) {
        return value.map((item) => redactUnknown(item, secrets));
    }
    if (isRecord(value)) {
        return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactUnknown(item, secrets)]));
    }
    return value;
}
function redactRecord(value, secrets) {
    return value === null ? null : redactUnknown(value, secrets);
}
function redactToolCall(call, secrets) {
    return {
        ...call,
        name: redact(call.name, secrets),
        arguments: redactUnknown(call.arguments, secrets),
    };
}
function redactResponse(response, secrets) {
    return {
        ...response,
        content: redact(response.content, secrets),
        toolCalls: response.toolCalls.map((call) => redactToolCall(call, secrets)),
        structuredOutput: redactRecord(response.structuredOutput, secrets),
    };
}
function errorSignal(payload) {
    const error = isRecord(payload) && isRecord(payload.error) ? payload.error : payload;
    if (!isRecord(error)) {
        return "";
    }
    return [error.code, error.type, error.message]
        .filter((value) => typeof value === "string")
        .join(" ")
        .toLowerCase();
}
function isContextOverflow(response, payload) {
    if (response.status !== 400 && response.status !== 413 && response.status !== 422) {
        return false;
    }
    return /context[_ -]?length[_ -]?exceeded|context window|context.*exceed|maximum context|too many tokens|prompt too long|context overflow/.test(errorSignal(payload));
}
function isModelRefusal(payload) {
    return /content[_ -]?filter|refus|safety|policy/.test(errorSignal(payload));
}
function mapHttpError(response, modelId, secrets, payload) {
    if (isContextOverflow(response, payload)) {
        throw new ContextOverflowError(`provider reported context overflow for '${modelId}'`, secrets);
    }
    if (isModelRefusal(payload)) {
        throw new ModelRefusalError(`provider refused the request for '${modelId}'`, secrets);
    }
    if (response.status === 401 || response.status === 403) {
        throw new AuthenticationError(`provider rejected credentials for '${modelId}'`, secrets);
    }
    if (response.status === 429) {
        throw new RateLimitError(`provider rate limited '${modelId}'`, retryAfterMs(response), secrets);
    }
    throw new ProviderError(`provider returned HTTP ${String(response.status)} for '${modelId}'`, response.status, secrets);
}
function apiKeyHeaders(config) {
    const headerName = config.apiKeyHeaderName ?? DEFAULT_API_KEY_HEADER_NAME;
    return { [headerName]: apiKeyHeaderValue(headerName, config.apiKey) };
}
export class OpenAiAdapter {
    deps;
    now;
    constructor(deps) {
        this.deps = deps;
        this.now = deps.now ?? Date.now;
    }
    call = async (request, config) => {
        const secrets = [config.apiKey, config.baseUrl];
        if (request.cancellationSignal?.aborted === true) {
            throw new CancelledError(`request for '${config.modelId}' cancelled before dispatch`, secrets);
        }
        const start = this.now();
        const response = await this.dispatch(request, config, secrets);
        if (!response.ok) {
            const errorPayload = await this.readErrorBody(response);
            mapHttpError(response, config.modelId, secrets, errorPayload);
        }
        const payload = await this.readBody(response, config, secrets);
        return redactResponse(normalizeChatResponse(payload, config.modelId, {
            requestId: this.deps.requestId,
            latencyMs: this.now() - start,
            costClass: this.deps.costClass,
        }, request.responseFormat?.type === "json_schema"), secrets);
    };
    // Streaming chat path (Layer 1): yields redacted content-delta tokens as they
    // arrive, then a terminal `done` with the assembled, redacted NormalizedResponse.
    // Tool-call streaming is out of scope — only `choices[0].delta.content` is surfaced.
    callStream = async function* (request, config) {
        const secrets = [config.apiKey, config.baseUrl];
        if (request.cancellationSignal?.aborted === true) {
            throw new CancelledError(`request for '${config.modelId}' cancelled before dispatch`, secrets);
        }
        const start = this.now();
        const response = await this.dispatch(request, config, secrets, true);
        if (!response.ok) {
            const errorPayload = await this.readErrorBody(response);
            mapHttpError(response, config.modelId, secrets, errorPayload);
        }
        const acc = { content: "", finishReason: "stop", prompt: 0, completion: 0 };
        for await (const token of this.streamDeltas(response, config, secrets, acc)) {
            yield { type: "delta", token };
        }
        const assembled = this.assembleResponse(config, start, acc);
        yield { type: "done", response: redactResponse(assembled, secrets) };
    };
    // Iterates the SSE stream, yielding each redacted content token while mutating
    // `acc` with the raw accumulated content, finish reason, and final usage counts.
    async *streamDeltas(response, config, secrets, acc) {
        try {
            for await (const chunk of readSseStream(response)) {
                const content = deltaFromChunk(chunk);
                if (content !== undefined) {
                    acc.content += content;
                    yield redact(content, secrets);
                }
                const finish = finishReasonFromChunk(chunk);
                if (finish !== undefined)
                    acc.finishReason = finish;
                const usage = usageFromChunk(chunk);
                if (usage !== undefined) {
                    acc.prompt = usage.prompt;
                    acc.completion = usage.completion;
                }
            }
        }
        catch (error) {
            throw this.mapStreamError(error, config, secrets);
        }
    }
    assembleResponse(config, start, acc) {
        const usage = {
            requestId: this.deps.requestId,
            promptTokens: acc.prompt,
            completionTokens: acc.completion,
            latencyMs: this.now() - start,
            costClass: this.deps.costClass,
        };
        return {
            modelId: config.modelId,
            content: acc.content,
            finishReason: acc.finishReason,
            toolCalls: [],
            structuredOutput: null,
            usage,
        };
    }
    // A mid-stream read failure surfaces as a TransportError; an already-typed
    // cancellation/timeout (e.g. raised by the underlying reader) passes through.
    mapStreamError(error, config, secrets) {
        if (error instanceof CancelledError || error instanceof TimeoutError) {
            return error;
        }
        return new TransportError(`stream read failed for '${config.modelId}'`, secrets);
    }
    async dispatch(request, config, secrets, stream = false) {
        const timeoutSignal = AbortSignal.timeout(config.timeoutMs);
        const cancel = request.cancellationSignal;
        const signal = cancel ? AbortSignal.any([timeoutSignal, cancel]) : timeoutSignal;
        const url = `${config.baseUrl}/chat/completions`;
        const body = JSON.stringify(stream ? buildStreamBody(request) : buildBody(request));
        const headers = {
            "content-type": "application/json",
            ...apiKeyHeaders(config),
        };
        try {
            return await gatewayFetch(url, {
                method: "POST",
                headers,
                body,
                signal,
                fetchImpl: this.deps.fetchImpl,
                ...(config.egress !== undefined ? { egress: config.egress } : {}),
            });
        }
        catch (error) {
            throw this.mapDispatchError(error, config, cancel, timeoutSignal, secrets);
        }
    }
    mapDispatchError(error, config, cancel, timeout, secrets) {
        if (cancel?.aborted === true) {
            return new CancelledError(`request for '${config.modelId}' cancelled`, secrets);
        }
        if (timeout.aborted) {
            return new TimeoutError(`request for '${config.modelId}' timed out`, secrets);
        }
        if (error instanceof DOMException && error.name === "TimeoutError") {
            return new TimeoutError(`request for '${config.modelId}' timed out`, secrets);
        }
        return new TransportError(`transport failure contacting '${config.modelId}'`, secrets);
    }
    async readBody(response, config, secrets) {
        try {
            return await readJsonCapped(response);
        }
        catch {
            throw new TransportError(`provider sent an unreadable body for '${config.modelId}'`, secrets);
        }
    }
    async readErrorBody(response) {
        try {
            return await readJsonCapped(response);
        }
        catch {
            return null;
        }
    }
}
