import { describe, it, expect, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import { ConsoleSession } from './console-session.js';

class StubChannel extends EventEmitter {
  written: Buffer[] = [];
  windowChanges: Array<{ rows: number; cols: number }> = [];
  ended = false;
  write(buf: Buffer): boolean { this.written.push(buf); return true; }
  end(): void { this.ended = true; this.emit('close'); }
  setWindow(rows: number, cols: number, _h?: number, _w?: number): void {
    this.windowChanges.push({ rows, cols });
  }
}

class StubClient extends EventEmitter {
  ended = false;
  end(): void { this.ended = true; this.emit('close'); }
}

function mkAudit() {
  return {
    beginAndComplete: vi.fn(),
    begin: vi.fn().mockReturnValue(1),
    complete: vi.fn(),
  };
}

function mkSession(channel: StubChannel, client: StubClient = new StubClient()) {
  const audit = mkAudit();
  const session = new ConsoleSession({
    localTaskId: 't1',
    connectionId: 'c1',
    ownerId: 'u1',
    startedByUserId: 'u1',
    cols: 80,
    rows: 24,
    scrollbackCap: 1024,
    channel: channel as any,
    client: client as any,
    auditRepo: audit as any,
  });
  return { session, audit, client };
}

describe('ConsoleSession', () => {
  it('initialises with cols/rows + ssh2 channel hooks', () => {
    const ch = new StubChannel();
    const { session } = mkSession(ch);
    expect(session.cols).toBe(80);
    expect(session.rows).toBe(24);
    expect(session.totalOutputBytes).toBe(0);
  });

  it('routes server output into scrollback and headless terminal', () => {
    const ch = new StubChannel();
    const { session } = mkSession(ch);
    ch.emit('data', Buffer.from('hello'));
    expect(session.totalOutputBytes).toBe(5);
    const screen = session.snapshotScreen();
    expect(screen.text).toContain('hello');
  });

  it('write() forwards to channel and updates lastActivityAt (AI input: LF→CR)', async () => {
    const ch = new StubChannel();
    const { session } = mkSession(ch);
    const before = session.lastActivityAt;
    await new Promise((r) => setTimeout(r, 5));
    session.write(Buffer.from('ls\n'), 'ai');
    // AI input has its LF terminator rewritten to CR so the remote PTY
    // (cooked mode, ICRNL) treats it as Enter — matches xterm.js human input.
    expect(ch.written[0]!.toString()).toBe('ls\r');
    expect(session.lastActivityAt).toBeGreaterThan(before);
    expect(session.totalInputBytes).toBe(3);
  });

  it('write() preserves human CR input unchanged', () => {
    const ch = new StubChannel();
    const { session } = mkSession(ch);
    session.write(Buffer.from('uptime\r'), 'human');
    expect(ch.written[0]!.toString()).toBe('uptime\r');
  });

  it('write() human partial input is forwarded immediately (no line buffer)', () => {
    const ch = new StubChannel();
    const { session } = mkSession(ch);
    // Typing single chars one at a time — must reach the channel without
    // waiting for Enter, otherwise the shell cannot echo and the user
    // sees nothing in the terminal.
    session.write(Buffer.from('l'), 'human');
    session.write(Buffer.from('s'), 'human');
    session.write(Buffer.from(' -la'), 'human');
    expect(ch.written.map((b) => b.toString())).toEqual(['l', 's', ' -la']);
    expect(session.totalInputBytes).toBe(6);
  });

  it('write() AI partial input is also forwarded immediately (mirrors human)', () => {
    const ch = new StubChannel();
    const { session } = mkSession(ch);
    // AI calling SshConsoleSend with no newline used to buffer the
    // bytes server-side, which made the shell go silent and looked like
    // a freeze. Forward immediately so the PTY echoes the characters
    // the same way it does when a human types.
    session.write(Buffer.from('ls'), 'ai');
    expect(ch.written.length).toBe(1);
    expect(ch.written[0]!.toString()).toBe('ls');
    session.write(Buffer.from(' -la\n'), 'ai');       // LF → CR via normalize
    expect(ch.written.length).toBe(2);
    expect(ch.written[1]!.toString()).toBe(' -la\r');
  });

  it('write() preserves control bytes (Ctrl-C) for both sources', () => {
    const ch = new StubChannel();
    const { session } = mkSession(ch);
    session.write(Buffer.from([0x03]), 'ai');
    session.write(Buffer.from([0x03]), 'human');
    expect(ch.written.map((b) => b[0])).toEqual([0x03, 0x03]);
  });

  it('write() converts every LF in a multi-line AI input', () => {
    const ch = new StubChannel();
    const { session } = mkSession(ch);
    session.write(Buffer.from('line1\nline2\nline3\n'), 'ai');
    // Each '\n' (0x0a) becomes '\r' (0x0d) so the remote shell treats each
    // line as Enter.
    const all = Buffer.concat(ch.written).toString();
    expect(all).toBe('line1\rline2\rline3\r');
    expect(all.indexOf('\n')).toBe(-1);
  });

  it('resize() calls channel.setWindow + headless.resize', () => {
    const ch = new StubChannel();
    const { session } = mkSession(ch);
    session.resize(100, 40);
    expect(session.cols).toBe(100);
    expect(session.rows).toBe(40);
    expect(ch.windowChanges).toEqual([{ rows: 40, cols: 100 }]);
  });

  it('close() is idempotent, ends the client, and records audit', async () => {
    const ch = new StubChannel();
    const { session, audit, client } = mkSession(ch);
    await session.close('idle_timeout');
    await session.close('idle_timeout');
    expect(ch.ended).toBe(true);
    expect(client.ended).toBe(true);
    expect(audit.beginAndComplete).toHaveBeenCalledTimes(1);
    const call = audit.beginAndComplete.mock.calls[0]![0];
    expect(call.action).toBe('ssh.console.close');
    expect(call.detail.reason).toBe('idle_timeout');
  });

  it('client "error" (ECONNRESET) tears the session down without throwing', async () => {
    const ch = new StubChannel();
    const { session, audit, client } = mkSession(ch);
    // Regression for #407: an unhandled ssh2 Client 'error' event crashes
    // the whole Node process. ConsoleSession must own a listener so a
    // dropped transport closes only this session.
    expect(() => client.emit('error', new Error('read ECONNRESET'))).not.toThrow();
    // close() runs on the next tick via the catch-less promise chain.
    await Promise.resolve();
    expect(session.isClosed).toBe(true);
    expect(audit.beginAndComplete).toHaveBeenCalledTimes(1);
    expect(audit.beginAndComplete.mock.calls[0]![0].detail.reason).toBe('host_disconnect');
  });

  it('client "close" tears the session down once', async () => {
    const ch = new StubChannel();
    const { session, audit, client } = mkSession(ch);
    client.emit('close');
    await Promise.resolve();
    expect(session.isClosed).toBe(true);
    expect(audit.beginAndComplete).toHaveBeenCalledTimes(1);
    expect(audit.beginAndComplete.mock.calls[0]![0].detail.reason).toBe('host_disconnect');
  });

  it('scrollback caps at scrollbackCap', () => {
    const ch = new StubChannel();
    const { session } = mkSession(ch);
    ch.emit('data', Buffer.alloc(2048, 0x61));
    const scroll = session.snapshotScrollback({ maxBytes: 5000 });
    expect(scroll.text.length).toBeLessThanOrEqual(1024);
  });

  describe('viewers', () => {
    it('addViewer / listViewers registers and lists handles', () => {
      const ch = new StubChannel();
      const { session } = mkSession(ch);
      const closeA = vi.fn();
      const closeB = vi.fn();
      session.addViewer({ userId: 'u1', close: closeA });
      session.addViewer({ userId: 'u2', close: closeB });
      expect(session.listViewers().map((v) => v.userId).sort()).toEqual(['u1', 'u2']);
    });

    it('addViewer returns an unsubscribe that removes the handle', () => {
      const ch = new StubChannel();
      const { session } = mkSession(ch);
      const close = vi.fn();
      const unsub = session.addViewer({ userId: 'u1', close });
      expect(session.listViewers()).toHaveLength(1);
      unsub();
      expect(session.listViewers()).toHaveLength(0);
    });

    it('close() clears all viewers', async () => {
      const ch = new StubChannel();
      const { session } = mkSession(ch);
      session.addViewer({ userId: 'u1', close: vi.fn() });
      session.addViewer({ userId: 'u2', close: vi.fn() });
      await session.close('idle_timeout');
      expect(session.listViewers()).toHaveLength(0);
    });
  });
});
