import { tmpdir } from 'node:os';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
import { PtyManager, type ClientBinding } from '../src/core/pty-manager.js';
import type { PushPayload, PushService } from '../src/core/push-service.js';
import { openDb, type Db } from '../src/db/index.js';

// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));

interface FakePty {
  pid: number;
  file: string;
  args: string[];
  opts: { cwd: string; cols: number; rows: number };
  write: Mock;
  resize: Mock;
  pause: Mock;
  resume: Mock;
  emitData(data: string): void;
  emitExit(exitCode: number, signal?: number): void;
}

const ptyMock = vi.hoisted(() => ({ instances: [] as unknown[] }));

vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
  let nextPid = 100_000;
  class FakePtyImpl {
    pid = nextPid++;
    write = vi.fn();
    resize = vi.fn();
    pause = vi.fn();
    resume = vi.fn();
    kill = vi.fn();
    private dataCbs: Array<(d: string) => void> = [];
    private exitCbs: Array<(e: { exitCode: number; signal?: number }) => void> = [];
    constructor(
      readonly file: string,
      readonly args: string[],
      readonly opts: unknown,
    ) {}
    onData(cb: (d: string) => void): { dispose: () => void } {
      this.dataCbs.push(cb);
      return { dispose: () => {} };
    }
    onExit(cb: (e: { exitCode: number; signal?: number }) => void): { dispose: () => void } {
      this.exitCbs.push(cb);
      return { dispose: () => {} };
    }
    emitData(d: string): void {
      for (const cb of this.dataCbs) cb(d);
    }
    emitExit(exitCode: number, signal?: number): void {
      for (const cb of this.exitCbs) cb(signal === undefined ? { exitCode } : { exitCode, signal });
    }
  }
  return {
    default: {
      spawn: (file: string, args: string[], opts: unknown): FakePtyImpl => {
        const p = new FakePtyImpl(file, args, opts);
        ptyMock.instances.push(p);
        return p;
      },
    },
  };
});

type BindingSpies = ClientBinding & {
  sendOutput: Mock<(payload: Buffer) => void>;
  sendResync: Mock<(payload: Buffer) => void>;
  onDetached: Mock<(reason: 'session_exit' | 'replaced') => void>;
  onControlChanged: Mock<(controlling: boolean) => void>;
};

const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));

let channelSeq = 1;
function makeBinding(mode: 'interactive' | 'observer'): BindingSpies {
  return {
    channel: channelSeq++,
    mode,
    controlling: false,
    sentBytes: 0,
    ackedBytes: 0,
    lagging: false,
    sendOutput: vi.fn(),
    sendResync: vi.fn(),
    onDetached: vi.fn(),
    onControlChanged: vi.fn(),
  };
}

function lastPty(): FakePty {
  return ptyMock.instances.at(-1) as FakePty;
}

describe('PtyManager (pty mocké)', () => {
  let db: Db;
  let manager: PtyManager;
  const cwd = tmpdir();

  beforeEach(() => {
    ptyMock.instances.length = 0;
    db = openDb(':memory:');
    manager = new PtyManager(db);
  });

  function spawnBash(): { summary: SessionSummary; pty: FakePty } {
    const summary = manager.spawn({ cwd, command: 'bash' });
    return { summary, pty: lastPty() };
  }

  describe('spawn', () => {
    it('émet session_update, insère en base et lance le pty avec le bon spec', () => {
      const updates: SessionSummary[] = [];
      manager.on('session_update', (s) => updates.push(s));
      const { summary, pty } = spawnBash();

      expect(updates).toHaveLength(1);
      expect(updates[0]).toEqual(summary);
      expect(summary.status).toBe('running');
      expect(summary.live).toBe(true);
      expect(summary.clients).toBe(0);
      expect(summary.command).toBe('bash');

      expect(pty.file).toBe('bash');
      expect(pty.args).toEqual(['--norc']);
      expect(pty.opts.cwd).toBe(cwd);
      expect(pty.opts.cols).toBe(120);
      expect(pty.opts.rows).toBe(32);

      const row = db.prepare('SELECT cwd, command FROM sessions WHERE id = ?').get(summary.id) as { cwd: string; command: string };
      expect(row).toEqual({ cwd, command: 'bash' });
      expect(manager.get(summary.id)?.id).toBe(summary.id);
    });

    it('cwd inexistant → erreur statusCode 400, rien en base', () => {
      expect(() => manager.spawn({ cwd: '/definitely/not/a/dir', command: 'bash' })).toThrowError(
        expect.objectContaining({ statusCode: 400 }),
      );
      const row = db.prepare('SELECT COUNT(*) AS n FROM sessions').get() as { n: number };
      expect(row.n).toBe(0);
    });

    it('summarize expose les champs P2 (source/attachable/resumable/pid)', () => {
      const { summary } = spawnBash();
      expect(summary).toMatchObject({
        source: 'managed',
        attachable: true,
        resumable: false,
        registryStatus: null,
        claudeSessionId: null,
      });
      expect(summary.pid).toBeGreaterThan(0);
    });
  });

  describe('resume / fork (P2)', () => {
    it('resume → pty claude avec --resume, command forcé à claude, resumed_from persisté', () => {
      const summary = manager.spawn({ cwd, resume: { claudeSessionId: 'abc-123' } });
      const p = lastPty();
      expect(summary.command).toBe('claude');
      expect(summary.source).toBe('managed');
      expect(summary.attachable).toBe(true);
      expect(p.file).toBe('/usr/bin/claude');
      expect(p.args).toEqual(['--resume', 'abc-123']);
      const row = db.prepare('SELECT resumed_from, command FROM sessions WHERE id = ?').get(summary.id) as {
        resumed_from: string | null;
        command: string;
      };
      expect(row).toEqual({ resumed_from: 'abc-123', command: 'claude' });
    });

    it('fork → --resume <id> --fork-session', () => {
      manager.spawn({ cwd, resume: { claudeSessionId: 'xyz', fork: true } });
      expect(lastPty().args).toEqual(['--resume', 'xyz', '--fork-session']);
    });

    it('session claude morte avec claudeSessionId connu → resumable dans list()', () => {
      const summary = manager.spawn({ cwd, resume: { claudeSessionId: 'sid-known' } });
      // simule la capture du claudeSessionId (normalement résolue via le registre)
      db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('sid-known', summary.id);
      lastPty().emitExit(0);
      const listed = manager.list().find((s) => s.id === summary.id);
      expect(listed).toMatchObject({ live: false, resumable: true, claudeSessionId: 'sid-known' });
    });

    it('capture le claudeSessionId via le registre (poll par pid) → findLiveByClaudeSessionId', () => {
      vi.useFakeTimers();
      const sessDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));
      try {
        const m = new PtyManager(db, sessDir);
        const summary = m.spawn({ cwd, command: 'claude' });
        const p = lastPty();
        writeFileSync(join(sessDir, `${p.pid}.json`), JSON.stringify({ pid: p.pid, sessionId: 'captured-sid', status: 'idle' }));
        expect(m.findLiveByClaudeSessionId('captured-sid')).toBeNull(); // pas encore capturé
        vi.advanceTimersByTime(400);
        const found = m.findLiveByClaudeSessionId('captured-sid');
        expect(found?.id).toBe(summary.id);
        expect(found?.claudeSessionId).toBe('captured-sid');
      } finally {
        vi.useRealTimers();
        rmSync(sessDir, { recursive: true, force: true });
      }
    });
  });

  describe('attach', () => {
    it('session inconnue → NOT_FOUND', () => {
      const res = manager.attach('nope', makeBinding('interactive'), 80, 24);
      expect(res).toEqual({ ok: false, code: 'NOT_FOUND' });
    });

    it('resync à l’attach = queue du ring (REPLAY_TAIL_BYTES max)', () => {
      const { summary, pty } = spawnBash();
      const chunks = ['A', 'B', 'C'].map((c) => c.repeat(100 * 1024));
      for (const c of chunks) pty.emitData(c);

      const b = makeBinding('interactive');
      const res = manager.attach(summary.id, b, 80, 24);
      expect(res).toEqual({ ok: true, controlling: true });

      expect(b.sendResync).toHaveBeenCalledTimes(1);
      const payload = b.sendResync.mock.calls[0]![0]!;
      const full = Buffer.from(chunks.join(''), 'ascii');
      expect(payload.length).toBe(REPLAY_TAIL_BYTES);
      expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
      // compteurs de flow control remis à zéro après le resync initial
      expect(b.sentBytes).toBe(0);
      expect(b.ackedBytes).toBe(0);
    });

    it('ring vide → resync avec payload vide', () => {
      const { summary } = spawnBash();
      const b = makeBinding('interactive');
      manager.attach(summary.id, b, 80, 24);
      expect(b.sendResync).toHaveBeenCalledTimes(1);
      expect(b.sendResync.mock.calls[0]![0]!.length).toBe(0);
    });

    it('contrôle au premier interactif seulement, les observers ne comptent pas', () => {
      const { summary, pty } = spawnBash();
      const obs = makeBinding('observer');
      const a = makeBinding('interactive');
      const b = makeBinding('interactive');

      expect(manager.attach(summary.id, obs, 80, 24)).toEqual({ ok: true, controlling: false });
      expect(pty.resize).not.toHaveBeenCalled(); // un observer ne redimensionne pas

      expect(manager.attach(summary.id, a, 100, 30)).toEqual({ ok: true, controlling: true });
      expect(pty.resize).toHaveBeenCalledWith(100, 30); // le contrôleur impose sa taille

      expect(manager.attach(summary.id, b, 200, 50)).toEqual({ ok: true, controlling: false });
      expect(pty.resize).toHaveBeenCalledTimes(1); // pas de resize pour le non-contrôleur
      expect(manager.get(summary.id)?.clients).toBe(3);
    });
  });

  describe('write / resize', () => {
    it('observer read-only : write refusé', () => {
      const { summary, pty } = spawnBash();
      const obs = makeBinding('observer');
      manager.attach(summary.id, obs, 80, 24);
      expect(manager.write(summary.id, obs, 'ls\r')).toBe('not_controlling');
      expect(pty.write).not.toHaveBeenCalled();
    });

    it('tout interactif peut écrire (même non contrôleur), session inconnue → gone', () => {
      const { summary, pty } = spawnBash();
      const a = makeBinding('interactive');
      const b = makeBinding('interactive');
      manager.attach(summary.id, a, 80, 24);
      manager.attach(summary.id, b, 80, 24);
      expect(manager.write(summary.id, a, 'echo a\r')).toBe('ok');
      expect(manager.write(summary.id, b, 'echo b\r')).toBe('ok');
      expect(pty.write).toHaveBeenCalledTimes(2);
      expect(manager.write('nope', a, 'x')).toBe('gone');
    });

    it('resize ignoré pour les non-contrôleurs', () => {
      const { summary, pty } = spawnBash();
      const a = makeBinding('interactive');
      const b = makeBinding('interactive');
      manager.attach(summary.id, a, 80, 24);
      manager.attach(summary.id, b, 80, 24);
      pty.resize.mockClear();
      manager.resize(summary.id, b, 50, 20);
      expect(pty.resize).not.toHaveBeenCalled();
      manager.resize(summary.id, a, 90, 28);
      expect(pty.resize).toHaveBeenCalledWith(90, 28);
    });
  });

  describe('answer (P4-A)', () => {
    it('select → "N\\r" si l’option existe, deny → Esc ; rejets gone/not_controlling/invalid', async () => {
      const sessDir = mkdtempSync(join(tmpdir(), 'arb-answer-'));
      const m = new PtyManager(db, sessDir);
      try {
        const summary = m.spawn({ cwd, command: 'claude' });
        const p = lastPty();
        // écran : dialogue de permission à 2 options (la 1re est sélectionnée ❯)
        p.emitData('\x1b[2J\x1b[HDo you want to create x.txt?\r\n❯ 1. Yes\r\n2. No\r\nEsc to cancel\r\n');
        writeFileSync(
          join(sessDir, `${p.pid}.json`),
          JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status: 'waiting', waitingFor: 'permission prompt' }),
        );
        await sleep(260); // laisse le screen reader + le debounce (200ms) du tracker établir l’état

        const a = makeBinding('interactive');
        m.attach(summary.id, a, 80, 24);
        p.write.mockClear();

        // option inexistante → invalid (anti-frappe fantôme), aucun write
        expect(m.answer(summary.id, a, 'select', 9)).toBe('invalid');
        expect(p.write).not.toHaveBeenCalled();
        // option existante → "1\r"
        expect(m.answer(summary.id, a, 'select', 1)).toBe('ok');
        expect(p.write).toHaveBeenCalledWith('1\r');
        // deny → Esc
        expect(m.answer(summary.id, a, 'deny')).toBe('ok');
        expect(p.write).toHaveBeenCalledWith('\x1b');

        // observer read-only
        const obs = makeBinding('observer');
        m.attach(summary.id, obs, 80, 24);
        expect(m.answer(summary.id, obs, 'deny')).toBe('not_controlling');
        // session inconnue → gone
        expect(m.answer('nope', a, 'deny')).toBe('gone');
      } finally {
        m.shutdown();
        rmSync(sessDir, { recursive: true, force: true });
      }
    });

    it('session sans état waiting (bash, pas de tracker) → invalid', () => {
      const { summary, pty } = spawnBash();
      const a = makeBinding('interactive');
      manager.attach(summary.id, a, 80, 24);
      pty.write.mockClear();
      expect(manager.answer(summary.id, a, 'deny')).toBe('invalid');
      expect(manager.answer(summary.id, a, 'select', 1)).toBe('invalid');
      expect(pty.write).not.toHaveBeenCalled();
    });
  });

  describe('push trigger (P4-B)', () => {
    it('une notif sur le front montant busy→waiting, après le debounce ; pas de notif en restant busy', async () => {
      const sessDir = mkdtempSync(join(tmpdir(), 'arb-push-'));
      const notifies: PushPayload[] = [];
      const fakePush = {
        notify: async (p: PushPayload) => {
          notifies.push(p);
        },
      } as unknown as PushService;
      const m = new PtyManager(db, sessDir, fakePush);
      try {
        const summary = m.spawn({ cwd, command: 'claude' });
        const p = lastPty();
        const regFile = join(sessDir, `${p.pid}.json`);
        const writeReg = (status: string, waitingFor?: string): void =>
          writeFileSync(regFile, JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status, ...(waitingFor ? { waitingFor } : {}) }));

        // busy : front montant vers busy, pas de notif
        writeReg('busy');
        p.emitData('working…');
        await sleep(260);
        expect(notifies).toHaveLength(0);

        // waiting + écran permission : front montant vers waiting → planifie la notif (debounce 1500ms)
        writeReg('waiting', 'permission prompt');
        p.emitData('\x1b[2J\x1b[HDo you want to create x.txt?\r\n❯ 1. Yes\r\n2. No\r\n');
        await sleep(260);
        expect(notifies).toHaveLength(0); // pas encore : debounce en cours

        await sleep(1500); // dépasse le debounce
        expect(notifies).toHaveLength(1);
        expect(notifies[0]).toMatchObject({ sessionId: summary.id, kind: 'permission', url: `/sessions/${summary.id}` });
      } finally {
        m.shutdown();
        rmSync(sessDir, { recursive: true, force: true });
      }
    });
  });

  describe('flow control', () => {
    it('pause() UNIQUEMENT quand tous les interactifs dépassent HIGH ; resume() quand le min repasse sous LOW', () => {
      const { summary, pty } = spawnBash();
      const a = makeBinding('interactive');
      const b = makeBinding('interactive');
      manager.attach(summary.id, a, 80, 24);
      manager.attach(summary.id, b, 80, 24);

      const chunk = 'x'.repeat(200 * 1024); // 200 Kio ASCII
      pty.emitData(chunk); // a=200K, b=200K — sous HIGH (384K)
      expect(pty.pause).not.toHaveBeenCalled();

      manager.ack(summary.id, b, 200 * 1024); // b rattrape tout
      pty.emitData(chunk); // a=400K > HIGH, b=200K < HIGH
      expect(pty.pause).not.toHaveBeenCalled(); // un seul interactif au-dessus ne suffit pas

      pty.emitData(chunk); // a=600K, b=400K → TOUS au-dessus de HIGH
      expect(a.sentBytes - a.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
      expect(b.sentBytes - b.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
      expect(pty.pause).toHaveBeenCalledTimes(1);
      expect(pty.resume).not.toHaveBeenCalled();

      manager.ack(summary.id, b, 600 * 1024); // outstanding b = 0 < LOW
      expect(pty.resume).toHaveBeenCalledTimes(1);
      expect(pty.pause).toHaveBeenCalledTimes(1); // pas de re-pause intempestive
    });

    it('les observers ne freinent jamais le PTY', () => {
      const { summary, pty } = spawnBash();
      const a = makeBinding('interactive');
      const obs = makeBinding('observer');
      manager.attach(summary.id, a, 80, 24);
      manager.attach(summary.id, obs, 80, 24);

      const chunk = 'y'.repeat(200 * 1024);
      for (let i = 0; i < 3; i++) {
        pty.emitData(chunk);
        manager.ack(summary.id, a, a.sentBytes); // l'interactif suit le rythme
      }
      expect(obs.sentBytes - obs.ackedBytes).toBeGreaterThan(FLOW.HIGH_WATERMARK);
      expect(pty.pause).not.toHaveBeenCalled();
    });

    it('session sans interactif (observers seuls) : jamais de pause', () => {
      const { summary, pty } = spawnBash();
      const obs = makeBinding('observer');
      manager.attach(summary.id, obs, 80, 24);
      pty.emitData('z'.repeat(500 * 1024));
      expect(pty.pause).not.toHaveBeenCalled();
    });
  });

  describe('client lagging', () => {
    it('> LAGGING_BYTES sans ACK → flux coupé, puis resync au rattrapage', () => {
      const { summary, pty } = spawnBash();
      const a = makeBinding('interactive');
      manager.attach(summary.id, a, 80, 24);

      const mib = 1024 * 1024;
      const chunks = ['A', 'B', 'C', 'D'].map((c) => c.repeat(mib));

      pty.emitData(chunks[0]!); // outstanding 1 Mio
      pty.emitData(chunks[1]!); // outstanding 2 Mio — pas strictement > LAGGING_BYTES
      expect(a.lagging).toBe(false);
      expect(a.sendOutput).toHaveBeenCalledTimes(2);

      pty.emitData(chunks[2]!); // outstanding 3 Mio > 2 Mio → lagging
      expect(a.lagging).toBe(true);
      expect(a.sendOutput).toHaveBeenCalledTimes(3); // ce chunk est parti avant le marquage
      // le seul interactif est lagging : le PTY ne doit pas rester en pause
      expect(pty.resume).toHaveBeenCalled();

      pty.emitData(chunks[3]!); // flux coupé pour le client lagging
      expect(a.sendOutput).toHaveBeenCalledTimes(3);

      manager.ack(summary.id, a, 3 * mib); // rattrapage : outstanding 0 < LOW
      expect(a.lagging).toBe(false);
      expect(a.sendResync).toHaveBeenCalledTimes(2); // attach + rattrapage
      const payload = a.sendResync.mock.calls[1]![0]!;
      const full = Buffer.from(chunks.join(''), 'ascii');
      expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
      expect(a.sentBytes).toBe(0);
      expect(a.ackedBytes).toBe(0);

      // le flux reprend normalement après le resync
      pty.emitData('tail');
      expect(a.sendOutput).toHaveBeenCalledTimes(4);
      expect(a.sendOutput.mock.calls[3]![0]!.toString('ascii')).toBe('tail');
    });
  });

  describe('detach et transfert de contrôle', () => {
    it('detach du contrôleur → transfert au prochain interactif (onControlChanged)', () => {
      const { summary } = spawnBash();
      const a = makeBinding('interactive');
      const b = makeBinding('interactive');
      const obs = makeBinding('observer');
      manager.attach(summary.id, a, 80, 24);
      manager.attach(summary.id, obs, 80, 24);
      manager.attach(summary.id, b, 80, 24);
      expect(a.controlling).toBe(true);
      expect(b.controlling).toBe(false);

      manager.detach(summary.id, a);
      expect(b.controlling).toBe(true);
      expect(b.onControlChanged).toHaveBeenCalledExactlyOnceWith(true);
      expect(obs.onControlChanged).not.toHaveBeenCalled();
      expect(manager.get(summary.id)?.clients).toBe(2);
    });

    it('detach d’un non-contrôleur : aucun transfert', () => {
      const { summary } = spawnBash();
      const a = makeBinding('interactive');
      const b = makeBinding('interactive');
      manager.attach(summary.id, a, 80, 24);
      manager.attach(summary.id, b, 80, 24);
      manager.detach(summary.id, b);
      expect(a.controlling).toBe(true);
      expect(a.onControlChanged).not.toHaveBeenCalled();
    });

    it('detach du dernier interactif : un observer ne récupère jamais le contrôle', () => {
      const { summary } = spawnBash();
      const a = makeBinding('interactive');
      const obs = makeBinding('observer');
      manager.attach(summary.id, a, 80, 24);
      manager.attach(summary.id, obs, 80, 24);
      manager.detach(summary.id, a);
      expect(obs.controlling).toBe(false);
      expect(obs.onControlChanged).not.toHaveBeenCalled();
    });

    it('un nouvel interactif après detach du contrôleur reprend le contrôle', () => {
      const { summary } = spawnBash();
      const a = makeBinding('interactive');
      manager.attach(summary.id, a, 80, 24);
      manager.detach(summary.id, a);
      const b = makeBinding('interactive');
      expect(manager.attach(summary.id, b, 80, 24)).toEqual({ ok: true, controlling: true });
    });
  });

  describe('exit', () => {
    it('onDetached(session_exit) pour tous, DB mise à jour, événements émis', () => {
      const { summary, pty } = spawnBash();
      const a = makeBinding('interactive');
      const obs = makeBinding('observer');
      manager.attach(summary.id, a, 80, 24);
      manager.attach(summary.id, obs, 80, 24);

      const exits: Array<{ sessionId: string; exitCode: number | null; signal: number | null }> = [];
      const updates: SessionSummary[] = [];
      manager.on('session_exit', (e) => exits.push(e));
      manager.on('session_update', (s) => updates.push(s));

      pty.emitExit(0);

      expect(a.onDetached).toHaveBeenCalledExactlyOnceWith('session_exit');
      expect(obs.onDetached).toHaveBeenCalledExactlyOnceWith('session_exit');

      expect(exits).toEqual([{ sessionId: summary.id, exitCode: 0, signal: null }]);
      expect(updates).toHaveLength(1);
      expect(updates[0]).toMatchObject({ id: summary.id, status: 'exited', live: false, exitCode: 0 });

      const row = db.prepare('SELECT ended_at, exit_code FROM sessions WHERE id = ?').get(summary.id) as { ended_at: string | null; exit_code: number | null };
      expect(row.ended_at).not.toBeNull();
      expect(row.exit_code).toBe(0);

      expect(manager.get(summary.id)).toBeNull();
      const listed = manager.list().find((s) => s.id === summary.id);
      expect(listed).toMatchObject({ status: 'exited', live: false, exitCode: 0 });
    });

    it('exit avec signal : propagé dans session_exit', () => {
      const { summary, pty } = spawnBash();
      const exits: Array<{ sessionId: string; exitCode: number | null; signal: number | null }> = [];
      manager.on('session_exit', (e) => exits.push(e));
      pty.emitExit(1, 15);
      expect(exits).toEqual([{ sessionId: summary.id, exitCode: 1, signal: 15 }]);
    });

    it('après exit : write → gone, attach → NOT_FOUND', () => {
      const { summary, pty } = spawnBash();
      const a = makeBinding('interactive');
      manager.attach(summary.id, a, 80, 24);
      pty.emitExit(0);
      expect(manager.write(summary.id, a, 'x')).toBe('gone');
      expect(manager.attach(summary.id, makeBinding('interactive'), 80, 24)).toEqual({ ok: false, code: 'NOT_FOUND' });
    });
  });
});
