import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
import { buildOperationArgv, resolveInvocation, } from './transport-common.js';
import { SuperDocCliError } from './errors.js';
const HOST_PROTOCOL_VERSION = '1.0';
const REQUIRED_FEATURES = ['cli.invoke', 'host.shutdown'];
const CHANGE_MODES = ['direct', 'tracked'];
const FORWARD_HOST_STDERR = typeof process !== 'undefined' && typeof process.env?.SUPERDOC_DEBUG_TEXT_REWRITE === 'string'
    ? process.env.SUPERDOC_DEBUG_TEXT_REWRITE === '1'
    : false;
const JSON_RPC_TIMEOUT_CODE = -32011;
// Mirrors apps/cli/src/host/server.ts:DEFAULT_REQUEST_TIMEOUT_MS. Kept in sync
// by hand; an explicit constant here avoids importing CLI app internals across
// the package boundary.
const HOST_DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
// Extra time the JS-side watchdog waits beyond the host-side ceiling so the
// host's structured RequestTimeout error wins the race against the SDK's own
// abort. The buffer absorbs JSON-RPC serialization, stdio drain, and event-
// loop latency.
const WATCHDOG_HEADROOM_MS = 5_000;
/**
 * Builds the argv passed to `spawn` for `superdoc host --stdio`. Propagates
 * `requestTimeoutMs` to the host via `--request-timeout-ms`, since the SDK
 * option alone cannot raise the host's 30s per-invoke ceiling otherwise.
 *
 * Exported for unit testing.
 */
export function buildHostSpawnArgs(prefixArgs, options) {
    const args = [...prefixArgs, 'host', '--stdio'];
    if (options.requestTimeoutMs != null) {
        args.push('--request-timeout-ms', String(options.requestTimeoutMs));
    }
    return args;
}
/**
 * Computes the JS-side watchdog timeout for a single JSON-RPC request.
 *
 * The watchdog must outlive the host-side `cli.invoke` ceiling so the host's
 * structured `RequestTimeout` JSON-RPC frame wins the race against the SDK's
 * own abort. Three cases:
 *
 * 1. A per-call `InvokeOptions.timeoutMs` is supplied → widen above it.
 * 2. The client set `requestTimeoutMs` → widen above that.
 * 3. Neither is set → widen above the host's compiled-in default so the
 *    default-config case doesn't race at 30s (which previously surfaced the
 *    old "Host watchdog timed out" string instead of the host's structured
 *    TIMEOUT error).
 *
 * Exported for unit testing.
 */
export function resolveJsWatchdogTimeout(watchdogTimeoutMs, requestTimeoutMs, timeoutMsOverride) {
    if (timeoutMsOverride != null) {
        return Math.max(watchdogTimeoutMs, timeoutMsOverride + WATCHDOG_HEADROOM_MS);
    }
    if (requestTimeoutMs != null) {
        return Math.max(watchdogTimeoutMs, requestTimeoutMs + WATCHDOG_HEADROOM_MS);
    }
    return Math.max(watchdogTimeoutMs, HOST_DEFAULT_REQUEST_TIMEOUT_MS + WATCHDOG_HEADROOM_MS);
}
/**
 * Transport that communicates with a long-lived CLI host process over JSON-RPC stdio.
 */
export class HostTransport {
    cliBin;
    env;
    startupTimeoutMs;
    shutdownTimeoutMs;
    requestTimeoutMs;
    watchdogTimeoutMs;
    maxQueueDepth;
    defaultChangeMode;
    user;
    child = null;
    stdoutReader = null;
    pending = new Map();
    nextRequestId = 1;
    connecting = null;
    stopping = false;
    constructor(options) {
        this.cliBin = options.cliBin;
        this.env = options.env;
        this.startupTimeoutMs = options.startupTimeoutMs ?? 5_000;
        this.shutdownTimeoutMs = options.shutdownTimeoutMs ?? 5_000;
        this.requestTimeoutMs = options.requestTimeoutMs;
        this.watchdogTimeoutMs = options.watchdogTimeoutMs ?? 30_000;
        this.maxQueueDepth = options.maxQueueDepth ?? 100;
        if (options.defaultChangeMode != null && !CHANGE_MODES.includes(options.defaultChangeMode)) {
            throw new SuperDocCliError('defaultChangeMode must be "direct" or "tracked".', {
                code: 'INVALID_ARGUMENT',
                details: { defaultChangeMode: options.defaultChangeMode },
            });
        }
        this.defaultChangeMode = options.defaultChangeMode;
        this.user = options.user;
    }
    async connect() {
        await this.ensureConnected();
    }
    async dispose() {
        if (!this.child)
            return;
        this.stopping = true;
        const child = this.child;
        try {
            await this.sendJsonRpcRequest('host.shutdown', {}, this.shutdownTimeoutMs);
        }
        catch {
            // ignore and force shutdown below
        }
        await new Promise((resolve) => {
            const timer = setTimeout(() => {
                child.kill('SIGKILL');
                resolve();
            }, this.shutdownTimeoutMs);
            child.once('close', () => {
                clearTimeout(timer);
                resolve();
            });
        });
        this.cleanupProcess(null);
        this.stopping = false;
    }
    async invoke(operation, params = {}, options = {}) {
        await this.ensureConnected();
        const argv = buildOperationArgv(operation, params, options, this.requestTimeoutMs, this.defaultChangeMode, this.user);
        const stdinBase64 = options.stdinBytes ? Buffer.from(options.stdinBytes).toString('base64') : '';
        const watchdogTimeout = this.resolveWatchdogTimeout(options.timeoutMs);
        const response = await this.sendJsonRpcRequest('cli.invoke', {
            argv,
            stdinBase64,
        }, watchdogTimeout);
        if (typeof response !== 'object' || response == null || Array.isArray(response)) {
            throw new SuperDocCliError('Host returned invalid cli.invoke result.', {
                code: 'HOST_PROTOCOL_ERROR',
                details: { result: response },
            });
        }
        const resultRecord = response;
        return resultRecord.data;
    }
    async ensureConnected() {
        if (this.child && !this.child.killed) {
            return;
        }
        if (this.connecting) {
            await this.connecting;
            return;
        }
        this.connecting = this.startHostProcess();
        try {
            await this.connecting;
        }
        finally {
            this.connecting = null;
        }
    }
    async startHostProcess() {
        const { command, prefixArgs } = resolveInvocation(this.cliBin);
        const args = buildHostSpawnArgs(prefixArgs, { requestTimeoutMs: this.requestTimeoutMs });
        const child = spawn(command, args, {
            env: {
                ...process.env,
                ...(this.env ?? {}),
            },
            stdio: ['pipe', 'pipe', 'pipe'],
        });
        this.child = child;
        const stdoutReader = createInterface({
            input: child.stdout,
            crlfDelay: Number.POSITIVE_INFINITY,
        });
        this.stdoutReader = stdoutReader;
        stdoutReader.on('line', (line) => {
            this.onStdoutLine(line);
        });
        child.stderr.on('data', (chunk) => {
            if (!FORWARD_HOST_STDERR) {
                return;
            }
            process.stderr.write(`[superdoc-host] ${String(chunk)}`);
        });
        child.on('error', (error) => {
            this.handleDisconnect(new SuperDocCliError('Host process failed.', {
                code: 'HOST_DISCONNECTED',
                details: {
                    message: error instanceof Error ? error.message : String(error),
                },
            }));
        });
        child.on('close', (code, signal) => {
            if (this.stopping) {
                this.cleanupProcess(null);
                return;
            }
            this.handleDisconnect(new SuperDocCliError('Host process disconnected.', {
                code: 'HOST_DISCONNECTED',
                details: { exitCode: code, signal },
            }));
        });
        try {
            const capabilities = await this.sendJsonRpcRequest('host.capabilities', {}, this.startupTimeoutMs);
            this.assertCapabilities(capabilities);
        }
        catch (error) {
            const normalized = error instanceof SuperDocCliError
                ? error
                : new SuperDocCliError('Host handshake failed.', {
                    code: 'HOST_HANDSHAKE_FAILED',
                    details: {
                        message: error instanceof Error ? error.message : String(error),
                    },
                });
            this.handleDisconnect(normalized);
            throw normalized;
        }
    }
    assertCapabilities(response) {
        if (typeof response !== 'object' || response == null || Array.isArray(response)) {
            throw new SuperDocCliError('Host capabilities response is invalid.', {
                code: 'HOST_HANDSHAKE_FAILED',
                details: { response },
            });
        }
        const record = response;
        const protocolVersion = record.protocolVersion;
        const features = record.features;
        if (protocolVersion !== HOST_PROTOCOL_VERSION) {
            throw new SuperDocCliError('Host protocol version is unsupported.', {
                code: 'HOST_HANDSHAKE_FAILED',
                details: {
                    expected: HOST_PROTOCOL_VERSION,
                    actual: protocolVersion,
                },
            });
        }
        if (!Array.isArray(features) || features.some((f) => typeof f !== 'string')) {
            throw new SuperDocCliError('Host capabilities.features must be a string array.', {
                code: 'HOST_HANDSHAKE_FAILED',
                details: { features },
            });
        }
        for (const requiredFeature of REQUIRED_FEATURES) {
            if (!features.includes(requiredFeature)) {
                throw new SuperDocCliError(`Host does not support required feature: ${requiredFeature}`, {
                    code: 'HOST_HANDSHAKE_FAILED',
                    details: { features },
                });
            }
        }
    }
    resolveWatchdogTimeout(timeoutMsOverride) {
        return resolveJsWatchdogTimeout(this.watchdogTimeoutMs, this.requestTimeoutMs, timeoutMsOverride);
    }
    async sendJsonRpcRequest(method, params, watchdogTimeoutMs) {
        const child = this.child;
        if (!child || !child.stdin.writable) {
            throw new SuperDocCliError('Host process is not available.', {
                code: 'HOST_DISCONNECTED',
            });
        }
        if (this.pending.size >= this.maxQueueDepth) {
            throw new SuperDocCliError('Host request queue is full.', {
                code: 'HOST_QUEUE_FULL',
                details: { maxQueueDepth: this.maxQueueDepth },
            });
        }
        const id = this.nextRequestId;
        this.nextRequestId += 1;
        const payload = JSON.stringify({
            jsonrpc: '2.0',
            id,
            method,
            params,
        });
        const promise = new Promise((resolve, reject) => {
            const timer = setTimeout(() => {
                this.pending.delete(id);
                reject(new SuperDocCliError(`Host watchdog timed out waiting for ${method}.`, {
                    code: 'HOST_TIMEOUT',
                    details: { method, timeoutMs: watchdogTimeoutMs },
                }));
                this.handleDisconnect(new SuperDocCliError('Host watchdog timeout; host process will be restarted on next request.', {
                    code: 'HOST_DISCONNECTED',
                    details: { method, timeoutMs: watchdogTimeoutMs },
                }));
            }, watchdogTimeoutMs);
            this.pending.set(id, { resolve, reject, timer });
            child.stdin.write(`${payload}\n`, (error) => {
                if (!error)
                    return;
                const pending = this.pending.get(id);
                if (!pending)
                    return;
                clearTimeout(pending.timer);
                this.pending.delete(id);
                reject(new SuperDocCliError('Failed to write request to host process.', {
                    code: 'HOST_DISCONNECTED',
                    details: { method, message: error.message },
                }));
            });
        });
        return promise;
    }
    onStdoutLine(line) {
        let parsed;
        try {
            parsed = JSON.parse(line);
        }
        catch {
            return;
        }
        if (typeof parsed !== 'object' || parsed == null || Array.isArray(parsed)) {
            return;
        }
        const record = parsed;
        if (record.jsonrpc !== '2.0') {
            return;
        }
        // Notification (no id) — reserved for future eventing
        if ('method' in record && !('id' in record)) {
            return;
        }
        const idRaw = record.id;
        if (typeof idRaw !== 'number') {
            return;
        }
        const pending = this.pending.get(idRaw);
        if (!pending) {
            return;
        }
        clearTimeout(pending.timer);
        this.pending.delete(idRaw);
        if ('error' in record) {
            pending.reject(this.mapJsonRpcError(record.error));
            return;
        }
        pending.resolve(record.result);
    }
    mapJsonRpcError(rawError) {
        if (typeof rawError !== 'object' || rawError == null || Array.isArray(rawError)) {
            return new SuperDocCliError('Host returned an unknown JSON-RPC error.', {
                code: 'HOST_PROTOCOL_ERROR',
                details: { error: rawError },
            });
        }
        const error = rawError;
        const data = error.data;
        const cliCode = typeof data?.cliCode === 'string' ? data.cliCode : undefined;
        const cliMessage = typeof data?.message === 'string' ? data.message : undefined;
        const exitCode = typeof data?.exitCode === 'number' ? data.exitCode : undefined;
        if (cliCode) {
            return new SuperDocCliError(cliMessage ?? error.message ?? 'Command failed.', {
                code: cliCode,
                details: data?.details,
                exitCode,
            });
        }
        if (error.code === JSON_RPC_TIMEOUT_CODE) {
            return new SuperDocCliError(error.message, {
                code: 'TIMEOUT',
                details: data,
            });
        }
        return new SuperDocCliError(error.message, {
            code: 'COMMAND_FAILED',
            details: data,
        });
    }
    handleDisconnect(error) {
        this.cleanupProcess(error);
    }
    cleanupProcess(error) {
        const child = this.child;
        if (child) {
            child.removeAllListeners();
            child.kill('SIGKILL');
        }
        this.child = null;
        if (this.stdoutReader) {
            this.stdoutReader.removeAllListeners();
            this.stdoutReader.close();
            this.stdoutReader = null;
        }
        const pendingEntries = Array.from(this.pending.values());
        this.pending.clear();
        const rejection = error ??
            new SuperDocCliError('Host process was disposed while request was in flight.', {
                code: 'HOST_DISCONNECTED',
            });
        for (const pending of pendingEntries) {
            clearTimeout(pending.timer);
            pending.reject(rejection);
        }
    }
}
