"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SessionBridgeService = void 0;
const crypto_1 = require("crypto");
const index_core_1 = require("../index-core");
const log = (0, index_core_1.createLogger)('SessionBridgeService');
/**
 * Manages the lifecycle of an MCP visual editing session and queues
 * changes between the Rivet UI and the MCP server.
 *
 * Instantiated by the MCP server and shared in-memory with the Express
 * server so both MCP tool handlers and REST endpoints can access it.
 */
class SessionBridgeService {
    sessionId = null;
    _setVariantTarget = null;
    _variantLookup = null;
    _isVariantPortAllowed = null;
    statusAccessToken = null;
    projectPath = null;
    pendingChanges = null;
    telemetryContext = null;
    roundsCompleted = 0;
    feedback = [];
    status = 'IDLE';
    waitResolve = null;
    activeRunCorrelation = null;
    devServerHealth = {
        ownership: 'none',
        host: null,
        port: null,
        isReachable: null,
        reason: 'unknown',
        lastCheckedAt: null,
        startedByRivet: false,
    };
    /**
     * Start a new MCP visual editing session
     * @returns The unique session ID
     */
    startSession(projectPath) {
        if (this.sessionId) {
            log.warn('Session already active, ending previous session');
            this.endSession();
        }
        this.sessionId = (0, crypto_1.randomUUID)();
        this.projectPath = projectPath;
        this.pendingChanges = null;
        this.telemetryContext = null;
        this.roundsCompleted = 0;
        this.feedback = [];
        this.status = 'IDLE';
        this.activeRunCorrelation = null;
        this._variantLookup = null;
        this._isVariantPortAllowed = null;
        this.statusAccessToken = null;
        this.devServerHealth = {
            ownership: 'none',
            host: null,
            port: null,
            isReachable: null,
            reason: 'unknown',
            lastCheckedAt: null,
            startedByRivet: false,
        };
        log.info(`Session started: ${this.sessionId} for ${projectPath}`);
        return this.sessionId;
    }
    /**
     * Add a feedback message to the session.
     * Feedback is shown in the Rivet UI to let the user know what the MCP client is doing.
     * Normalizes literal escape sequences (e.g. `\n`, `\t`) that MCP clients sometimes
     * emit instead of real whitespace characters.
     */
    reportFeedback(message) {
        if (!this.sessionId) {
            log.warn('Cannot report feedback: no active session');
            return;
        }
        // Internal __RIVET_* signals carry JSON payloads — do not normalize escape
        // sequences in them, or the embedded JSON becomes unparseable.
        const normalized = message.startsWith('__RIVET_')
            ? message
            : message.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
        this.feedback.push(normalized);
        log.info(`Feedback reported: ${normalized}`);
    }
    /** Get all feedback messages for the current session */
    getFeedback() {
        return this.feedback;
    }
    /** Get the current session status */
    getStatus() {
        return this.status;
    }
    /** Set the session status (used by MCP tools to indicate progress) */
    setStatus(status) {
        if (this.status !== status) {
            log.info(`Session status changed: ${this.status} -> ${status}`);
            this.status = status;
        }
    }
    /** Check whether a session is currently active */
    isActive() {
        return this.sessionId !== null;
    }
    /** Get the current session ID, or null if no session is active */
    getSessionId() {
        return this.sessionId;
    }
    /** Get the project path for the current session */
    getProjectPath() {
        return this.projectPath;
    }
    /**
     * Rebind the active session to a new project root after a fresh-variant
     * handoff succeeds.
     */
    rebindProjectPath(projectPath) {
        if (!this.sessionId) {
            throw new Error('Cannot rebind project path without an active session');
        }
        this.projectPath = projectPath;
        log.info(`Session project rebound to ${projectPath}`);
    }
    /**
     * Queue changes from the Rivet UI for retrieval by the MCP client.
     * Replaces any previously queued intent that hasn't been retrieved yet.
     */
    queueChangeRequest(intent) {
        if (!this.sessionId) {
            log.warn('Cannot queue changes: no active session');
            return;
        }
        // Clear feedback from previous round when a new round starts
        this.feedback = [];
        this.pendingChanges = intent;
        this.updateTelemetryContext(intent.telemetryContext);
        log.info(`Change request queued (Round ${this.roundsCompleted + 1}): ${intent.changes.length} changes, ${intent.sourceFiles.length} source files`);
        // Wake up any blocking watch_for_changes call
        if (this.waitResolve) {
            this.waitResolve(intent);
            this.waitResolve = null;
        }
    }
    /**
     * Retrieve pending changes, or null if none is queued.
     * Does NOT clear the queue — call clearPending() after processing.
     */
    getPendingChanges() {
        return this.pendingChanges;
    }
    /**
     * Block until changes are queued, or return immediately if already pending.
     * Used by watch_for_changes for clients without background monitoring (e.g. Cursor).
     * Rejects if the timeout is reached.
     */
    waitForChanges(timeoutMs = 300_000) {
        if (this.pendingChanges) {
            return Promise.resolve(this.pendingChanges);
        }
        return new Promise((resolve, reject) => {
            const timer = setTimeout(() => {
                this.waitResolve = null;
                reject(new Error('Timed out waiting for design changes'));
            }, timeoutMs);
            this.waitResolve = (intent) => {
                clearTimeout(timer);
                resolve(intent);
            };
        });
    }
    /**
     * Clear the pending changes queue and increment the round counter.
     * Call after the MCP client has retrieved and processed the intent.
     */
    clearPending(expectedIntent) {
        if (expectedIntent && this.pendingChanges !== expectedIntent) {
            log.info('Pending intent changed while processing; preserving newer intent');
            return;
        }
        if (this.pendingChanges) {
            this.roundsCompleted++;
            this.pendingChanges = null;
            log.info(`Pending intent cleared, round ${this.roundsCompleted} complete`);
        }
    }
    /**
     * Consume a single-item variant-request batch from the pending queue.
     *
     * Variant-request batches are not "applied" — they are routed into the MCP
     * variants lifecycle (propose_variants → approval → continue → commit). They
     * must NOT call startApplyingRun, must NOT fire trackChangesQueued /
     * trackAgentRunStarted, and must NOT increment the apply-run round counter.
     *
     * Returns the consumed intent so callers can derive the agent's
     * propose_variants target from it.
     */
    consumeVariantRequestBatch(expectedIntent) {
        const intent = this.pendingChanges;
        if (!intent)
            return null;
        if (expectedIntent && intent !== expectedIntent) {
            log.info('Pending intent changed while consuming variant request; preserving newer intent');
            return null;
        }
        this.pendingChanges = null;
        // Wake any blocking watch_for_changes caller that hasn't returned yet,
        // mirroring the queue/wake symmetry of queueChangeRequest. (Defensive:
        // the consume path is normally reached from a watch_for_changes caller
        // that has already resolved; this is a no-op in that case.)
        this.waitResolve = null;
        log.info(`Variant-request batch consumed (no apply run started, ${intent.changes.length} items)`);
        return intent;
    }
    /**
     * Consume a static-preview refinement batch from the pending queue.
     *
     * These batches refine an in-memory HTML variant through the variants MCP
     * lifecycle. They must not start a base project apply run or increment the
     * apply-round counter.
     */
    consumeStaticPreviewRefineBatch(expectedIntent) {
        const intent = this.pendingChanges;
        if (!intent)
            return null;
        if (expectedIntent && intent !== expectedIntent) {
            log.info('Pending intent changed while consuming static-preview refine request; preserving newer intent');
            return null;
        }
        this.pendingChanges = null;
        this.waitResolve = null;
        log.info(`Static-preview refine batch consumed (no apply run started, ${intent.changes.length} items)`);
        return intent;
    }
    /**
     * Remove variant-request comment items from a mixed pending batch while
     * preserving all non-variant items for a follow-up apply run.
     */
    rejectMixedVariantRequestBatch(expectedIntent) {
        const intent = this.pendingChanges;
        if (!intent)
            return null;
        if (expectedIntent && intent !== expectedIntent) {
            log.info('Pending intent changed while rejecting mixed variant request; preserving newer intent');
            return undefined;
        }
        const nextChanges = intent.changes.filter((change) => !(change.kind === 'comment' &&
            change.variantRequest !== undefined));
        if (nextChanges.length === intent.changes.length) {
            return intent;
        }
        if (nextChanges.length === 0) {
            this.pendingChanges = null;
            this.waitResolve = null;
            log.info('Mixed variant batch rejection removed all pending changes');
            return null;
        }
        this.pendingChanges = {
            ...intent,
            changes: nextChanges,
        };
        log.info(`Mixed variant batch rejected; preserved ${nextChanges.length} non-variant items`);
        return this.pendingChanges;
    }
    /**
     * Mark the currently processing MCP apply run using identifiers from the queued request.
     */
    startApplyingRun(intent) {
        const telemetryContext = this.getTelemetryContext();
        const correlation = {
            traceId: intent.traceId || (0, crypto_1.randomUUID)(),
            agentRunId: intent.agentRunId || (0, crypto_1.randomUUID)(),
            startedAt: Date.now(),
            browserSessionId: telemetryContext?.browserSessionId,
        };
        const orphanedRun = this.activeRunCorrelation;
        this.activeRunCorrelation = correlation;
        return {
            newRun: correlation,
            orphanedRun,
        };
    }
    /**
     * Complete the active MCP run and clear it from bridge state.
     */
    completeActiveRunCorrelation() {
        const correlation = this.activeRunCorrelation;
        this.activeRunCorrelation = null;
        return correlation;
    }
    /** Get the number of completed editing rounds in this session */
    getRoundsCompleted() {
        return this.roundsCompleted;
    }
    /** Get telemetry correlation data for the active session */
    getTelemetryContext() {
        return this.telemetryContext;
    }
    /** Get dev-server health metadata for the active MCP session. */
    getDevServerHealth() {
        return this.devServerHealth;
    }
    /** Set the base session dev-server context (ownership + endpoint). */
    setDevServerContext(context) {
        this.devServerHealth = {
            ...this.devServerHealth,
            ownership: context.ownership,
            host: context.host ?? null,
            port: context.port ?? null,
            startedByRivet: context.startedByRivet,
        };
    }
    /** Update session dev-server health reason/reachability. */
    updateDevServerHealth(update) {
        this.devServerHealth = {
            ...this.devServerHealth,
            isReachable: update.isReachable === undefined
                ? this.devServerHealth.isReachable
                : update.isReachable,
            reason: update.reason ?? this.devServerHealth.reason,
            lastCheckedAt: update.lastCheckedAt === undefined
                ? this.devServerHealth.lastCheckedAt
                : update.lastCheckedAt,
        };
    }
    /** Merge and store telemetry correlation data for the active session */
    updateTelemetryContext(context) {
        if (!context) {
            return;
        }
        const nextContext = {
            ...this.telemetryContext,
            ...context,
        };
        if (nextContext.browserSessionId === undefined &&
            nextContext.browserDistinctId === undefined) {
            return;
        }
        this.telemetryContext = nextContext;
    }
    /** Inject the proxy's setVariantTarget fn (called from server.ts after proxy is created) */
    registerVariantTargetSetter(fn) {
        this._setVariantTarget = fn;
    }
    /** Register a variant-key→port lookup function (called from mcp/server.ts after variant sessions are built) */
    registerVariantLookup(fn) {
        this._variantLookup = fn;
    }
    /** Register preview-port allowlist checker tied to the active variant session */
    registerVariantPortAllowlist(fn) {
        this._isVariantPortAllowed = fn;
    }
    /** Look up a variant's preview port by stable lookup key */
    lookupVariantPort(variantKey) {
        return this._variantLookup?.(variantKey) ?? null;
    }
    /** Check whether a preview port is registered for the active variant session */
    isVariantPortAllowed(port) {
        return this._isVariantPortAllowed?.(port) ?? false;
    }
    /** Register a status polling token used by Rivet UI. */
    setStatusAccessToken(token) {
        this.statusAccessToken = token;
    }
    /** Check whether a status polling token has already been registered. */
    hasStatusAccessToken() {
        return this.statusAccessToken !== null;
    }
    /** Validate status polling token from API requests. */
    isStatusAccessTokenValid(token) {
        return Boolean(token) && token === this.statusAccessToken;
    }
    /**
     * Advertise a Rivet-spawned committed dev server (vite_app or static
     * preview) on `devServerHealth` so `/api/mcp/status` and the iframe's
     * `usePreviewUpstreamHealth` probe see `ownership: 'rivet_owned'` + the
     * new port. Pass `null` to clear back to the no-server state. Distinct
     * from `switchPreviewPort` (which only retargets the proxy) and from
     * `setDevServerContext` (which the MCP server uses for the user's own
     * dev server during a regular session). Only the orchestrator's
     * commit-handoff path should call this — variant cycling and cancel
     * still go through `switchPreviewPort` and must NOT touch the user's
     * external dev-server health.
     */
    setCommittedDevServerHealth(port) {
        if (port === null) {
            this.devServerHealth = {
                ownership: 'none',
                host: null,
                port: null,
                isReachable: null,
                reason: 'unknown',
                lastCheckedAt: Date.now(),
                startedByRivet: false,
            };
            return;
        }
        this.devServerHealth = {
            ...this.devServerHealth,
            ownership: 'rivet_owned',
            host: '127.0.0.1',
            port,
            isReachable: true,
            reason: 'healthy',
            startedByRivet: true,
            lastCheckedAt: Date.now(),
        };
    }
    /** Switch the proxy to a variant dev server (or back to user's dev server when port is null) */
    switchPreviewPort(port) {
        if (!this._setVariantTarget) {
            log.warn(`switchPreviewPort(${port}) called but no variant target setter registered — proxy will NOT retarget. (Did open_visual_editor run? Is framework='static'?)`);
        }
        else {
            log.info(`switchPreviewPort → ${port === null ? 'default upstream' : `localhost:${port}`}`);
        }
        this._setVariantTarget?.(port);
        this.reportFeedback('__RIVET_RELOAD_PREVIEW__');
    }
    /**
     * End the current session, clearing all state.
     * @returns The total number of completed rounds
     */
    endSession() {
        const rounds = this.roundsCompleted;
        const id = this.sessionId;
        // Cancel any blocking wait
        this.waitResolve = null;
        this.sessionId = null;
        this.projectPath = null;
        this.pendingChanges = null;
        this.telemetryContext = null;
        this.roundsCompleted = 0;
        this.feedback = [];
        this.status = 'IDLE';
        this.activeRunCorrelation = null;
        this._variantLookup = null;
        this._isVariantPortAllowed = null;
        this.statusAccessToken = null;
        this.devServerHealth = {
            ownership: 'none',
            host: null,
            port: null,
            isReachable: null,
            reason: 'unknown',
            lastCheckedAt: null,
            startedByRivet: false,
        };
        log.info(`Session ended: ${id}, ${rounds} rounds completed`);
        return rounds;
    }
}
exports.SessionBridgeService = SessionBridgeService;
//# sourceMappingURL=SessionBridgeService.js.map