// Orchestrator: routes a request through the capability registry, then through the
// circuit breaker, bounded retry, and the provider adapter. Usage metadata
// (request id, latency, cost class) is owned by the gateway, not the provider, so
// the audit ledger (issue #10) has a reliable typed target on every response.
import { randomUUID } from "node:crypto";
import { UnknownModelError } from "@oscharko-dev/keiko-security/errors/gateway";
import { findConfiguredCapability } from "./model-selection.js";
import { OpenAiAdapter } from "./openai-adapter.js";
import { CircuitBreaker, executeWithRetry, systemClock } from "./resilience.js";
export class Gateway {
    config;
    clock;
    adapter;
    providers;
    breakers = new Map();
    constructor(config, deps = {}) {
        this.config = config;
        this.clock = deps.clock ?? systemClock;
        this.adapter = deps.adapter;
        this.providers = new Map(config.providers.map((p) => [p.modelId, p]));
    }
    async chat(request) {
        const route = this.route(request.modelId);
        const breaker = this.breakerFor(route.provider);
        const requestId = randomUUID();
        const start = this.clock.now();
        const adapter = this.adapterFor(requestId, route.capability);
        const result = await executeWithRetry((attemptTimeoutMs) => this.invoke(breaker, adapter, request, {
            ...route.provider,
            ...(attemptTimeoutMs === undefined ? {} : { timeoutMs: attemptTimeoutMs }),
        }), route.provider, this.clock, request.cancellationSignal);
        return {
            ...result,
            usage: {
                ...result.usage,
                requestId,
                latencyMs: Math.max(1, this.clock.now() - start),
                costClass: route.capability.costClass,
            },
        };
    }
    // Streaming counterpart of chat(). Routes identically and guards with the circuit
    // breaker, but is NOT wrapped in executeWithRetry: a mid-stream retry would replay
    // already-emitted tokens. An adapter without a streaming variant falls back to a
    // single delta+done synthesised from its buffered call().
    async *chatStream(request) {
        const route = this.route(request.modelId);
        const breaker = this.breakerFor(route.provider);
        breaker.assertAllowed();
        const requestId = randomUUID();
        const start = this.clock.now();
        const adapter = this.adapterFor(requestId, route.capability);
        try {
            for await (const chunk of this.streamFrom(adapter, request, route.provider)) {
                yield chunk.type === "done"
                    ? { type: "done", response: this.enrich(chunk.response, requestId, start, route) }
                    : chunk;
            }
            breaker.recordSuccess();
        }
        catch (error) {
            breaker.recordFailure();
            throw error;
        }
    }
    async *streamFrom(adapter, request, provider) {
        if (adapter.callStream !== undefined) {
            yield* adapter.callStream(request, provider);
            return;
        }
        const response = await adapter.call(request, provider);
        yield { type: "delta", token: response.content };
        yield { type: "done", response };
    }
    enrich(response, requestId, start, route) {
        return {
            ...response,
            usage: {
                ...response.usage,
                requestId,
                latencyMs: Math.max(1, this.clock.now() - start),
                costClass: route.capability.costClass,
            },
        };
    }
    circuitStatus(modelId) {
        const breaker = this.breakers.get(modelId);
        return (breaker?.status(modelId) ?? {
            modelId,
            state: "closed",
            consecutiveFailures: 0,
            openedAt: null,
        });
    }
    async invoke(breaker, adapter, request, provider) {
        breaker.assertAllowed();
        try {
            const response = await adapter.call(request, provider);
            breaker.recordSuccess();
            return response;
        }
        catch (error) {
            breaker.recordFailure();
            throw error;
        }
    }
    route(modelId) {
        const provider = this.providers.get(modelId);
        if (provider === undefined) {
            throw new UnknownModelError(`no provider configured for model '${modelId}'`);
        }
        const capability = findConfiguredCapability(this.config, modelId);
        if (capability === undefined) {
            throw new UnknownModelError(`model '${modelId}' has no capability metadata`);
        }
        if (capability.kind !== "chat") {
            throw new UnknownModelError(`model '${modelId}' has kind '${capability.kind}'; the chat path requires a chat model`);
        }
        return { provider, capability };
    }
    breakerFor(provider) {
        const existing = this.breakers.get(provider.modelId);
        if (existing !== undefined) {
            return existing;
        }
        const breaker = new CircuitBreaker(provider.modelId, this.config.circuitBreaker, this.clock);
        this.breakers.set(provider.modelId, breaker);
        return breaker;
    }
    adapterFor(requestId, capability) {
        return (this.adapter ??
            new OpenAiAdapter({
                requestId,
                costClass: capability.costClass,
                now: this.clock.now,
            }));
    }
}
