/**
 * OpenCode SDK integration for agent interactions
 *
 * Uses @opencode-ai/sdk/v2 for native TypeScript integration.
 * Follows the same patterns as the Codex client.
 */
import { createOpencode } from '@opencode-ai/sdk/v2';
import { createServer } from 'node:net';
import { AskUserQuestionDeniedError } from '../../core/workflow/ask-user-question-error.js';
import { createLogger, getErrorMessage, createStreamDiagnostics, parseStructuredOutput } from '../../shared/utils/index.js';
import { parseProviderModel } from '../../shared/utils/providerModel.js';
import { buildOpenCodePermissionRuleset, mapToOpenCodePermissionReply, mapToOpenCodeTools, } from './types.js';
import { createStreamTrackingState, emitInit, emitText, emitResult, handlePartUpdated, } from './OpenCodeStreamHandler.js';
import { buildRateLimitedResponseFields, containsRateLimitError } from '../rate-limit/detection.js';
const log = createLogger('opencode-sdk');
const OPENCODE_STREAM_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
const OPENCODE_STREAM_ABORTED_MESSAGE = 'OpenCode execution aborted';
const OPENCODE_RETRY_MAX_ATTEMPTS = 3;
const OPENCODE_RETRY_BASE_DELAY_MS = 250;
const OPENCODE_INTERACTION_TIMEOUT_MS = 5000;
const OPENCODE_SERVER_START_TIMEOUT_MS = 60000;
const OPENCODE_RETRYABLE_ERROR_PATTERNS = [
    'stream disconnected before completion',
    'transport error',
    'network error',
    'error decoding response body',
    'econnreset',
    'etimedout',
    'eai_again',
    'fetch failed',
    'failed to start server on port',
    'timeout waiting for server',
];
let sharedServer = null;
let initPromise = null;
async function acquireClient(model, apiKey) {
    if (initPromise) {
        await initPromise;
    }
    if (sharedServer?.model === model && sharedServer.apiKey === apiKey) {
        if (sharedServer.queue.length === 0) {
            return { client: sharedServer.client, release: () => releaseClient() };
        }
        return new Promise((resolve) => {
            sharedServer.queue.push((client) => resolve({ client, release: () => releaseClient() }));
        });
    }
    sharedServer?.close();
    let resolveInit;
    initPromise = new Promise((resolve) => { resolveInit = resolve; });
    try {
        const port = await getFreePort();
        const { client, server } = await createOpencode({
            port,
            config: {
                model,
                small_model: model,
                ...(apiKey ? { provider: { opencode: { options: { apiKey } } } } : {}),
            },
            timeout: OPENCODE_SERVER_START_TIMEOUT_MS,
        });
        const closeServer = () => {
            try {
                server.close();
            }
            catch {
                // Ignore close errors
            }
        };
        sharedServer = { client, close: closeServer, model, apiKey, queue: [] };
        log.debug('OpenCode server started', { model, port });
        return { client, release: () => releaseClient() };
    }
    finally {
        initPromise = null;
        resolveInit();
    }
}
function releaseClient() {
    if (!sharedServer)
        return;
    const next = sharedServer.queue.shift();
    next?.(sharedServer.client);
}
export function resetSharedServer() {
    sharedServer?.close();
    sharedServer = null;
}
async function withTimeout(operation, timeoutMs, timeoutErrorMessage) {
    const controller = new AbortController();
    let timeoutId;
    const timeoutPromise = new Promise((_, reject) => {
        timeoutId = setTimeout(() => {
            controller.abort();
            reject(new Error(timeoutErrorMessage));
        }, timeoutMs);
    });
    try {
        return await Promise.race([
            operation(controller.signal),
            timeoutPromise,
        ]);
    }
    finally {
        if (timeoutId !== undefined) {
            clearTimeout(timeoutId);
        }
    }
}
function extractOpenCodeErrorMessage(error) {
    if (!error || typeof error !== 'object') {
        return undefined;
    }
    const value = error;
    if (typeof value.message === 'string' && value.message.length > 0) {
        return value.message;
    }
    if (typeof value.data?.message === 'string' && value.data.message.length > 0) {
        return value.data.message;
    }
    if (typeof value.name === 'string' && value.name.length > 0) {
        return value.name;
    }
    return undefined;
}
function getCommonPrefixLength(a, b) {
    const max = Math.min(a.length, b.length);
    let i = 0;
    while (i < max && a[i] === b[i]) {
        i += 1;
    }
    return i;
}
function stripPromptEcho(chunk, echoState) {
    if (!chunk)
        return '';
    if (!echoState.remainingPrompt)
        return chunk;
    const consumeLength = getCommonPrefixLength(chunk, echoState.remainingPrompt);
    if (consumeLength > 0) {
        echoState.remainingPrompt = echoState.remainingPrompt.slice(consumeLength);
        return chunk.slice(consumeLength);
    }
    return chunk;
}
function toQuestionInput(props) {
    return {
        questions: props.questions.map((item) => ({
            question: item.question,
            header: item.header,
            options: item.options.map((opt) => ({
                label: opt.label,
                description: opt.description,
            })),
            multiSelect: item.multiple,
        })),
    };
}
function toQuestionAnswers(props, answers) {
    return props.questions.map((item) => {
        const key = item.header || item.question;
        const value = answers[key];
        if (!value)
            return [];
        return [value];
    });
}
async function getFreePort() {
    return new Promise((resolve, reject) => {
        const server = createServer();
        server.unref();
        server.on('error', reject);
        server.listen(0, '127.0.0.1', () => {
            const addr = server.address();
            if (!addr || typeof addr === 'string') {
                server.close(() => reject(new Error('Failed to allocate free TCP port')));
                return;
            }
            const port = addr.port;
            server.close((err) => {
                if (err) {
                    reject(err);
                    return;
                }
                resolve(port);
            });
        });
    });
}
/**
 * Client for OpenCode SDK agent interactions.
 *
 * Handles session management, streaming event conversion,
 * permission auto-reply, and response processing.
 */
export class OpenCodeClient {
    isRetriableError(message, aborted, abortCause) {
        if (abortCause === 'timeout') {
            return true;
        }
        if (aborted || abortCause) {
            return false;
        }
        const lower = message.toLowerCase();
        return OPENCODE_RETRYABLE_ERROR_PATTERNS.some((pattern) => lower.includes(pattern));
    }
    async waitForRetryDelay(attempt, signal) {
        const delayMs = OPENCODE_RETRY_BASE_DELAY_MS * (2 ** Math.max(0, attempt - 1));
        await new Promise((resolve, reject) => {
            const timeoutId = setTimeout(() => {
                if (signal) {
                    signal.removeEventListener('abort', onAbort);
                }
                resolve();
            }, delayMs);
            const onAbort = () => {
                clearTimeout(timeoutId);
                if (signal) {
                    signal.removeEventListener('abort', onAbort);
                }
                reject(new Error(OPENCODE_STREAM_ABORTED_MESSAGE));
            };
            if (signal) {
                if (signal.aborted) {
                    onAbort();
                    return;
                }
                signal.addEventListener('abort', onAbort, { once: true });
            }
        });
    }
    /** Build a prompt suffix that instructs the agent to return JSON matching the schema */
    buildStructuredOutputSuffix(schema) {
        return [
            '',
            '---',
            'IMPORTANT: You MUST respond with ONLY a valid JSON object matching this schema. No other text, no markdown code blocks, no explanation.',
            '```',
            JSON.stringify(schema, null, 2),
            '```',
        ].join('\n');
    }
    buildRateLimitedResponse(agentType, sessionId, message) {
        return {
            persona: agentType,
            timestamp: new Date(),
            sessionId,
            ...buildRateLimitedResponseFields('opencode', 'sdk_error', message),
        };
    }
    /** Call OpenCode with an agent prompt */
    async call(agentType, prompt, options) {
        const basePrompt = options.systemPrompt
            ? `${options.systemPrompt}\n\n${prompt}`
            : prompt;
        // OpenCode SDK does not natively support structured output via outputFormat.
        // Inject JSON output instructions into the prompt to make the agent return JSON.
        const fullPrompt = options.outputSchema
            ? `${basePrompt}${this.buildStructuredOutputSuffix(options.outputSchema)}`
            : basePrompt;
        for (let attempt = 1; attempt <= OPENCODE_RETRY_MAX_ATTEMPTS; attempt++) {
            let idleTimeoutId;
            const streamAbortController = new AbortController();
            const timeoutMessage = `OpenCode stream timed out after ${Math.floor(OPENCODE_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`;
            let abortCause;
            let diagRef;
            let release;
            let opencodeApiClient;
            let sessionId = options.sessionId;
            const interactionTimeoutMs = options.interactionTimeoutMs ?? OPENCODE_INTERACTION_TIMEOUT_MS;
            const resetIdleTimeout = () => {
                if (idleTimeoutId !== undefined) {
                    clearTimeout(idleTimeoutId);
                }
                idleTimeoutId = setTimeout(() => {
                    diagRef?.onIdleTimeoutFired();
                    abortCause = 'timeout';
                    streamAbortController.abort();
                }, OPENCODE_STREAM_IDLE_TIMEOUT_MS);
            };
            const onExternalAbort = () => {
                abortCause = 'external';
                streamAbortController.abort();
            };
            if (options.abortSignal) {
                if (options.abortSignal.aborted) {
                    streamAbortController.abort();
                }
                else {
                    options.abortSignal.addEventListener('abort', onExternalAbort, { once: true });
                }
            }
            try {
                log.debug('Starting OpenCode session', {
                    agentType,
                    model: options.model,
                    hasSystemPrompt: !!options.systemPrompt,
                    attempt,
                });
                const diag = createStreamDiagnostics('opencode-sdk', { agentType, model: options.model, attempt });
                diagRef = diag;
                const parsedModel = parseProviderModel(options.model, 'OpenCode model');
                const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`;
                const acquired = await acquireClient(fullModel, options.opencodeApiKey);
                opencodeApiClient = acquired.client;
                release = acquired.release;
                const sessionResult = sessionId
                    ? { data: { id: sessionId } }
                    : await opencodeApiClient.session.create({
                        directory: options.cwd,
                        permission: buildOpenCodePermissionRuleset(options.permissionMode, options.networkAccess),
                    });
                sessionId = sessionResult.data?.id;
                if (!sessionId) {
                    release();
                    throw new Error('Failed to create OpenCode session');
                }
                const { stream } = await opencodeApiClient.event.subscribe({ directory: options.cwd }, { signal: streamAbortController.signal });
                resetIdleTimeout();
                diag.onConnected();
                const tools = mapToOpenCodeTools(options.allowedTools);
                const promptPayload = {
                    sessionID: sessionId,
                    directory: options.cwd,
                    model: parsedModel,
                    ...(options.variant !== undefined ? { variant: options.variant } : {}),
                    ...(tools ? { tools } : {}),
                    parts: [{ type: 'text', text: fullPrompt }],
                };
                if (options.outputSchema) {
                    promptPayload.outputFormat = {
                        type: 'json_schema',
                        schema: options.outputSchema,
                    };
                }
                const promptPayloadForSdk = promptPayload;
                await opencodeApiClient.session.promptAsync(promptPayloadForSdk, {
                    signal: streamAbortController.signal,
                });
                emitInit(options.onStream, options.model, sessionId);
                let content = '';
                let success = true;
                let failureMessage = '';
                const state = createStreamTrackingState();
                const echoState = { remainingPrompt: fullPrompt };
                const textOffsets = new Map();
                const textContentParts = new Map();
                // Consume a raw text delta for a part: strip the prompt echo, stream the
                // visible portion, accumulate it, and advance the part's raw offset in
                // lockstep. Sharing this keeps content and offset consistent whether the
                // text arrives via `message.part.delta` or a full-snapshot
                // `message.part.updated` — some providers emit both for the same part.
                const consumeTextDelta = (partId, rawDelta) => {
                    if (!rawDelta)
                        return;
                    const visibleDelta = stripPromptEcho(rawDelta, echoState);
                    if (visibleDelta) {
                        emitText(options.onStream, visibleDelta);
                        const previous = textContentParts.get(partId) ?? '';
                        textContentParts.set(partId, `${previous}${visibleDelta}`);
                    }
                    const prevOffset = textOffsets.get(partId) ?? 0;
                    textOffsets.set(partId, prevOffset + rawDelta.length);
                };
                for await (const event of stream) {
                    if (streamAbortController.signal.aborted)
                        break;
                    resetIdleTimeout();
                    const sseEvent = event;
                    diag.onFirstEvent(sseEvent.type);
                    diag.onEvent(sseEvent.type);
                    if (sseEvent.type === 'message.part.updated') {
                        const props = sseEvent.properties;
                        const part = props.part;
                        const delta = props.delta;
                        if (part.type === 'text') {
                            const textPart = part;
                            const prev = textOffsets.get(textPart.id) ?? 0;
                            const rawDelta = delta
                                ?? (textPart.text.length > prev ? textPart.text.slice(prev) : '');
                            consumeTextDelta(textPart.id, rawDelta);
                            continue;
                        }
                        handlePartUpdated(part, delta, options.onStream, state);
                        continue;
                    }
                    if (sseEvent.type === 'message.part.delta') {
                        const deltaProps = sseEvent.properties;
                        if (deltaProps.field === 'text' && deltaProps.delta) {
                            consumeTextDelta(deltaProps.partID, deltaProps.delta);
                        }
                        continue;
                    }
                    if (sseEvent.type === 'permission.asked') {
                        const permProps = sseEvent.properties;
                        if (permProps.sessionID === sessionId) {
                            try {
                                const reply = options.permissionMode
                                    ? mapToOpenCodePermissionReply(options.permissionMode)
                                    : 'once';
                                await withTimeout((signal) => opencodeApiClient.permission.reply({
                                    requestID: permProps.id,
                                    directory: options.cwd,
                                    reply,
                                }, { signal }), interactionTimeoutMs, 'OpenCode permission reply timed out');
                            }
                            catch (e) {
                                success = false;
                                failureMessage = getErrorMessage(e);
                                break;
                            }
                        }
                        continue;
                    }
                    if (sseEvent.type === 'question.asked') {
                        const questionProps = sseEvent.properties;
                        if (questionProps.sessionID === sessionId) {
                            const rejectQuestion = () => withTimeout((signal) => opencodeApiClient.question.reject({
                                requestID: questionProps.id,
                                directory: options.cwd,
                            }, { signal }), interactionTimeoutMs, 'OpenCode question reject timed out');
                            if (!options.onAskUserQuestion) {
                                try {
                                    await rejectQuestion();
                                }
                                catch (e) {
                                    success = false;
                                    failureMessage = getErrorMessage(e);
                                    break;
                                }
                                continue;
                            }
                            try {
                                const answers = await options.onAskUserQuestion(toQuestionInput(questionProps));
                                await withTimeout((signal) => opencodeApiClient.question.reply({
                                    requestID: questionProps.id,
                                    directory: options.cwd,
                                    answers: toQuestionAnswers(questionProps, answers),
                                }, { signal }), interactionTimeoutMs, 'OpenCode question reply timed out');
                            }
                            catch (e) {
                                if (e instanceof AskUserQuestionDeniedError) {
                                    try {
                                        await rejectQuestion();
                                    }
                                    catch (rejectErr) {
                                        success = false;
                                        failureMessage = getErrorMessage(rejectErr);
                                        break;
                                    }
                                }
                                else {
                                    success = false;
                                    failureMessage = getErrorMessage(e);
                                    break;
                                }
                            }
                        }
                        continue;
                    }
                    if (sseEvent.type === 'message.updated') {
                        const messageProps = sseEvent.properties;
                        const info = messageProps.info;
                        const isCurrentAssistantMessage = info?.sessionID === sessionId && info.role === 'assistant';
                        if (isCurrentAssistantMessage) {
                            const streamError = extractOpenCodeErrorMessage(info?.error);
                            if (streamError) {
                                success = false;
                                failureMessage = streamError;
                                diag.onStreamError('message.updated', streamError);
                                break;
                            }
                        }
                        continue;
                    }
                    if (sseEvent.type === 'message.completed') {
                        const completedProps = sseEvent.properties;
                        const info = completedProps.info;
                        const isCurrentAssistantMessage = info?.sessionID === sessionId && info.role === 'assistant';
                        if (isCurrentAssistantMessage) {
                            const streamError = extractOpenCodeErrorMessage(info?.error);
                            if (streamError) {
                                success = false;
                                failureMessage = streamError;
                                diag.onStreamError('message.completed', streamError);
                                break;
                            }
                        }
                        continue;
                    }
                    if (sseEvent.type === 'message.failed') {
                        const failedProps = sseEvent.properties;
                        const info = failedProps.info;
                        const isCurrentAssistantMessage = info?.sessionID === sessionId && info.role === 'assistant';
                        if (isCurrentAssistantMessage) {
                            success = false;
                            failureMessage = extractOpenCodeErrorMessage(info?.error) ?? 'OpenCode message failed';
                            diag.onStreamError('message.failed', failureMessage);
                            break;
                        }
                        continue;
                    }
                    if (sseEvent.type === 'session.status') {
                        const statusProps = sseEvent.properties;
                        if (statusProps.sessionID === sessionId && statusProps.status?.type === 'idle') {
                            break;
                        }
                        continue;
                    }
                    if (sseEvent.type === 'session.idle') {
                        const idleProps = sseEvent.properties;
                        if (idleProps.sessionID === sessionId) {
                            break;
                        }
                        continue;
                    }
                    if (sseEvent.type === 'session.error') {
                        const errorProps = sseEvent.properties;
                        if (!errorProps.sessionID || errorProps.sessionID === sessionId) {
                            success = false;
                            failureMessage = errorProps.error?.data?.message ?? 'OpenCode session error';
                            diag.onStreamError('session.error', failureMessage);
                            break;
                        }
                        continue;
                    }
                }
                content = [...textContentParts.values()].join('\n');
                diag.onCompleted(success ? 'normal' : 'error', success ? undefined : failureMessage);
                if (!success) {
                    const message = failureMessage || 'OpenCode execution failed';
                    if (containsRateLimitError(message)) {
                        const rateLimitedResponse = this.buildRateLimitedResponse(agentType, sessionId, message);
                        emitResult(options.onStream, false, rateLimitedResponse.error ?? rateLimitedResponse.content, sessionId);
                        return rateLimitedResponse;
                    }
                    const retriable = this.isRetriableError(message, streamAbortController.signal.aborted, abortCause);
                    if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) {
                        log.info('Retrying OpenCode call after transient failure', { agentType, attempt, message });
                        await this.waitForRetryDelay(attempt, options.abortSignal);
                        continue;
                    }
                    emitResult(options.onStream, false, message, sessionId);
                    return {
                        persona: agentType,
                        status: 'error',
                        content: message,
                        timestamp: new Date(),
                        sessionId,
                    };
                }
                const trimmed = content.trim();
                const structuredOutput = parseStructuredOutput(trimmed, !!options.outputSchema);
                emitResult(options.onStream, true, trimmed, sessionId);
                return {
                    persona: agentType,
                    status: 'done',
                    content: trimmed,
                    timestamp: new Date(),
                    sessionId,
                    structuredOutput,
                };
            }
            catch (error) {
                const message = getErrorMessage(error);
                const errorMessage = streamAbortController.signal.aborted
                    ? abortCause === 'timeout'
                        ? timeoutMessage
                        : OPENCODE_STREAM_ABORTED_MESSAGE
                    : message;
                if (containsRateLimitError(errorMessage)) {
                    const rateLimitedResponse = this.buildRateLimitedResponse(agentType, sessionId, errorMessage);
                    if (sessionId) {
                        emitResult(options.onStream, false, rateLimitedResponse.error ?? rateLimitedResponse.content, sessionId);
                    }
                    return rateLimitedResponse;
                }
                diagRef?.onCompleted(abortCause === 'timeout' ? 'timeout' : streamAbortController.signal.aborted ? 'abort' : 'error', errorMessage);
                const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause);
                if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) {
                    log.info('Retrying OpenCode call after transient exception', { agentType, attempt, errorMessage });
                    await this.waitForRetryDelay(attempt, options.abortSignal);
                    continue;
                }
                if (sessionId) {
                    emitResult(options.onStream, false, errorMessage, sessionId);
                }
                return {
                    persona: agentType,
                    status: 'error',
                    content: errorMessage,
                    timestamp: new Date(),
                    sessionId,
                };
            }
            finally {
                if (idleTimeoutId !== undefined) {
                    clearTimeout(idleTimeoutId);
                }
                if (options.abortSignal) {
                    options.abortSignal.removeEventListener('abort', onExternalAbort);
                }
                release?.();
                if (!streamAbortController.signal.aborted) {
                    streamAbortController.abort();
                }
            }
        }
        throw new Error('Unreachable: OpenCode retry loop exhausted without returning');
    }
    /** Call OpenCode with a custom agent configuration (system prompt + prompt) */
    async callCustom(agentName, prompt, systemPrompt, options) {
        return this.call(agentName, prompt, {
            ...options,
            systemPrompt,
        });
    }
}
const defaultClient = new OpenCodeClient();
export async function callOpenCode(agentType, prompt, options) {
    return defaultClient.call(agentType, prompt, options);
}
export async function callOpenCodeCustom(agentName, prompt, systemPrompt, options) {
    return defaultClient.callCustom(agentName, prompt, systemPrompt, options);
}
//# sourceMappingURL=client.js.map