/**
 * WizardStore — Nanostore-backed reactive store for the TUI.
 * React components subscribe via useSyncExternalStore.
 *
 * Navigation is delegated to WizardRouter.
 * The active screen is derived from session state — not imperatively set.
 * Overlays (outage, etc.) are the only imperative navigation.
 *
 * All session mutations that affect screen resolution go through
 * explicit setters so emitChange() is always called.
 */
import { atom, map } from 'nanostores';
import { TaskStatus } from '../wizard-ui.js';
import { AuthOnboardingPath, isCreateAccountOnboarding, buildSession, toCredentialAppId, } from '../../lib/wizard-session.js';
import { DEFAULT_AMPLITUDE_ZONE } from '../../lib/constants.js';
import { resolveZone } from '../../lib/zone-resolution.js';
import { AdditionalFeature, McpOutcome, SlackOutcome, RunPhase, PostAgentStepStatus, } from './session-constants.js';
import { WizardRouter, Screen, Overlay, Flow, } from './router.js';
import { analytics, sessionPropertiesCompact } from '../../utils/analytics.js';
import { clearApiKey } from '../../utils/api-key-store.js';
import { CANONICAL_STEPS, matchCanonicalStep, } from '../../lib/canonical-tasks.js';
// Inlined to avoid tsx ESM resolution bug with dynamic import().
const FLAG_LLM_ANALYTICS = 'wizard-llm-analytics';
export { TaskStatus, Screen, Overlay, Flow, RunPhase, McpOutcome, SlackOutcome, PostAgentStepStatus, };
export class WizardStore {
    // ── Internal nanostore atoms ─────────────────────────────────────
    $session = map(buildSession({}));
    $statusMessages = atom([]);
    $tasks = atom([]);
    $eventPlan = atom([]);
    /**
     * Live list of file writes the inner agent has issued during the run.
     * Bounded to the most recent MAX_FILE_WRITES entries (FIFO eviction)
     * so a runaway inner agent that touches hundreds of files doesn't blow
     * up the TUI's render budget.
     */
    $fileWrites = atom([]);
    /**
     * Monotonic counter of every file write initiated by the agent — incremented
     * once per logical write when planned (PreToolUse) or, for synthesized
     * entries only, when applied (PostToolUse without a prior planned event).
     * Unlike `$fileWrites.length` (which caps at MAX_FILE_WRITES), this keeps
     * climbing past the cap so coaching/heartbeat signals that key off it
     * don't stall after the 50th write.
     */
    $fileWritesTotal = atom(0);
    /**
     * Per-step journey status derived from deterministic tool-call signals
     * (see `src/lib/journey-state.ts`). This is the source of truth for the
     * canonical 5-step progress UI; `syncTodos` only updates each step's
     * `activeForm` flavor text. Initialised on first `applyJourneyTransition`
     * call — until then each step is treated as `pending`.
     */
    $derivedJourney = atom({});
    /**
     * Latest non-empty `activeForm` the agent has emitted via `TodoWrite`,
     * keyed by canonical step id. Refreshed every `syncTodos` call. The
     * renderer prefers this over the canonical `defaultActiveForm` so the
     * user sees the agent's current narration ("Installing project
     * dependencies") on the active row.
     */
    $journeyActiveForms = atom({});
    $version = atom(0);
    /** True while the user is typing a slash command in the command bar. */
    $commandMode = atom(false);
    /** Transient feedback message shown in the command bar after a command runs. */
    $commandFeedback = atom(null);
    /** Error caught by the app error boundary — shown in ConsoleView. */
    $screenError = atom(null);
    $screenErrorRetry = atom(0);
    /** Tab id to switch to imperatively (e.g. from a slash command). */
    $requestedTab = atom(null);
    /** Last screen seen — used to detect screen transitions for analytics. */
    _lastScreen = null;
    /**
     * Active timer for `setCommandFeedback`. Tracked on the instance so a
     * second feedback call (e.g. `/feedback` after `/whoami`) can cancel
     * the prior timer before scheduling its own — without the ref, the
     * older long timer can wipe a fresh feedback message and the user sees
     * the wrong text disappear.
     */
    _feedbackTimer = null;
    /** Hooks run when transitioning onto a screen. */
    _enterScreenHooks = new Map();
    version = '';
    /** Navigation router — resolves active screen from session state. */
    router;
    /**
     * Setup promise — IntroScreen resolves this when the user confirms.
     * bin.ts awaits it before calling runWizard.
     */
    _resolveSetup;
    setupComplete = new Promise((resolve) => {
        this._resolveSetup = resolve;
    });
    /** Pending confirmation or choice prompt from the agent. */
    $pendingPrompt = atom(null);
    /** Resolves when the user picks continue vs run wizard after Amplitude pre-detection. */
    _preDetectedChoiceResolver = null;
    constructor(flow = Flow.Wizard) {
        this.router = new WizardRouter(flow);
    }
    // ── State accessors (read from atoms) ────────────────────────────
    get session() {
        return this.$session.get();
    }
    set session(value) {
        this.$session.set(value);
        this.emitChange();
    }
    get commandMode() {
        return this.$commandMode.get();
    }
    get commandFeedback() {
        return this.$commandFeedback.get();
    }
    get screenError() {
        return this.$screenError.get();
    }
    get screenErrorRetry() {
        return this.$screenErrorRetry.get();
    }
    get requestedTab() {
        return this.$requestedTab.get();
    }
    get statusMessages() {
        return this.$statusMessages.get();
    }
    get tasks() {
        return this.$tasks.get();
    }
    get eventPlan() {
        return this.$eventPlan.get();
    }
    get fileWrites() {
        return this.$fileWrites.get();
    }
    get fileWritesTotal() {
        return this.$fileWritesTotal.get();
    }
    get pendingPrompt() {
        return this.$pendingPrompt.get();
    }
    // ── Session setters ─────────────────────────────────────────────
    // Every setter that affects screen resolution calls emitChange().
    // Business logic calls these instead of mutating session directly.
    /** Advances the flow past IntroScreen. Does not start the agent. */
    concludeIntro() {
        this.$session.setKey('introConcluded', true);
        this.emitChange();
    }
    /**
     * Return to the welcome (Intro) screen. Used by Esc on RegionSelect and by
     * create-account EmailCapture when the user backs out before entering an account.
     * Does not clear region or framework — Continue from Intro re-resolves the
     * same downstream gates.
     */
    rewindIntro() {
        if (!this._reverting) {
            this.router._setDirection('pop');
        }
        this.$session.setKey('introConcluded', false);
        analytics.wizardCapture('back navigation', { to: 'intro' });
        this.$version.set(this.$version.get() + 1);
        if (!this._reverting) {
            this._detectTransition();
        }
    }
    /**
     * Unblocks bin.ts via the setupComplete promise, signalling that the agent
     * can start. Called by bin.ts via onEnterScreen(Screen.Run) so it fires at
     * the right point in the flow — after auth, data check, and setup questions.
     */
    completeSetup() {
        this.$session.setKey('setupConfirmed', true);
        analytics.wizardCapture('setup confirmed', sessionPropertiesCompact(this.session));
        this._resolveSetup();
        this.emitChange();
    }
    setRunPhase(phase) {
        const prevPhase = this.session.runPhase;
        this.$session.setKey('runPhase', phase);
        // Stamp the start time on the transition into Running so the elapsed
        // timer on the Run screen survives tab re-mounts (TabContainer fully
        // unmounts inactive tabs).
        if (phase === RunPhase.Running && prevPhase !== RunPhase.Running) {
            this.$session.setKey('runStartedAt', Date.now());
            // Pre-populate the canonical 5 tasks so the user sees "0 done · 5
            // to go" from frame 1, not an empty list while the agent loads.
            // The renderer derives rows from $derivedJourney + $journeyActiveForms;
            // calling it on every Running entry is idempotent and preserves
            // any progress already accumulated on a re-entry.
            if (this.$tasks.get().length === 0) {
                this.renderJourneyTasks();
            }
        }
        this.emitChange();
    }
    /**
     * Seed the post-agent step queue once, before the first step starts.
     * Idempotent: a second call replaces the queue, so callers should only
     * invoke this at the post-agent boundary.
     */
    seedPostAgentSteps(steps) {
        this.$session.setKey('postAgentSteps', steps);
        this.emitChange();
    }
    /**
     * Patch a single post-agent step by id. No-op if the id isn't seeded.
     * Callers transition `pending → in_progress → completed | skipped`;
     * we stamp `startedAt` on the in_progress transition so the
     * FinalizingPanel can compute per-step elapsed time without a separate
     * timer.
     */
    setPostAgentStep(id, patch) {
        const current = this.session.postAgentSteps;
        const idx = current.findIndex((s) => s.id === id);
        if (idx === -1)
            return;
        const prev = current[idx];
        const next = {
            ...prev,
            status: patch.status,
            reason: patch.reason ?? prev.reason,
        };
        if (patch.status === PostAgentStepStatus.InProgress &&
            prev.status !== PostAgentStepStatus.InProgress) {
            next.startedAt = Date.now();
        }
        const replaced = current.slice();
        replaced[idx] = next;
        this.$session.setKey('postAgentSteps', replaced);
        this.emitChange();
    }
    setCredentials(credentials) {
        this.$session.setKey('credentials', credentials);
        const session = this.$session.get();
        // readDisk: true — setCredentials may be called from paths (agent mode,
        // classic UI, token refresh) that run before / around RegionSelect.
        const zone = resolveZone(session, DEFAULT_AMPLITUDE_ZONE, {
            readDisk: true,
        });
        if (session.userEmail) {
            analytics.setDistinctId(session.userEmail);
            analytics.identifyUser({
                email: session.userEmail,
                org_id: session.selectedOrgId ?? undefined,
                org_name: session.selectedOrgName ?? undefined,
                project_id: session.selectedProjectId ?? undefined,
                project_name: session.selectedProjectName ?? undefined,
                app_id: session.selectedAppId ?? credentials?.appId,
                env_name: session.selectedEnvName,
                region: zone,
                integration: session.integration,
            });
        }
        analytics.wizardCapture('auth complete', {
            'app id': credentials?.appId,
            region: zone,
        });
        this.emitChange();
    }
    /**
     * Update the cached user email used by `/whoami`. Required because nanostores'
     * map-based storage replaces the top-level session object on every `setKey`,
     * so closed-over `session` references in long-lived callbacks (e.g. the
     * re-auth watcher in bin.ts) become stale and direct mutation would land on
     * a discarded object.
     */
    setUserEmail(email) {
        this.$session.setKey('userEmail', email);
        this.emitChange();
    }
    setApiKeyNotice(notice) {
        this.$session.setKey('apiKeyNotice', notice);
        this.emitChange();
    }
    setSelectedEnvName(name) {
        this.$session.setKey('selectedEnvName', name);
        this.emitChange();
    }
    setFrameworkConfig(integration, config) {
        this.$session.setKey('integration', integration);
        this.$session.setKey('frameworkConfig', config);
        if (integration) {
            analytics.identifyUser({ integration });
        }
        this.emitChange();
    }
    setDetectionComplete() {
        this.$session.setKey('detectionComplete', true);
        this.emitChange();
    }
    /**
     * Mirror the full per-framework detection table onto the session.
     * Used by `/diagnostics` so users filing bug reports can see exactly
     * which detector returned what — even for frameworks that didn't
     * win. Set BEFORE `setDetectionComplete` so the screen never sees a
     * stale `[]` after the spinner flips off.
     */
    setDetectionResults(results) {
        this.$session.setKey('detectionResults', results);
        this.emitChange();
    }
    setDetectedFramework(label) {
        this.$session.setKey('detectedFrameworkLabel', label);
        this.emitChange();
    }
    // ── Inline directory change ─────────────────────────────────────
    //
    // The IntroScreen's "Change directory" flow needs to do three things
    // atomically:
    //   1. Validate the new path is a real directory (caller's job).
    //   2. Reset every detection-related session field so the spinner
    //      reappears and the workspace-analysis warnings re-render.
    //   3. Kick off `runFrameworkDetection` against the new directory,
    //      cancelling any in-flight detection from the previous path.
    //
    // The detection runner itself lives in `lib/framework-detection.ts`
    // — bin.ts wires it into the store at startup via
    // `setFrameworkRedetector`. That keeps this file from depending on
    // the framework registry (which would invert the import graph and
    // break the test surface).
    /**
     * Re-detection callback registered by bin.ts. Returning `null` from
     * `changeInstallDir` would cause it to no-op when the detector
     * isn't wired up (e.g. tests, classic mode), which we want — the
     * directory change still applies, we just skip the redetect.
     */
    _redetect = null;
    /** AbortController for the active detection run, if any. */
    _activeDetectionAbort = null;
    /**
     * Register the framework re-detection callback. Called once by
     * bin.ts during TUI bootstrap. Calling it more than once replaces
     * the previous callback (used by tests).
     */
    setFrameworkRedetector(redetect) {
        this._redetect = redetect;
    }
    /**
     * Register an in-flight detection's AbortController so a subsequent
     * `changeInstallDir` can cancel it. bin.ts calls this once at boot
     * with the controller it created for the INITIAL detection — that
     * way a user who picks "Change directory" while the first
     * `Scanning…` spinner is still running cancels the initial run too,
     * not just later re-runs.
     *
     * Without this hook the initial detection's terminal
     * `setDetectionComplete()` could fire AFTER the directory swap has
     * reset state, briefly flashing stale framework results from the
     * old tree.
     */
    registerActiveDetection(controller) {
        this._activeDetectionAbort = controller;
    }
    /**
     * Swap the install directory and re-run framework detection.
     *
     * `newInstallDir` MUST be a resolved, existing absolute path —
     * the caller (PathInput in IntroScreen) handles `~` expansion and
     * validation so this method can stay pure-state-mutation.
     *
     * Effects:
     *   - cancels any in-flight detection
     *   - clears `frameworkConfig`, `integration`, `detectedFrameworkLabel`,
     *     `frameworkContext`, `detectionResults`, `discoveredFeatures`
     *   - flips `detectionComplete` back to false (spinner reappears)
     *   - clears the checkpoint-restore flag — checkpoint was for the
     *     OLD directory; resuming into a different tree would be wrong
     *   - kicks off a new detection run against the new directory
     */
    changeInstallDir(newInstallDir) {
        // Cancel any active detection. The runner short-circuits on the
        // signal so a stale run can't fire `setDetectionComplete()` after
        // we've reset state for the new directory.
        if (this._activeDetectionAbort) {
            this._activeDetectionAbort.abort();
        }
        this.$session.setKey('installDir', newInstallDir);
        this.$session.setKey('frameworkConfig', null);
        this.$session.setKey('integration', null);
        this.$session.setKey('detectedFrameworkLabel', null);
        this.$session.setKey('frameworkContext', {});
        // The answer-order array tracks which framework-setup question
        // keys the user answered, in order, so back-navigation can pop the
        // most recent one. Without this reset, stale keys from the OLD
        // directory's gatherContext stay in the array and
        // `popLastFrameworkContextAnswer` returns `true` for phantom pops
        // (the keys it claims to remove no longer exist in
        // `frameworkContext`), creating dead back-navigation steps.
        this.$session.setKey('frameworkContextAnswerOrder', []);
        this.$session.setKey('detectionResults', []);
        this.$session.setKey('detectionComplete', false);
        // Reset BOTH the discovered-features list AND every opt-in flag /
        // queue derived from it. These are computed from the OLD project's
        // package.json (Stripe, LLM SDK presence, etc.) and have zero
        // meaning in the new tree — letting them carry over would cause
        // the agent to set up irrelevant addons (e.g. LLM analytics for a
        // Python AI repo we already navigated away from).
        this.$session.setKey('discoveredFeatures', []);
        this.$session.setKey('additionalFeatureQueue', []);
        this.$session.setKey('llmOptIn', false);
        this.$session.setKey('sessionReplayOptIn', false);
        this.$session.setKey('engagementOptIn', false);
        this.$session.setKey('optInFeaturesComplete', false);
        // Checkpoint resume is invalid across directories — different tree
        // means different ampli.json, different events.json, different
        // framework. Treat the change as a "Start fresh".
        this.$session.setKey('_restoredFromCheckpoint', false);
        analytics.wizardCapture('install dir changed', {
            // Don't capture the path itself — could be PII / contain
            // employer-internal repo names. Capture only the SHAPE of the
            // event so we know how often this exit hatch is used.
            'has redetector': this._redetect !== null,
        });
        this.emitChange();
        if (this._redetect) {
            const controller = new AbortController();
            this._activeDetectionAbort = controller;
            const target = newInstallDir;
            // Fire-and-forget: detection mirrors its results into the store
            // as it goes, so callers don't await this. Errors are swallowed
            // — detection failures fall back to the generic framework, and
            // the runner already logs internally.
            void this._redetect(target, controller.signal).catch(() => {
                /* detection errors are non-fatal; the generic fallback handles them */
            });
        }
    }
    setLoginUrl(url) {
        this.$session.setKey('loginUrl', url);
        this.emitChange();
    }
    setRegion(region) {
        this.$session.setKey('region', region);
        this.$session.setKey('regionForced', false);
        analytics.wizardCapture('region selected', { region });
        // Persist the chosen zone to project binding files (`project-binding.json`
        // and mirrored `ampli.json`) so the next wizard run uses the right zone —
        // even if the user exits before completing SUSI. When switching regions via
        // /region, prior OrgId/ProjectId are invalid in the new zone; drop them so
        // resolveCredentials doesn't silently steer back to a stale project.
        //
        // Only updates when a binding already exists; never creates one. Fresh
        // projects have their zone persisted later by setOrgAndProject()
        // once the full SUSI flow completes.
        const session = this.$session.get();
        const typedZone = region;
        void (async () => {
            try {
                const { readAmpliConfig, writeAmpliConfig } = await import('../../lib/ampli-config.js');
                const prior = readAmpliConfig(session.installDir);
                if (!prior.ok)
                    return; // no existing binding — nothing to update
                const next = { ...prior.config, Zone: typedZone };
                if (session.selectedOrgId && session.selectedProjectId) {
                    next.OrgId = session.selectedOrgId;
                    next.ProjectId = session.selectedProjectId;
                }
                else {
                    // Cleared by setRegionForced — IDs from the old zone are invalid.
                    delete next.OrgId;
                    delete next.ProjectId;
                }
                const persisted = writeAmpliConfig(session.installDir, next);
                if (!persisted) {
                    this.setCommandFeedback("Region updated for this session, but couldn't persist to project binding files. Re-pick if it sticks to the old zone next run.", 6000);
                }
            }
            catch (err) {
                // Non-fatal: project persistence is best-effort. On read-only
                // filesystems or permission errors we'd leave the old Zone in place
                // and users can still complete the current session.
                analytics.captureException(err instanceof Error ? err : new Error(String(err)));
                this.setCommandFeedback("Region updated for this session, but couldn't persist project binding files. Re-pick if it sticks to the old zone next run.", 6000);
            }
        })();
        this.emitChange();
    }
    setSignupEmail(email) {
        this.$session.setKey('signupEmail', email);
        analytics.wizardCapture('signup email captured', { 'has email': !!email });
        this.emitChange();
    }
    setSignupFullName(fullName) {
        this.$session.setKey('signupFullName', fullName);
        analytics.wizardCapture('signup full name captured', {
            'has name': !!fullName,
        });
        this.emitChange();
    }
    markEmailCaptureComplete() {
        this.$session.setKey('emailCaptureComplete', true);
        analytics.wizardCapture('email capture complete');
        this.emitChange();
    }
    markSignupTokensObtained() {
        this.$session.setKey('signupTokensObtained', true);
        this.emitChange();
    }
    acceptTermsOfService() {
        this.$session.setKey('tosAccepted', true);
        analytics.wizardCapture('terms of service accepted');
        this.emitChange();
    }
    resetEmailCapture() {
        this.$session.setKey('emailCaptureComplete', false);
        analytics.wizardCapture('back navigation', { to: 'email-capture' });
        this.emitChange();
    }
    resetToS() {
        this.$session.setKey('tosAccepted', null);
        analytics.wizardCapture('back navigation', { to: 'tos' });
        this.emitChange();
    }
    setAuthOnboardingPath(path) {
        this.$session.setKey('authOnboardingPath', path);
        this.emitChange();
    }
    switchToLogin() {
        this.$session.setKey('authOnboardingPath', AuthOnboardingPath.SignIn);
        this.$session.setKey('signupEmail', null);
        this.$session.setKey('signupFullName', null);
        analytics.wizardCapture('signup switched to login', {
            reason: 'existing user',
        });
        this.emitChange();
    }
    cancelWizard(reason) {
        this.setOutroData({
            kind: 'cancel',
            message: reason,
        });
        this.emitChange();
    }
    /**
     * Force the RegionSelect screen to re-appear (/region command).
     *
     * A mid-session region change is equivalent to logging out and logging back
     * in against the other data center: OAuth tokens are zone-scoped, and every
     * org/workspace/environment the user has picked lives in the old region.
     * Clear all of that so the Auth screen reappears once a new region is
     * picked, forcing a fresh login. OAuth tokens in the wizard session store
     * are kept per-zone and will be silently reused if the user already signed
     * into the target region previously.
     */
    setRegionForced() {
        this.$session.setKey('regionForced', true);
        // Credentials and OAuth intermediates (all zone-scoped)
        this.$session.setKey('credentials', null);
        this.$session.setKey('pendingOrgs', null);
        this.$session.setKey('pendingAuthIdToken', null);
        this.$session.setKey('pendingAuthAccessToken', null);
        this.$session.setKey('apiKeyNotice', null);
        // User / org / workspace / project selection — all lived in the old zone
        this.$session.setKey('userEmail', null);
        this.$session.setKey('selectedOrgId', null);
        this.$session.setKey('selectedOrgName', null);
        this.$session.setKey('selectedProjectId', null);
        this.$session.setKey('selectedProjectName', null);
        this.$session.setKey('selectedAppId', null);
        this.$session.setKey('selectedEnvName', null);
        // Downstream flow state that depends on the old zone's data
        this.$session.setKey('projectHasData', null);
        this.$session.setKey('activationLevel', null);
        this.$session.setKey('activationOptionsComplete', false);
        this.$session.setKey('dataIngestionConfirmed', false);
        this.$session.setKey('signupMagicLinkUrl', null);
        this.$session.setKey('mcpComplete', false);
        this.$session.setKey('mcpOutcome', null);
        this.$session.setKey('mcpInstalledClients', []);
        this.$session.setKey('slackComplete', false);
        this.$session.setKey('slackOutcome', null);
        // Framework-detection + feature-discovery state lived against the old
        // zone's project. Clearing these BEFORE we touch outroData/runPhase keeps
        // the router from transiently resolving to a post-detection screen
        // (e.g. FeatureOptIn / Setup) while runPhase is still mid-tear-down.
        this.$session.setKey('integration', null);
        this.$session.setKey('frameworkConfig', null);
        this.$session.setKey('frameworkContext', {});
        this.$session.setKey('discoveredFeatures', []);
        this.$session.setKey('additionalFeatureQueue', []);
        this.$session.setKey('additionalFeatureCurrent', null);
        this.$session.setKey('additionalFeatureCompleted', []);
        this.$session.setKey('optInFeaturesComplete', false);
        // If the wizard had already reached Outro (success, error, or cancel)
        // the outroData short-circuits router.resolve and would block the
        // re-auth flow. A region switch is a hard reset — clear outroData so
        // the user lands on RegionSelect → Auth → ... for the new zone.
        this.$session.setKey('outroData', null);
        this.$session.setKey('runPhase', RunPhase.Idle);
        this.emitChange();
    }
    /**
     * Reset session fields cleared by IntroScreen's "Start fresh" branch
     * after a checkpoint restore.
     *
     * Previously the screen did `store.session = { ...store.session, ... }`
     * — direct top-level reassignment goes through the `set session()`
     * setter, which calls `$session.set()` + `emitChange()`. That works
     * for `useSyncExternalStore` subscribers (everyone reads via
     * `$version`), BUT it bypasses the per-key change events that
     * nanostores `map.setKey()` emits. Any future code that subscribes
     * via `listenKeys(['integration'])` etc. would silently miss the
     * reset. Routing the same reset through individual `setKey` calls is
     * the idiomatic store pattern and keeps the contract uniform across
     * actions — every other reset (`setRegionForced`, etc.) does it this
     * way already.
     */
    resetForFreshStart() {
        this.$session.setKey('_restoredFromCheckpoint', false);
        this.$session.setKey('introConcluded', false);
        this.$session.setKey('detectionComplete', false);
        this.$session.setKey('detectedFrameworkLabel', null);
        this.$session.setKey('integration', null);
        this.$session.setKey('frameworkConfig', null);
        this.$session.setKey('frameworkContext', {});
        this.$session.setKey('region', null);
        this.$session.setKey('selectedOrgId', null);
        this.$session.setKey('selectedOrgName', null);
        this.$session.setKey('selectedProjectId', null);
        this.$session.setKey('selectedProjectName', null);
        this.$session.setKey('selectedEnvName', null);
        this.emitChange();
    }
    /**
     * Rewind to Intro (Welcome). Used when Esc means “step back before region /
     * signup” — not for /region mid-session (`regionForced`), where tearing down
     * the spine would surprise users switching DC.
     */
    backToWelcome() {
        if (this.session.regionForced) {
            return;
        }
        // Same navigation contract as rewindIntro(): Esc-back must animate as a
        // pop — emitChange() would force 'push' and DissolveTransition slides the
        // wrong way (Bugbot: backToWelcome transition direction).
        if (!this._reverting) {
            this.router._setDirection('pop');
        }
        this.$session.setKey('introConcluded', false);
        this.$session.setKey('region', null);
        if (isCreateAccountOnboarding(this.session)) {
            this.$session.setKey('emailCaptureComplete', false);
            this.$session.setKey('tosAccepted', null);
            this.$session.setKey('signupEmail', null);
            this.$session.setKey('signupFullName', null);
            this.$session.setKey('signupTokensObtained', false);
            this.$session.setKey('signupMagicLinkUrl', null);
            this.$session.setKey('pendingAuthAccessToken', null);
            this.$session.setKey('pendingAuthIdToken', null);
            this.$session.setKey('pendingOrgs', null);
        }
        analytics.wizardCapture('back navigation', { to: 'welcome' });
        this.$version.set(this.$version.get() + 1);
        if (!this._reverting) {
            this._detectTransition();
        }
    }
    _retryResolve = null;
    setScreenError(error) {
        this.$screenError.set(error);
        this.$version.set(this.$version.get() + 1);
    }
    clearScreenError() {
        this.$screenError.set(null);
        this.$screenErrorRetry.set(this.$screenErrorRetry.get() + 1);
        this.$version.set(this.$version.get() + 1);
        this._retryResolve?.();
        this._retryResolve = null;
    }
    /**
     * Returns a Promise that resolves the next time clearScreenError() is called
     * (i.e. the user presses R). Used by InkUI.setRunError to block until retry.
     */
    waitForRetry() {
        return new Promise((resolve) => {
            this._retryResolve = resolve;
        });
    }
    /** Show a confirmation prompt. Resolves with true (yes) or false (no / skipped). */
    promptConfirm(message) {
        return new Promise((resolve) => {
            this.$pendingPrompt.set({ kind: 'confirm', message, resolve });
            this.$version.set(this.$version.get() + 1);
        });
    }
    /** Show a multiple-choice prompt. Resolves with the chosen option or empty string if skipped. */
    promptChoice(message, options) {
        return new Promise((resolve) => {
            this.$pendingPrompt.set({ kind: 'choice', message, options, resolve });
            this.$version.set(this.$version.get() + 1);
        });
    }
    /** Resolve a confirm or choice pending prompt. */
    resolvePrompt(answer) {
        const prompt = this.$pendingPrompt.get();
        if (!prompt || prompt.kind === 'event-plan')
            return;
        analytics.wizardCapture('prompt response', {
            'prompt kind': prompt.kind,
            response: String(answer),
        });
        this.$pendingPrompt.set(null);
        this.$version.set(this.$version.get() + 1);
        if (prompt.kind === 'confirm') {
            prompt.resolve(answer);
        }
        else {
            prompt.resolve(answer);
        }
    }
    /** Show an event-plan confirmation. Resolves when the user approves, skips, or gives feedback. */
    promptEventPlan(events) {
        return new Promise((resolve) => {
            this.$pendingPrompt.set({ kind: 'event-plan', events, resolve });
            this.$version.set(this.$version.get() + 1);
        });
    }
    /** Resolve the pending event-plan prompt. */
    resolveEventPlan(decision) {
        const prompt = this.$pendingPrompt.get();
        if (!prompt || prompt.kind !== 'event-plan')
            return;
        analytics.wizardCapture('prompt response', {
            'prompt kind': 'event-plan',
            response: typeof decision === 'object' ? 'feedback' : String(decision),
        });
        this.$pendingPrompt.set(null);
        this.$version.set(this.$version.get() + 1);
        prompt.resolve(decision);
    }
    /** Enter or exit slash command mode. */
    setCommandMode(active) {
        this.$commandMode.set(active);
        this.$version.set(this.$version.get() + 1);
    }
    showSnakeOverlay() {
        this.pushOverlay(Overlay.Snake);
    }
    hideSnakeOverlay() {
        this.popOverlay();
    }
    showMcpOverlay() {
        this.pushOverlay(Overlay.Mcp);
    }
    hideMcpOverlay() {
        this.popOverlay();
    }
    showSlackOverlay() {
        this.pushOverlay(Overlay.Slack);
    }
    hideSlackOverlay() {
        this.popOverlay();
    }
    showLogoutOverlay() {
        // Set the loggingOut flag SYNCHRONOUSLY before pushing the overlay so the
        // bin.ts re-auth watcher can't race the overlay push and observe a state
        // where credentials are null but currentScreen is not yet Overlay.Logout
        // (which would trigger an unwanted runOAuthCycle and pop the browser).
        this.$session.setKey('loggingOut', true);
        this.pushOverlay(Overlay.Logout);
    }
    hideLogoutOverlay() {
        // Clearing loggingOut here covers the cancel path (user dismissed the
        // confirm prompt). On the confirm path, process.exit(0) tears the
        // process down before this matters.
        this.$session.setKey('loggingOut', false);
        this.popOverlay();
    }
    showLoginOverlay() {
        this.pushOverlay(Overlay.Login);
    }
    hideLoginOverlay() {
        this.popOverlay();
    }
    /** Update just the access token in credentials without triggering auth analytics. */
    updateAccessToken(accessToken) {
        const creds = this.$session.get().credentials;
        if (!creds)
            return;
        this.$session.setKey('credentials', { ...creds, accessToken });
        this.$version.set(this.$version.get() + 1);
    }
    /** Update both access and id tokens (e.g. after a silent OAuth refresh). */
    updateTokens(accessToken, idToken) {
        const creds = this.$session.get().credentials;
        if (!creds)
            return;
        this.$session.setKey('credentials', { ...creds, accessToken, idToken });
        this.$version.set(this.$version.get() + 1);
    }
    /**
     * Restore org/project/app session IDs that weren't populated at startup
     * (e.g. because the fire-and-forget fetchAmplitudeUser failed due to expired token).
     * Only updates fields that are provided.
     */
    restoreSessionIds(fields) {
        if (fields.orgId !== undefined)
            // Mirror setOrgAndWorkspace: collapse '' -> null so isAuthenticated
            // doesn't treat an empty org id as a real one.
            this.$session.setKey('selectedOrgId', fields.orgId || null);
        if (fields.orgName !== undefined)
            this.$session.setKey('selectedOrgName', fields.orgName);
        if (fields.projectId !== undefined)
            // Mirror setOrgAndProject: collapse empty strings to null so an
            // accidental '' from a caller is treated as "no project" rather than
            // a real ID. No current caller passes empty (CreateProjectScreen
            // omits projectId, DataIngestionCheckScreen reads from API
            // responses), but keeping the guard consistent across both write
            // paths prevents future regressions.
            this.$session.setKey('selectedProjectId', fields.projectId || null);
        if (fields.projectName !== undefined)
            this.$session.setKey('selectedProjectName', fields.projectName);
        if (fields.appId !== undefined)
            this.$session.setKey('selectedAppId', fields.appId);
        this.emitChange();
    }
    /** Request the TabContainer to switch to a tab by id. Clears after consumption. */
    setRequestedTab(id) {
        this.$requestedTab.set(id);
        this.$version.set(this.$version.get() + 1);
    }
    clearRequestedTab() {
        this.$requestedTab.set(null);
    }
    /** Show a transient feedback message in the command bar. Clears after ms. */
    setCommandFeedback(message, ms = 3000) {
        if (this._feedbackTimer !== null) {
            clearTimeout(this._feedbackTimer);
            this._feedbackTimer = null;
        }
        this.$commandFeedback.set(message);
        this.$version.set(this.$version.get() + 1);
        this._feedbackTimer = setTimeout(() => {
            this._feedbackTimer = null;
            this.$commandFeedback.set(null);
            this.$version.set(this.$version.get() + 1);
        }, ms);
    }
    setProjectHasData(value) {
        this.$session.setKey('projectHasData', value);
        this.emitChange();
    }
    setActivationLevel(level) {
        this.$session.setKey('activationLevel', level);
        // Keep projectHasData in sync so existing routing still works
        this.$session.setKey('projectHasData', level === 'full' ? true : false);
        this.emitChange();
    }
    /**
     * Mark this session as having a complete prior install detected on
     * disk — set when the Activation pre-flight finds all four local
     * signals (SDK dep, source import, ampli.json scope, event plan).
     * Pairs with `activationLevel = 'full'` to short-circuit Setup + Run
     * while keeping DataIngestionCheck running so the user can verify
     * events arrive after a re-deploy.
     */
    setLocalInstrumentationComplete(value) {
        this.$session.setKey('localInstrumentationComplete', value);
        this.emitChange();
    }
    setSnippetConfigured(value) {
        this.$session.setKey('snippetConfigured', value);
        this.emitChange();
    }
    setDataIngestionConfirmed() {
        this.$session.setKey('dataIngestionConfirmed', true);
        analytics.wizardCapture('data ingestion confirmed', sessionPropertiesCompact(this.session));
        this.emitChange();
    }
    setChecklistDashboardUrl(url) {
        this.$session.setKey('checklistDashboardUrl', url);
        this.emitChange();
    }
    setSignupMagicLinkUrl(url) {
        this.$session.setKey('signupMagicLinkUrl', url);
        this.emitChange();
    }
    setActivationOptionsComplete() {
        this.$session.setKey('activationOptionsComplete', true);
        this.emitChange();
    }
    /**
     * Called from bin.ts when OAuth completes (browser redirect done).
     * Stores auth tokens + org list so AuthScreen can show the SUSI pickers.
     * Also sets region from the detected cloud zone.
     */
    setOAuthComplete(data) {
        // Wipe any cached project API key for this install dir before the
        // AuthScreen resolution runs. Without this, a stale key from a prior
        // run in a different org silently wins over the freshly-fetched env
        // key in `data.orgs`, and the agent inlines the wrong key.
        clearApiKey(this.session.installDir);
        this.$session.setKey('pendingAuthAccessToken', data.accessToken);
        this.$session.setKey('pendingAuthIdToken', data.idToken);
        this.$session.setKey('pendingOrgs', data.orgs);
        // Auto-set region — skips RegionSelect for users whose zone is detected.
        // OAuth-derived zone is intent-equivalent per the write invariant on the
        // region field (see src/lib/wizard-session.ts).
        if (data.cloudRegion) {
            this.$session.setKey('region', data.cloudRegion);
        }
        this.emitChange();
    }
    /** Replace the cached org list (used by Start Over to pick up newly-created projects). */
    setPendingOrgs(orgs) {
        this.$session.setKey('pendingOrgs', orgs);
        this.emitChange();
    }
    /**
     * Enter the create-project flow. Sets `session.createProject.pending = true`
     * so the router resolves to CreateProjectScreen.
     *
     * @param source which picker or invocation triggered creation
     * @param suggestedName optional pre-filled name (e.g. from /create-project <name> or --project-name)
     */
    startCreateProject(source, suggestedName) {
        this.$session.setKey('createProject', {
            pending: true,
            source,
            suggestedName: suggestedName ?? null,
        });
        analytics.wizardCapture('Create Project Started', { source });
        this.emitChange();
    }
    /** Exit the create-project flow without creating a project. */
    cancelCreateProject() {
        this.$session.setKey('createProject', {
            pending: false,
            source: null,
            suggestedName: null,
        });
        analytics.wizardCapture('Create Project Cancelled', {});
        this.emitChange();
    }
    /**
     * Finish the create-project flow successfully. Clears the pending flag
     * — the caller is responsible for calling `setCredentials()` with the
     * returned apiKey so the rest of the auth flow stays consistent.
     */
    completeCreateProject() {
        this.$session.setKey('createProject', {
            pending: false,
            source: null,
            suggestedName: null,
        });
        this.emitChange();
    }
    /**
     * Called from AuthScreen when org + project selection changes.
     * Records org/project on the session, and (when `persist` is true)
     * writes the IDs to the project's ampli.json.
     *
     * Pass `persist: false` from synthesisers that only mirror existing state
     * (e.g. the auto-resolve effect that reflects values already loaded from
     * ampli.json) — those code paths shouldn't trigger fresh disk writes
     * during render. User-driven flows (picker selection, "start over",
     * "create project") leave `persist` at its default of `true` so the
     * config file stays in sync with what the user picked.
     */
    setOrgAndProject(org, project, installDir, options = {}) {
        const { persist = true } = options;
        // Callers (e.g. AuthScreen "Start Over", stale-org clear, create-project
        // fallback) pass `{ id: '', name: '' }` to reset session state. Collapse
        // empty IDs to null so `isAuthenticated` and downstream truthy checks
        // treat them as "not selected" rather than as real values.
        this.$session.setKey('selectedOrgId', org.id || null);
        this.$session.setKey('selectedOrgName', org.name);
        this.$session.setKey('selectedProjectId', project.id || null);
        this.$session.setKey('selectedProjectName', project.name);
        // Extract the Amplitude app ID (and api key, when present) from the
        // lowest-rank environment.
        const lowestEnv = project.environments
            ?.slice()
            .sort((a, b) => a.rank - b.rank)
            .find((e) => e.app?.id);
        const appId = lowestEnv?.app?.id ?? null;
        const apiKey = lowestEnv?.app?.apiKey ?? null;
        this.$session.setKey('selectedAppId', appId);
        // Keep credentials.appId / projectApiKey in sync with the freshly
        // picked project. Without this, /whoami and any other reader that
        // looks at credentials.* keeps showing the appId that was bound at
        // initial OAuth (the user's first/old project) — even after they
        // switched projects mid-session.
        const currentCreds = this.$session.get().credentials;
        if (currentCreds) {
            this.$session.setKey('credentials', {
                ...currentCreds,
                appId: toCredentialAppId(appId),
                projectApiKey: apiKey ?? currentCreds.projectApiKey,
            });
        }
        if (persist) {
            // Write ampli.json to the project directory.
            void import('../../lib/ampli-config.js').then(({ writeAmpliConfig }) => {
                // readDisk: true — invoked from store mutation paths where the
                // RegionSelect invariant isn't guaranteed (e.g. checkpoint restore).
                const zone = resolveZone(this.$session.get(), DEFAULT_AMPLITUDE_ZONE, {
                    readDisk: true,
                });
                writeAmpliConfig(installDir, {
                    OrgId: org.id,
                    ProjectId: project.id,
                    Zone: zone,
                });
            });
        }
        this.emitChange();
    }
    setServiceStatus(status) {
        this.$session.setKey('serviceStatus', status);
        this.emitChange();
    }
    setRetryState(state) {
        this.$session.setKey('retryState', state);
        this.emitChange();
    }
    addDiscoveredFeature(feature) {
        if (!this.session.discoveredFeatures.includes(feature)) {
            this.$session.setKey('discoveredFeatures', [
                ...this.session.discoveredFeatures,
                feature,
            ]);
            this.emitChange();
        }
    }
    /**
     * Auto-enable every opt-in addon (Session Replay, Guides & Surveys,
     * LLM when the feature flag is on) that's been discovered for the
     * current integration. Routes through `enableFeature` so React
     * subscribers get notified and per-feature analytics fire.
     *
     * No opt-in picker — the unified browser SDK ships with all three
     * addons in one package. Quota / privacy concerns are surfaced via
     * per-option inline comments in the agent-generated init code, which
     * users can comment out individually — a clearer opt-out surface
     * than a one-shot picker.
     */
    autoEnableInlineAddons(source = 'auto-tui') {
        for (const feature of this.session.discoveredFeatures) {
            let additional = null;
            if (feature === 'session_replay') {
                additional = AdditionalFeature.SessionReplay;
            }
            else if (feature === 'llm') {
                additional = AdditionalFeature.LLM;
            }
            else if (feature === 'engagement') {
                additional = AdditionalFeature.Engagement;
            }
            if (!additional)
                continue;
            this.enableFeature(additional, source);
        }
        this.$session.setKey('optInFeaturesComplete', true);
        this.emitChange();
    }
    /**
     * Set the additional feature currently being processed by the stop hook.
     * Used by the Run screen to render it as an in-progress task.
     */
    setCurrentFeature(feature) {
        this.$session.setKey('additionalFeatureCurrent', feature);
        this.emitChange();
    }
    /**
     * Mark a feature as completed by the stop hook (clears `current`).
     * Used by the Run screen to render it as a done task.
     */
    markFeatureComplete(feature) {
        if (!this.session.additionalFeatureCompleted.includes(feature)) {
            this.$session.setKey('additionalFeatureCompleted', [
                ...this.session.additionalFeatureCompleted,
                feature,
            ]);
        }
        if (this.session.additionalFeatureCurrent === feature) {
            this.$session.setKey('additionalFeatureCurrent', null);
        }
        this.emitChange();
    }
    /**
     * Enable an additional feature: enqueue it for the stop hook
     * and set any feature-specific session flags.
     * Respects Amplitude Experiment feature flags — if the corresponding
     * flag is off the feature is silently skipped.
     */
    enableFeature(feature, source = 'picklist') {
        // Gate LLM analytics behind the wizard-llm-analytics feature flag
        if (feature === AdditionalFeature.LLM) {
            if (!analytics.isFeatureFlagEnabled(FLAG_LLM_ANALYTICS)) {
                return;
            }
        }
        if (!this.session.additionalFeatureQueue.includes(feature)) {
            this.$session.setKey('additionalFeatureQueue', [
                ...this.session.additionalFeatureQueue,
                feature,
            ]);
        }
        // Feature-specific flags
        if (feature === AdditionalFeature.LLM) {
            this.$session.setKey('llmOptIn', true);
        }
        if (feature === AdditionalFeature.SessionReplay) {
            this.$session.setKey('sessionReplayOptIn', true);
        }
        if (feature === AdditionalFeature.Engagement) {
            this.$session.setKey('engagementOptIn', true);
        }
        analytics.wizardCapture('feature enabled', { feature, source });
        this.emitChange();
    }
    setAmplitudePreDetected() {
        this.$session.setKey('amplitudePreDetected', true);
        this.$session.setKey('amplitudePreDetectedChoicePending', true);
        this.emitChange();
    }
    /**
     * Blocks bin.ts until McpScreen resolves via resolvePreDetectedChoice().
     */
    waitForPreDetectedChoice() {
        return new Promise((resolve) => {
            this._preDetectedChoiceResolver = resolve;
        });
    }
    /**
     * Called from McpScreen when the user chooses to skip the agent (continue to
     * MCP) or run the full setup wizard anyway.
     */
    resolvePreDetectedChoice(runWizardAnyway) {
        const resolveFn = this._preDetectedChoiceResolver;
        this._preDetectedChoiceResolver = null;
        if (!runWizardAnyway) {
            this.$session.setKey('amplitudePreDetectedChoicePending', false);
        }
        this.emitChange();
        resolveFn?.(runWizardAnyway);
    }
    /**
     * Undo the pre-detection fast-path so runWizard can run; used when the user
     * opts into the setup agent after Amplitude was already found in the project.
     */
    resetForAgentAfterPreDetected() {
        this.$session.setKey('amplitudePreDetected', false);
        this.$session.setKey('amplitudePreDetectedChoicePending', false);
        this.$session.setKey('runPhase', RunPhase.Idle);
        this.emitChange();
    }
    setMcpComplete(outcome = McpOutcome.Skipped, installedClients = []) {
        this.$session.setKey('mcpComplete', true);
        this.$session.setKey('mcpOutcome', outcome);
        this.$session.setKey('mcpInstalledClients', installedClients);
        analytics.wizardCapture('mcp complete', {
            'mcp outcome': outcome,
            'mcp installed clients': installedClients,
            ...sessionPropertiesCompact(this.session),
        });
        this.emitChange();
    }
    setSlackComplete(outcome = SlackOutcome.Skipped) {
        this.$session.setKey('slackComplete', true);
        this.$session.setKey('slackOutcome', outcome);
        analytics.wizardCapture('slack complete', {
            'slack outcome': outcome,
            ...sessionPropertiesCompact(this.session),
        });
        this.emitChange();
    }
    setOutroData(data) {
        this.$session.setKey('outroData', data);
        analytics.wizardCapture('outro reached', { 'outro kind': data.kind });
        this.emitChange();
    }
    /**
     * One-shot signal that the OutroScreen has been dismissed by the user
     * (keypress on cancel/error, picker action on success). Used by
     * `wizardAbort` to wait for the Outro to render and be acknowledged
     * before calling `process.exit` — without this, the process exits
     * while Ink is still flushing the previous frame and the user never
     * sees the OutroScreen at all on error paths.
     *
     * Resolves at most once. Subsequent calls to `signalOutroDismissed`
     * are no-ops; subsequent awaiters get a fresh pending promise that
     * will resolve on the next dismissal (in practice there's only ever
     * one dismissal per process).
     */
    _resolveOutroDismissed = null;
    _outroDismissedPromise = null;
    /** Returns a promise that resolves when the OutroScreen is dismissed. */
    outroDismissed() {
        if (!this._outroDismissedPromise) {
            this._outroDismissedPromise = new Promise((resolve) => {
                this._resolveOutroDismissed = resolve;
            });
        }
        return this._outroDismissedPromise;
    }
    /** Mark the outro as dismissed. Idempotent. */
    signalOutroDismissed() {
        if (this._resolveOutroDismissed) {
            this._resolveOutroDismissed();
            this._resolveOutroDismissed = null;
        }
        else if (!this._outroDismissedPromise) {
            // Dismissal arrived before anyone awaited it — pre-resolve so the
            // first awaiter gets a settled promise immediately.
            this._outroDismissedPromise = Promise.resolve();
        }
    }
    setFrameworkContext(key, value, autoDetected = false) {
        const ctx = { ...this.$session.get().frameworkContext, [key]: value };
        this.$session.setKey('frameworkContext', ctx);
        if (!autoDetected) {
            // Only track user-answered keys so back-nav can distinguish them
            // from auto-detected entries. popLastFrameworkContextAnswer returns
            // false when no user answers remain, letting the router walk back
            // past Setup transparently.
            const order = this.$session.get().frameworkContextAnswerOrder;
            const next = order.filter((k) => k !== key).concat(key);
            this.$session.setKey('frameworkContextAnswerOrder', next);
        }
        this.emitChange();
    }
    /**
     * Pop the most recently-answered framework setup question from
     * `frameworkContext`. Used by SetupScreen and by back-navigation past
     * FeatureOptIn. Returns `true` if an answer was popped.
     */
    popLastFrameworkContextAnswer() {
        const session = this.$session.get();
        const order = session.frameworkContextAnswerOrder;
        if (order.length === 0)
            return false;
        const lastKey = order[order.length - 1];
        const { [lastKey]: _removed, ...rest } = session.frameworkContext;
        void _removed;
        this.$session.setKey('frameworkContext', rest);
        this.$session.setKey('frameworkContextAnswerOrder', order.slice(0, -1));
        // Setup is a pre-Run flow entry; if the user's stepping back into Setup
        // from a post-run state (e.g. via /restart or repeat-run paths) we must
        // not leave the router short-circuited past Run.
        this.clearPostRunStateForBackNav();
        this.emitChange();
        return true;
    }
    // ── Back-navigation reverts ────────────────────────────────────
    // Each helper here un-completes one flow entry. They're invoked via
    // FlowEntry.revert callbacks from flows.ts during goBack().
    /**
     * Clear post-Run state so a back-nav into pre-Run territory doesn't
     * leave the router short-circuited past the agent run / outro.
     *
     * Called by every reset helper that lands the user on a screen *before*
     * the Run entry. Without this, after a back-nav the router would see
     * `runPhase === Completed` and skip Run (and any post-run flow entries
     * with completed isComplete predicates), routing the user straight to
     * stale post-run state.
     *
     * `outroData` is also cleared because the OutroKind.Cancel branch in
     * router.resolve() jumps directly to Outro regardless of pipeline order.
     *
     * No-op when there's nothing to clear, so calling it is safe whether or
     * not the run actually started.
     */
    clearPostRunStateForBackNav() {
        this.$session.setKey('runPhase', RunPhase.Idle);
        this.$session.setKey('runStartedAt', null);
        this.$session.setKey('outroData', null);
        this.$session.setKey('mcpComplete', false);
        this.$session.setKey('mcpOutcome', null);
        this.$session.setKey('mcpInstalledClients', []);
        this.$session.setKey('slackComplete', false);
        this.$session.setKey('slackOutcome', null);
        this.$session.setKey('dataIngestionConfirmed', false);
        this.$session.setKey('optInFeaturesComplete', false);
        this.$session.setKey('additionalFeatureQueue', []);
        this.$session.setKey('additionalFeatureCurrent', null);
        this.$session.setKey('additionalFeatureCompleted', []);
    }
    /**
     * Revert past the Auth step back to RegionSelect. Region affects the
     * OAuth host, so we drop pending tokens, the cached org list, and any
     * resolved credentials so the next pass actually re-authenticates.
     */
    resetAuthForRegionChange() {
        this.$session.setKey('region', null);
        this.$session.setKey('regionForced', true);
        this.$session.setKey('credentials', null);
        this.$session.setKey('pendingAuthAccessToken', null);
        this.$session.setKey('pendingAuthIdToken', null);
        this.$session.setKey('pendingOrgs', null);
        this.$session.setKey('selectedOrgId', null);
        this.$session.setKey('selectedOrgName', null);
        this.$session.setKey('selectedProjectId', null);
        this.$session.setKey('selectedProjectName', null);
        this.$session.setKey('selectedAppId', null);
        this.$session.setKey('selectedEnvName', null);
        this.$session.setKey('projectHasData', null);
        this.clearPostRunStateForBackNav();
        analytics.wizardCapture('back navigation', { from: 'auth', to: 'region' });
        this.emitChange();
    }
    /**
     * Confirm the silently-resolved org/project on a returning run.
     * Clears `requiresAccountConfirmation` so the Auth flow gate advances.
     */
    confirmAccount() {
        this.$session.setKey('requiresAccountConfirmation', false);
        analytics.wizardCapture('account confirmed', {
            'from screen': 'auth',
            'org id': this.session.selectedOrgId,
            'project id': this.session.selectedProjectId,
        });
        this.emitChange();
    }
    /**
     * Reject the silently-resolved org/project on a returning run and drop
     * back to the SUSI picker. Drops the cached selection AND credentials
     * so AuthScreen re-runs the OAuth-to-picker pipeline; the cached idToken
     * is still used (no fresh browser round-trip required) but the user can
     * pick a different org / project / environment this time.
     */
    rejectStoredAccount() {
        this.$session.setKey('requiresAccountConfirmation', false);
        this.$session.setKey('credentials', null);
        this.$session.setKey('selectedOrgId', null);
        this.$session.setKey('selectedOrgName', null);
        this.$session.setKey('selectedProjectId', null);
        this.$session.setKey('selectedProjectName', null);
        this.$session.setKey('selectedAppId', null);
        this.$session.setKey('selectedEnvName', null);
        this.$session.setKey('projectHasData', null);
        analytics.wizardCapture('account rejected', { 'from screen': 'auth' });
        this.emitChange();
    }
    /**
     * Wipe in-memory auth state after the user confirmed /logout (disk
     * credentials are cleared by LogoutScreen itself). Routes through
     * `setKey` so per-key listeners (`listenKeys`) fire — direct
     * `store.session.X = null` assignments only notify version-bumped
     * subscribers and silently miss key-scoped subscriptions.
     */
    clearAuthForLogout() {
        this.$session.setKey('credentials', null);
        this.$session.setKey('userEmail', null);
        this.$session.setKey('selectedOrgId', null);
        this.$session.setKey('selectedOrgName', null);
        this.$session.setKey('selectedProjectId', null);
        this.$session.setKey('selectedProjectName', null);
        this.$session.setKey('selectedAppId', null);
        this.$session.setKey('selectedEnvName', null);
        this.$session.setKey('projectHasData', null);
        this.emitChange();
    }
    /**
     * Revert past the Auth step back into the org/workspace picker. Keeps
     * credentials so we don't force a fresh OAuth round-trip — only the
     * picked identity is cleared.
     */
    clearOrgAndProjectSelection() {
        this.$session.setKey('selectedOrgId', null);
        this.$session.setKey('selectedOrgName', null);
        this.$session.setKey('selectedProjectId', null);
        this.$session.setKey('selectedProjectName', null);
        this.$session.setKey('selectedAppId', null);
        this.$session.setKey('selectedEnvName', null);
        this.$session.setKey('projectHasData', null);
        this.clearPostRunStateForBackNav();
        analytics.wizardCapture('back navigation', {
            from: 'data-setup',
            to: 'auth',
        });
        this.emitChange();
    }
    /** Re-run the activation check on the next visit to DataSetup. */
    resetActivationCheck() {
        this.$session.setKey('projectHasData', null);
        this.$session.setKey('activationLevel', 'none');
        this.$session.setKey('activationOptionsComplete', false);
        this.$session.setKey('localInstrumentationComplete', false);
        this.clearPostRunStateForBackNav();
        analytics.wizardCapture('back navigation', { to: 'data-setup' });
        this.emitChange();
    }
    /** Re-show the activation-options picker. */
    resetActivationOptions() {
        this.$session.setKey('activationOptionsComplete', false);
        this.clearPostRunStateForBackNav();
        analytics.wizardCapture('back navigation', { to: 'activation-options' });
        this.emitChange();
    }
    /** Re-show the feature opt-in picklist. */
    resetFeatureOptIn() {
        // Note: clearPostRunStateForBackNav also clears optInFeaturesComplete,
        // but we keep the explicit set above for clarity since this method's
        // primary purpose is reverting that flag.
        this.$session.setKey('optInFeaturesComplete', false);
        this.clearPostRunStateForBackNav();
        analytics.wizardCapture('back navigation', { to: 'feature-opt-in' });
        this.emitChange();
    }
    /** Re-show the MCP install picker. */
    resetMcp() {
        this.$session.setKey('mcpComplete', false);
        this.$session.setKey('mcpOutcome', null);
        this.$session.setKey('mcpInstalledClients', []);
        analytics.wizardCapture('back navigation', { to: 'mcp' });
        this.emitChange();
    }
    /** Re-enter the wait-for-events screen. */
    resetDataIngestion() {
        this.$session.setKey('dataIngestionConfirmed', false);
        analytics.wizardCapture('back navigation', { to: 'data-ingestion' });
        this.emitChange();
    }
    /** Re-show the Slack setup prompt. */
    resetSlack() {
        this.$session.setKey('slackComplete', false);
        this.$session.setKey('slackOutcome', null);
        analytics.wizardCapture('back navigation', { to: 'slack' });
        this.emitChange();
    }
    // ── Derived state ───────────────────────────────────────────────
    /**
     * The screen that should be rendered right now.
     * Derived from session state via the router.
     */
    get currentScreen() {
        return this.router.resolve(this.session);
    }
    /** Direction hint for screen transitions. */
    get lastNavDirection() {
        return this.router.lastNavDirection;
    }
    /** Whether the user can step back from the current screen. */
    canGoBack() {
        return this.router.canGoBack(this.session);
    }
    /**
     * Step back to the previous decision. Returns true if a revert fired.
     * No-ops (returns false) when the active screen is a back-stop, has no
     * prior revertible step, or an overlay is currently active.
     */
    goBack() {
        // Suppress direction/transition in emitChange during reverts so the
        // single _detectTransition below fires with the correct 'pop' direction.
        this._reverting = true;
        const ok = this.router.goBack(this.session, this);
        this._reverting = false;
        if (!ok)
            return false;
        // router already flipped direction to 'pop'. Bump version + run the
        // transition hooks so React + analytics observe the move.
        this.$version.set(this.$version.get() + 1);
        this._detectTransition();
        return true;
    }
    /** True while a revert callback is executing inside goBack(). */
    _reverting = false;
    // ── Change notification ─────────────────────────────────────────
    getVersion() {
        return this.$version.get();
    }
    /**
     * Notify React that state has changed.
     * The router re-resolves the active screen on next render.
     */
    emitChange() {
        if (!this._reverting) {
            this.router._setDirection('push');
        }
        this.$version.set(this.$version.get() + 1);
        if (!this._reverting) {
            this._detectTransition();
        }
    }
    // ── Overlay navigation ──────────────────────────────────────────
    pushOverlay(overlay) {
        this.router._setDirection('push');
        this.router.pushOverlay(overlay);
        this.$version.set(this.$version.get() + 1);
        this._detectTransition();
    }
    popOverlay() {
        this.router._setDirection('pop');
        this.router.popOverlay();
        this.$version.set(this.$version.get() + 1);
        this._detectTransition();
    }
    // ── Screen transition analytics ─────────────────────────────────
    /**
     * Register a callback to run when transitioning onto the given screen.
     * Fires after every transition that lands on this screen.
     */
    onEnterScreen(screen, fn) {
        const list = this._enterScreenHooks.get(screen) ?? [];
        list.push(fn);
        this._enterScreenHooks.set(screen, list);
    }
    /**
     * Detect screen transitions, run enter-screen hooks, and fire analytics.
     * Called at the end of emitChange/pushOverlay/popOverlay.
     */
    _detectTransition() {
        const next = this.router.resolve(this.session);
        const prev = this._lastScreen;
        // Update _lastScreen before invoking hooks so re-entrant emitChange calls
        // (e.g. completeSetup inside an onEnterScreen hook) see prev === next and
        // don't re-fire the same hooks, preventing infinite recursion.
        this._lastScreen = next;
        if (prev !== null && next !== prev) {
            const hooks = this._enterScreenHooks.get(next);
            if (hooks) {
                for (const fn of hooks)
                    fn();
            }
            analytics.wizardCapture('wizard screen entered', {
                'screen name': next,
                'previous screen': prev,
                ...sessionPropertiesCompact(this.session),
            });
        }
    }
    // ── Agent observation state ─────────────────────────────────────
    pushStatus(message) {
        this.$statusMessages.set([...this.$statusMessages.get(), message]);
        this.emitChange();
    }
    setTasks(tasks) {
        this.$tasks.set(tasks);
        this.emitChange();
    }
    updateTask(index, done) {
        const tasks = this.$tasks.get();
        if (tasks[index]) {
            const updated = [...tasks];
            updated[index] = {
                ...updated[index],
                done,
                status: done ? TaskStatus.Completed : TaskStatus.Pending,
            };
            this.$tasks.set(updated);
            this.emitChange();
        }
    }
    setEventPlan(events) {
        this.$eventPlan.set(events);
        this.emitChange();
    }
    /**
     * Cap on how many file-write rows we retain before evicting the oldest.
     * The inner agent occasionally touches dozens of files (skill installs,
     * lint passes, multi-step refactors) — keeping all of them alive in
     * the TUI would blow our render budget on small terminals. Recent
     * writes are the user-relevant signal; keep the tail.
     */
    static MAX_FILE_WRITES = 50;
    /**
     * Append (or restart) a file write row from a `recordFileChangePlanned`
     * call. If the same absolute path is already in `'planned'` state, we
     * collapse the duplicate (MultiEdit fans out per file but the agent's
     * PreToolUse fires once per tool invocation — defensive).
     */
    recordFileChangePlanned(data) {
        const now = Date.now();
        const next = {
            path: data.path,
            operation: data.operation,
            status: 'planned',
            startedAt: now,
        };
        const existing = this.$fileWrites.get();
        // Collapse a back-to-back duplicate (same path, still planned). Otherwise
        // append. Eviction at MAX_FILE_WRITES keeps the array bounded.
        const dedupedTail = existing.length > 0 &&
            existing[existing.length - 1].path === data.path &&
            existing[existing.length - 1].status === 'planned'
            ? existing.slice(0, -1)
            : existing;
        const appended = [...dedupedTail, next];
        const bounded = appended.length > WizardStore.MAX_FILE_WRITES
            ? appended.slice(appended.length - WizardStore.MAX_FILE_WRITES)
            : appended;
        this.$fileWrites.set(bounded);
        this.$fileWritesTotal.set(this.$fileWritesTotal.get() + 1);
        this.emitChange();
    }
    /**
     * Mark the most recent matching planned row as applied. We match the
     * most recent occurrence (in case the same file is rewritten later in
     * the run). If no planned row exists (the planned event was lost or the
     * applied event arrived first), append a synthesized row that's
     * already-completed — better to surface the write than silently drop
     * it.
     */
    recordFileChangeApplied(data) {
        const now = Date.now();
        const existing = this.$fileWrites.get();
        for (let i = existing.length - 1; i >= 0; i--) {
            if (existing[i].path === data.path && existing[i].status === 'planned') {
                const updated = [...existing];
                updated[i] = {
                    ...existing[i],
                    status: 'applied',
                    completedAt: now,
                    bytes: data.bytes,
                };
                this.$fileWrites.set(updated);
                this.emitChange();
                return;
            }
        }
        // No matching planned row — synthesize a completed entry.
        const synthesized = {
            path: data.path,
            operation: data.operation,
            status: 'applied',
            startedAt: now,
            completedAt: now,
            bytes: data.bytes,
        };
        const appended = [...existing, synthesized];
        const bounded = appended.length > WizardStore.MAX_FILE_WRITES
            ? appended.slice(appended.length - WizardStore.MAX_FILE_WRITES)
            : appended;
        this.$fileWrites.set(bounded);
        this.$fileWritesTotal.set(this.$fileWritesTotal.get() + 1);
        this.emitChange();
    }
    /**
     * Apply a deterministic journey transition derived from a tool call
     * (see `src/lib/journey-state.ts`). Status of the named step is set to
     * the incoming value with a monotonic guard — once a step has reached
     * `completed`, no later transition demotes it. Sequential-ordering
     * cascade (a later step advancing implies all earlier ones are done)
     * is applied at render time.
     *
     * This is the source of truth for the canonical 5-step UI status.
     * `syncTodos` only refreshes per-step `activeForm` flavor text; it
     * never writes status here.
     */
    applyJourneyTransition(stepId, status) {
        const prev = this.$derivedJourney.get();
        const current = prev[stepId];
        // Monotonic guard — once completed, can't regress to in_progress.
        if (current === 'completed' && status !== 'completed')
            return;
        if (current === status)
            return;
        this.$derivedJourney.set({ ...prev, [stepId]: status });
        this.renderJourneyTasks();
        this.emitChange();
    }
    /**
     * Refresh per-step `activeForm` text from the agent's `TodoWrite`
     * output. Status is owned by `applyJourneyTransition`; this method
     * never touches it. Incoming todos that don't exactly match a
     * canonical label are dropped.
     *
     * Safe fallback: when no journey transition has fired yet (early in a
     * run, or in tests that exercise syncTodos directly), the renderer
     * still seeds the canonical 5 rows as `pending` so the user-visible
     * list is never empty.
     */
    syncTodos(todos) {
        const next = {
            ...this.$journeyActiveForms.get(),
        };
        for (const todo of todos) {
            const idx = matchCanonicalStep(todo.content);
            if (idx < 0)
                continue;
            if (todo.activeForm && todo.activeForm.trim()) {
                next[CANONICAL_STEPS[idx].id] = todo.activeForm;
            }
        }
        this.$journeyActiveForms.set(next);
        this.renderJourneyTasks();
        this.emitChange();
    }
    /**
     * Re-render the canonical 5-row task list from the derived journey
     * status + per-step activeForms. Sequential-ordering cascade applied
     * here: when step N is in_progress or completed, every step before
     * N rolls up to completed (the agent has demonstrably moved past
     * them). Single-in-progress is implicit — the cascade walks earlier
     * in_progress states up to completed.
     */
    renderJourneyTasks() {
        const derived = this.$derivedJourney.get();
        const activeForms = this.$journeyActiveForms.get();
        let frontier = -1;
        for (let i = 0; i < CANONICAL_STEPS.length; i++) {
            const status = derived[CANONICAL_STEPS[i].id];
            if (status === 'in_progress' || status === 'completed')
                frontier = i;
        }
        const tasks = CANONICAL_STEPS.map((step, i) => {
            let status = TaskStatus.Pending;
            const explicit = derived[step.id];
            if (explicit === 'completed') {
                status = TaskStatus.Completed;
            }
            else if (explicit === 'in_progress') {
                // Sequential cascade still applies — if a later step has been
                // touched, this in_progress is stale (the agent moved on
                // without explicitly completing this step). Promote to
                // Completed so the user-visible list stays single-in_progress.
                status = i < frontier ? TaskStatus.Completed : TaskStatus.InProgress;
            }
            else if (i < frontier) {
                // Sequential cascade — a later step has advanced, so this
                // earlier step must be done by definition.
                status = TaskStatus.Completed;
            }
            return {
                label: step.label,
                activeForm: activeForms[step.id] ?? step.defaultActiveForm,
                status,
                done: status === TaskStatus.Completed,
            };
        });
        this.$tasks.set(tasks);
    }
    // ── React integration ───────────────────────────────────────────
    subscribe(callback) {
        return this.$version.listen(() => callback());
    }
    getSnapshot() {
        return this.$version.get();
    }
}
