/**
 * v0.31.11: thin-client auto-upgrade prompt — unit tests.
 *
 * Covers safeCompare, driftLevel, state-file IO (round-trip + corrupt + atomic),
 * decideAction (every row of the decision matrix from the plan), the lockfile
 * contract (acquire / EEXIST / stale-reclaim), and the orchestrator (yes/no/
 * upgrade-advanced/not-advanced/threw paths).
 *
 * Pure-Bun, no DB, no real network. Uses the test injection seams
 * (`_setVerifierForTest`, `_setPromptReaderForTest`, `_setUpgradeRunnerForTest`)
 * so the orchestrator runs end-to-end without spawning subprocesses or
 * touching the user's PATH.
 */

import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, openSync, closeSync, utimesSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { withEnv } from './helpers/with-env.ts';
import {
  safeCompare,
  driftLevel,
  loadPromptState,
  savePromptState,
  acquirePromptLock,
  decideAction,
  maybePromptForUpgrade,
  _setVerifierForTest,
  _setPromptReaderForTest,
  _setUpgradeRunnerForTest,
  type PromptState,
} from '../src/core/thin-client-upgrade-prompt.ts';
import type { CliOptions } from '../src/core/cli-options.ts';
import type { GBrainConfig } from '../src/core/config.ts';

const DEFAULT_CLI_OPTS: CliOptions = {
  quiet: false,
  progressJson: false,
  progressInterval: 1000,
  timeoutMs: null,
  explain: false,
  brain: null,
};

let tmpHome: string;

beforeEach(() => {
  tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-upgrade-prompt-'));
  // Reset injection seams between tests.
  _setVerifierForTest(null);
  _setPromptReaderForTest(null);
  _setUpgradeRunnerForTest(null);
});

afterEach(() => {
  try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
  _setVerifierForTest(null);
  _setPromptReaderForTest(null);
  _setUpgradeRunnerForTest(null);
});

// ============================================================================
// safeCompare
// ============================================================================

describe('safeCompare', () => {
  test('equal versions return 0', () => {
    expect(safeCompare('0.31.11', '0.31.11')).toBe(0);
  });
  test('local less than remote returns -1', () => {
    expect(safeCompare('0.31.4', '0.31.11')).toBe(-1);
    expect(safeCompare('0.31.4', '0.32.0')).toBe(-1);
    expect(safeCompare('0.31.4', '1.0.0')).toBe(-1);
  });
  test('local greater than remote returns 1', () => {
    expect(safeCompare('0.32.0', '0.31.11')).toBe(1);
    expect(safeCompare('1.0.0', '0.99.99')).toBe(1);
  });
  test('4-segment versions parse (4th segment ignored by underlying comparator)', () => {
    // Underlying compareVersions from src/commands/migrations/index.ts only
    // compares segments 0-2 — the 4th segment is intentionally ignored. This
    // matches gbrain's actual 3-segment release practice (VERSION file).
    expect(safeCompare('0.31.4.0', '0.31.4.0')).toBe(0);
    expect(safeCompare('0.31.4.1', '0.31.4.2')).toBe(0); // 4th segment ignored
    expect(safeCompare('0.31.4.0', '0.31.5.0')).toBe(-1); // segment 2 differs
  });
  test('empty / missing / non-numeric returns null', () => {
    expect(safeCompare('', '0.31.4')).toBe(null);
    expect(safeCompare('0.31.4', '')).toBe(null);
    expect(safeCompare('0.31', '0.31.4')).toBe(null); // 2-segment
    expect(safeCompare('0.31.4', '0.31')).toBe(null);
    expect(safeCompare('0.31.4-rc1', '0.31.4')).toBe(null); // suffix
    expect(safeCompare('0.31.x', '0.31.4')).toBe(null);
    expect(safeCompare('a.b.c', '0.31.4')).toBe(null);
    expect(safeCompare('0.31.4.5.6', '0.31.4')).toBe(null); // 5-segment
  });
});

// ============================================================================
// driftLevel
// ============================================================================

describe('driftLevel', () => {
  test('major bump', () => {
    expect(driftLevel('0.31.11', '1.0.0')).toBe('major');
  });
  test('minor bump (same major)', () => {
    expect(driftLevel('0.31.11', '0.32.0')).toBe('minor');
  });
  test('patch bump (same major+minor)', () => {
    expect(driftLevel('0.31.11', '0.31.12')).toBe('patch');
  });
  test('local equal to remote → none', () => {
    expect(driftLevel('0.31.11', '0.31.11')).toBe('none');
  });
  test('local ahead of remote → none', () => {
    expect(driftLevel('0.32.0', '0.31.11')).toBe('none');
  });
  test('malformed → none', () => {
    expect(driftLevel('garbage', '0.31.11')).toBe('none');
    expect(driftLevel('0.31.11', 'garbage')).toBe('none');
  });
});

// ============================================================================
// State file IO
// ============================================================================

describe('promptState IO', () => {
  test('missing file returns empty state', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      const state = loadPromptState();
      expect(state).toEqual({ schema_version: 1, entries: {} });
    });
  });

  test('round-trip: save then load', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      const state: PromptState = {
        schema_version: 1,
        entries: {
          'https://brain.example.com': {
            last_prompted_remote_version: '0.32.0',
            last_response: 'no',
            last_prompted_at_iso: '2026-05-10T12:00:00Z',
          },
        },
      };
      savePromptState(state);
      const loaded = loadPromptState();
      expect(loaded).toEqual(state);
    });
  });

  test('atomic write: tmp file written then renamed', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      savePromptState({ schema_version: 1, entries: {} });
      const finalPath = join(tmpHome, '.gbrain', 'upgrade-prompt-state.json');
      const tmpPath = `${finalPath}.tmp`;
      expect(existsSync(finalPath)).toBe(true);
      expect(existsSync(tmpPath)).toBe(false); // tmp gets renamed away
    });
  });

  test('corrupt JSON falls through to empty state', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      // Pre-create the gbrain dir + corrupt file
      const path = join(tmpHome, '.gbrain', 'upgrade-prompt-state.json');
      const dir = join(tmpHome, '.gbrain');
      try { require('fs').mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
      writeFileSync(path, '{not valid json');
      const state = loadPromptState();
      expect(state).toEqual({ schema_version: 1, entries: {} });
    });
  });

  test('truncated mid-write file falls through to empty state', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      const path = join(tmpHome, '.gbrain', 'upgrade-prompt-state.json');
      const dir = join(tmpHome, '.gbrain');
      try { require('fs').mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
      writeFileSync(path, '{"schema_version":1,"entries":{"foo":{"last_'); // truncated
      const state = loadPromptState();
      expect(state).toEqual({ schema_version: 1, entries: {} });
    });
  });

  test('malformed entry (missing fields) is dropped, valid sibling preserved', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      const path = join(tmpHome, '.gbrain', 'upgrade-prompt-state.json');
      const dir = join(tmpHome, '.gbrain');
      try { require('fs').mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
      writeFileSync(path, JSON.stringify({
        schema_version: 1,
        entries: {
          'https://good.example.com': {
            last_prompted_remote_version: '0.32.0',
            last_response: 'no',
            last_prompted_at_iso: '2026-05-10T12:00:00Z',
          },
          'https://malformed-1.example.com': { last_response: 'no' }, // missing version + iso
          'https://malformed-2.example.com': {
            last_prompted_remote_version: 42, // wrong type
            last_response: 'no',
            last_prompted_at_iso: '2026-05-10T12:00:00Z',
          },
          'https://malformed-3.example.com': {
            last_prompted_remote_version: '0.32.0',
            last_response: 'banana', // invalid enum
            last_prompted_at_iso: '2026-05-10T12:00:00Z',
          },
        },
      }));
      const state = loadPromptState();
      expect(Object.keys(state.entries)).toEqual(['https://good.example.com']);
      expect(state.entries['https://good.example.com'].last_response).toBe('no');
    });
  });

  test('empty-string entry key is dropped', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      const path = join(tmpHome, '.gbrain', 'upgrade-prompt-state.json');
      const dir = join(tmpHome, '.gbrain');
      try { require('fs').mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
      writeFileSync(path, JSON.stringify({
        schema_version: 1,
        entries: {
          '': {
            last_prompted_remote_version: '0.32.0',
            last_response: 'no',
            last_prompted_at_iso: '2026-05-10T12:00:00Z',
          },
        },
      }));
      const state = loadPromptState();
      expect(Object.keys(state.entries)).toEqual([]);
    });
  });

  test('missing schema_version or wrong shape → empty state', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      const path = join(tmpHome, '.gbrain', 'upgrade-prompt-state.json');
      const dir = join(tmpHome, '.gbrain');
      try { require('fs').mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
      writeFileSync(path, JSON.stringify({ entries: {} })); // missing schema_version
      expect(loadPromptState()).toEqual({ schema_version: 1, entries: {} });

      writeFileSync(path, JSON.stringify({ schema_version: 99, entries: {} })); // wrong version
      expect(loadPromptState()).toEqual({ schema_version: 1, entries: {} });
    });
  });

  test('multi-mcp-url entries are isolated', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      const state: PromptState = {
        schema_version: 1,
        entries: {
          'https://work.example.com': {
            last_prompted_remote_version: '0.32.0',
            last_response: 'no',
            last_prompted_at_iso: '2026-05-10T12:00:00Z',
          },
          'https://home.example.com': {
            last_prompted_remote_version: '1.0.0',
            last_response: 'yes',
            last_prompted_at_iso: '2026-05-10T13:00:00Z',
          },
        },
      };
      savePromptState(state);
      const loaded = loadPromptState();
      expect(loaded.entries['https://work.example.com'].last_response).toBe('no');
      expect(loaded.entries['https://home.example.com'].last_response).toBe('yes');
    });
  });
});

// ============================================================================
// decideAction (pure decision matrix)
// ============================================================================

const EMPTY_STATE: PromptState = { schema_version: 1, entries: {} };

describe('decideAction', () => {
  const baseInput = {
    localVersion: '0.31.4',
    remoteVersion: '0.32.0',
    mcpUrl: 'https://brain.example.com',
    state: EMPTY_STATE,
    cliOpts: DEFAULT_CLI_OPTS,
    stdinIsTty: true,
    stdoutIsTty: true,
    bannerIsSuppressed: false,
  };

  test('drift detected, all gates pass → prompt', () => {
    expect(decideAction(baseInput)).toEqual({ kind: 'prompt', level: 'minor' });
  });

  test('local equals remote → noop', () => {
    expect(decideAction({ ...baseInput, localVersion: '0.32.0' })).toEqual({ kind: 'noop' });
  });

  test('local ahead of remote → noop', () => {
    expect(decideAction({ ...baseInput, localVersion: '1.0.0' })).toEqual({ kind: 'noop' });
  });

  test('safeCompare null (malformed local) → noop', () => {
    expect(decideAction({ ...baseInput, localVersion: 'garbage' })).toEqual({ kind: 'noop' });
  });

  test('D8: patch drift → noop', () => {
    expect(decideAction({ ...baseInput, localVersion: '0.32.0', remoteVersion: '0.32.1' })).toEqual({ kind: 'noop' });
  });

  test('D8: minor drift → prompt', () => {
    expect(decideAction({ ...baseInput, localVersion: '0.31.0', remoteVersion: '0.32.0' })).toEqual({ kind: 'prompt', level: 'minor' });
  });

  test('D8: major drift → prompt', () => {
    expect(decideAction({ ...baseInput, localVersion: '0.31.0', remoteVersion: '1.0.0' })).toEqual({ kind: 'prompt', level: 'major' });
  });

  test('D7: bannerIsSuppressed → noop', () => {
    expect(decideAction({ ...baseInput, bannerIsSuppressed: true })).toEqual({ kind: 'noop' });
  });

  test('D6: stdin not TTY → noop', () => {
    expect(decideAction({ ...baseInput, stdinIsTty: false })).toEqual({ kind: 'noop' });
  });

  test('D6: stdout not TTY → noop', () => {
    expect(decideAction({ ...baseInput, stdoutIsTty: false })).toEqual({ kind: 'noop' });
  });

  test('sticky decline (last_response=no, same remote version) → noop', () => {
    const state: PromptState = {
      schema_version: 1,
      entries: {
        'https://brain.example.com': {
          last_prompted_remote_version: '0.32.0',
          last_response: 'no',
          last_prompted_at_iso: '2026-05-10T12:00:00Z',
        },
      },
    };
    expect(decideAction({ ...baseInput, state })).toEqual({ kind: 'noop' });
  });

  test('sticky yes (last_response=yes, same remote version) → noop', () => {
    const state: PromptState = {
      schema_version: 1,
      entries: {
        'https://brain.example.com': {
          last_prompted_remote_version: '0.32.0',
          last_response: 'yes',
          last_prompted_at_iso: '2026-05-10T12:00:00Z',
        },
      },
    };
    expect(decideAction({ ...baseInput, state })).toEqual({ kind: 'noop' });
  });

  test('failed prior attempt → re-prompt fresh', () => {
    const state: PromptState = {
      schema_version: 1,
      entries: {
        'https://brain.example.com': {
          last_prompted_remote_version: '0.32.0',
          last_response: 'failed',
          last_prompted_at_iso: '2026-05-10T12:00:00Z',
        },
      },
    };
    expect(decideAction({ ...baseInput, state })).toEqual({ kind: 'prompt', level: 'minor' });
  });

  test('new bump after decline → re-prompt', () => {
    const state: PromptState = {
      schema_version: 1,
      entries: {
        'https://brain.example.com': {
          last_prompted_remote_version: '0.32.0',
          last_response: 'no',
          last_prompted_at_iso: '2026-05-10T12:00:00Z',
        },
      },
    };
    // Remote bumped from 0.32.0 to 0.33.0
    expect(decideAction({ ...baseInput, state, remoteVersion: '0.33.0' })).toEqual({ kind: 'prompt', level: 'minor' });
  });
});

// ============================================================================
// Lockfile (D2)
// ============================================================================

describe('acquirePromptLock', () => {
  test('acquire then release', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      const lock = acquirePromptLock();
      expect(lock).not.toBeNull();
      const lockPath = join(tmpHome, '.gbrain', 'upgrade-prompt.lock');
      expect(existsSync(lockPath)).toBe(true);
      lock!.release();
      expect(existsSync(lockPath)).toBe(false);
    });
  });

  test('concurrent acquire returns null (EEXIST)', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      const lock1 = acquirePromptLock();
      expect(lock1).not.toBeNull();
      const lock2 = acquirePromptLock();
      expect(lock2).toBeNull();
      lock1!.release();
      // Now a fresh acquire should succeed
      const lock3 = acquirePromptLock();
      expect(lock3).not.toBeNull();
      lock3!.release();
    });
  });

  test('stale lock (>60s old mtime) reclaimed', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      // Manually create a stale lockfile
      const dir = join(tmpHome, '.gbrain');
      try { require('fs').mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
      const lockPath = join(dir, 'upgrade-prompt.lock');
      const fd = openSync(lockPath, 'wx+');
      closeSync(fd);
      // Backdate mtime to 2 minutes ago
      const past = new Date(Date.now() - 120_000);
      utimesSync(lockPath, past, past);
      // Acquire should reclaim
      const lock = acquirePromptLock();
      expect(lock).not.toBeNull();
      lock!.release();
    });
  });

  test('release is idempotent', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      const lock = acquirePromptLock();
      expect(lock).not.toBeNull();
      lock!.release();
      // Second release must not throw
      expect(() => lock!.release()).not.toThrow();
    });
  });
});

// ============================================================================
// Orchestrator
// ============================================================================

describe('maybePromptForUpgrade orchestrator', () => {
  const cfg: GBrainConfig = {
    remote_mcp: {
      mcp_url: 'https://brain.example.com',
      issuer_url: 'https://brain.example.com',
      oauth_client_id: 'test-client',
    },
  } as GBrainConfig;

  const identity = { version: '0.32.0' };

  test('no remote_mcp → returns immediately', async () => {
    let called = false;
    _setPromptReaderForTest(async () => { called = true; return 'n'; });
    await maybePromptForUpgrade({} as GBrainConfig, identity, DEFAULT_CLI_OPTS, false, {
      localVersion: '0.31.4',
      exit: ((code: number) => { throw new Error(`unexpected exit ${code}`); }) as (code: number) => never,
      log: () => { /* swallow */ },
    });
    expect(called).toBe(false);
  });

  test('decideAction=noop (patch drift) → returns without prompting', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      let prompted = false;
      _setPromptReaderForTest(async () => { prompted = true; return 'n'; });
      await maybePromptForUpgrade(cfg, { version: '0.31.5' }, DEFAULT_CLI_OPTS, false, {
        localVersion: '0.31.4',
        exit: ((code: number) => { throw new Error(`unexpected exit ${code}`); }) as (code: number) => never,
        log: () => { /* swallow */ },
      });
      expect(prompted).toBe(false);
    });
  });

  test('prompt → "n" → state persisted, returns without exit', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      _setPromptReaderForTest(async () => 'n');
      let exited = false;
      await maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
        localVersion: '0.31.4',
        exit: ((code: number) => { exited = true; throw new Error(`unexpected exit ${code}`); }) as (code: number) => never,
        log: () => { /* swallow */ },
        stdinIsTty: true,
        stdoutIsTty: true,
      });
      expect(exited).toBe(false);
      const state = loadPromptState();
      expect(state.entries[cfg.remote_mcp!.mcp_url]).toBeDefined();
      expect(state.entries[cfg.remote_mcp!.mcp_url].last_response).toBe('no');
      expect(state.entries[cfg.remote_mcp!.mcp_url].last_prompted_remote_version).toBe('0.32.0');
    });
  });

  test('prompt → "y" → upgrade runs → advanced → state=yes, exit 0', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      _setPromptReaderForTest(async () => 'y');
      let upgradeRan = false;
      _setUpgradeRunnerForTest(() => { upgradeRan = true; });
      _setVerifierForTest(() => ({ advanced: true, newVersion: '0.32.0' }));

      let exitCode = -1;
      class ExitError extends Error { constructor(public code: number) { super(`exit ${code}`); } }
      try {
        await maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
          localVersion: '0.31.4',
          exit: ((code: number) => { exitCode = code; throw new ExitError(code); }) as (code: number) => never,
          log: () => { /* swallow */ },
          stdinIsTty: true,
          stdoutIsTty: true,
        });
      } catch (e) {
        if (!(e instanceof ExitError)) throw e;
      }

      expect(upgradeRan).toBe(true);
      expect(exitCode).toBe(0);
      const state = loadPromptState();
      expect(state.entries[cfg.remote_mcp!.mcp_url].last_response).toBe('yes');
    });
  });

  test('prompt → "y" → upgrade runs → NOT advanced → state=failed, exit 1', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      _setPromptReaderForTest(async () => 'y');
      _setUpgradeRunnerForTest(() => { /* swallow, simulate exit 0 with no advance */ });
      _setVerifierForTest(() => ({ advanced: false, newVersion: '0.31.4' }));

      let exitCode = -1;
      class ExitError extends Error { constructor(public code: number) { super(`exit ${code}`); } }
      try {
        await maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
          localVersion: '0.31.4',
          exit: ((code: number) => { exitCode = code; throw new ExitError(code); }) as (code: number) => never,
          log: () => { /* swallow */ },
          stdinIsTty: true,
          stdoutIsTty: true,
        });
      } catch (e) {
        if (!(e instanceof ExitError)) throw e;
      }

      expect(exitCode).toBe(1);
      const state = loadPromptState();
      expect(state.entries[cfg.remote_mcp!.mcp_url].last_response).toBe('failed');
    });
  });

  test('prompt → "y" → upgrade throws → state=failed, exit 1', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      _setPromptReaderForTest(async () => 'y');
      _setUpgradeRunnerForTest(() => { throw new Error('subprocess died'); });
      _setVerifierForTest(() => { throw new Error('verifier should not be called'); });

      let exitCode = -1;
      let logged = '';
      class ExitError extends Error { constructor(public code: number) { super(`exit ${code}`); } }
      try {
        await maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
          localVersion: '0.31.4',
          exit: ((code: number) => { exitCode = code; throw new ExitError(code); }) as (code: number) => never,
          log: (msg: string) => { logged += msg; },
          stdinIsTty: true,
          stdoutIsTty: true,
        });
      } catch (e) {
        if (!(e instanceof ExitError)) throw e;
      }

      expect(exitCode).toBe(1);
      expect(logged).toContain('subprocess died');
      const state = loadPromptState();
      expect(state.entries[cfg.remote_mcp!.mcp_url].last_response).toBe('failed');
    });
  });

  test('empty answer (just enter) treated as yes', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      _setPromptReaderForTest(async () => '');
      let upgradeRan = false;
      _setUpgradeRunnerForTest(() => { upgradeRan = true; });
      _setVerifierForTest(() => ({ advanced: true, newVersion: '0.32.0' }));

      class ExitError extends Error { constructor(public code: number) { super(); } }
      try {
        await maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
          localVersion: '0.31.4',
          exit: ((code: number) => { throw new ExitError(code); }) as (code: number) => never,
          log: () => { /* swallow */ },
          stdinIsTty: true,
          stdoutIsTty: true,
        });
      } catch (e) {
        if (!(e instanceof ExitError)) throw e;
      }
      expect(upgradeRan).toBe(true);
    });
  });

  test('installs and removes prompt-scoped SIGINT handler around the prompt', async () => {
    // Regression guard: without the prompt-scoped handler, Ctrl-C during the
    // prompt is swallowed by runThinClientRouted's outer AbortController-only
    // handler. We verify by counting SIGINT listeners before, during (inside
    // the prompt-reader callback), and after.
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      const before = process.listeners('SIGINT').length;
      let listenersDuringPrompt = -1;
      _setPromptReaderForTest(async () => {
        listenersDuringPrompt = process.listeners('SIGINT').length;
        return 'n';
      });
      await maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
        localVersion: '0.31.4',
        exit: ((code: number) => { throw new Error(`unexpected exit ${code}`); }) as (code: number) => never,
        log: () => { /* swallow */ },
        stdinIsTty: true,
        stdoutIsTty: true,
      });
      const after = process.listeners('SIGINT').length;
      expect(listenersDuringPrompt).toBe(before + 1);
      expect(after).toBe(before);
    });
  });

  test('SIGINT handler is removed even if the prompt reader throws', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      const before = process.listeners('SIGINT').length;
      _setPromptReaderForTest(async () => { throw new Error('stdin closed'); });
      await expect(
        maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
          localVersion: '0.31.4',
          exit: ((code: number) => { throw new Error(`unexpected exit ${code}`); }) as (code: number) => never,
          log: () => { /* swallow */ },
          stdinIsTty: true,
          stdoutIsTty: true,
        })
      ).rejects.toThrow('stdin closed');
      expect(process.listeners('SIGINT').length).toBe(before);
    });
  });

  test('prompt reader returns null (EOF) → no state write, no exit, caller continues', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      _setPromptReaderForTest(async () => null);
      let exited = false;
      await maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
        localVersion: '0.31.4',
        exit: ((code: number) => { exited = true; throw new Error(`unexpected exit ${code}`); }) as (code: number) => never,
        log: () => { /* swallow */ },
        stdinIsTty: true,
        stdoutIsTty: true,
      });
      expect(exited).toBe(false);
      // CRITICAL: must NOT persist 'no' to state — a transient EOF should not
      // poison the per-version sticky-decline gate.
      const state = loadPromptState();
      expect(state.entries[cfg.remote_mcp!.mcp_url]).toBeUndefined();
    });
  });

  test('lock contention (sibling holds lock) → no prompt fires', async () => {
    await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
      // Pre-acquire the lock
      const sibling = acquirePromptLock();
      expect(sibling).not.toBeNull();
      try {
        let prompted = false;
        _setPromptReaderForTest(async () => { prompted = true; return 'y'; });
        await maybePromptForUpgrade(cfg, identity, DEFAULT_CLI_OPTS, false, {
          localVersion: '0.31.4',
          exit: ((code: number) => { throw new Error(`unexpected exit ${code}`); }) as (code: number) => never,
          log: () => { /* swallow */ },
          stdinIsTty: true,
          stdoutIsTty: true,
        });
        expect(prompted).toBe(false);
      } finally {
        sibling!.release();
      }
    });
  });
});
