/**
 * Unit tests for the SSH Console tools (SshConsoleEnsure / Send / Snapshot).
 *
 * Strategy: stub the full SshSubsystem rather than booting repos — these
 * tests focus on the orchestration logic (find-or-open, deny check,
 * snapshot routing). The 12-step preflight is already covered by the
 * SshExec tests in ssh.test.ts.
 */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { executeTool, TOOL_DEFS, unescapeAiInput } from './ssh-console.js';
import { setSshSubsystem, type SshSubsystem } from './ssh.js';
import type { ToolContext } from './core.js';

describe('unescapeAiInput', () => {
  it('passes real LF through unchanged', () => {
    expect(unescapeAiInput('ls -la\n')).toBe('ls -la\n');
  });

  it('converts literal 2-char "\\\\n" to real LF', () => {
    // source '\\n' = 2 chars (backslash + n); should become real LF.
    expect(unescapeAiInput('ls -la\\n')).toBe('ls -la\n');
  });

  it('converts literal 2-char "\\\\r" to real CR', () => {
    expect(unescapeAiInput('uptime\\r')).toBe('uptime\r');
  });

  it('converts literal "\\\\t" and "\\\\0"', () => {
    expect(unescapeAiInput('a\\tb')).toBe('a\tb');
    expect(unescapeAiInput('a\\0b')).toBe('a\0b');
  });

  it('converts \\xHH hex escapes', () => {
    expect(unescapeAiInput('\\x03')).toBe('\x03');           // Ctrl-C
    expect(unescapeAiInput('\\x1b:q!\\n')).toBe('\x1b:q!\n'); // Esc + vim exit
  });

  it('handles double-backslash correctly', () => {
    // source '\\\\' = 2 chars (\\); should become single backslash.
    expect(unescapeAiInput('a\\\\b')).toBe('a\\b');
  });

  it('preserves unknown escapes literally', () => {
    expect(unescapeAiInput('foo\\q')).toBe('foo\\q');
  });

  it('does not touch a string with no backslashes', () => {
    expect(unescapeAiInput('hello world')).toBe('hello world');
    expect(unescapeAiInput('')).toBe('');
  });
});

function mkConn(overrides: Partial<{
  commandDenyPatterns: string | null;
  commandAllowPatterns: string | null;
}> = {}) {
  return {
    id: 'conn-1',
    ownerId: 'u1',
    label: 'test',
    host: 'localhost',
    port: 22,
    username: 'me',
    privateKeyEnc: Buffer.alloc(0),
    passphraseEnc: null,
    keyVersion: 1,
    keyFingerprint: 'fp-key',
    hostKeyType: 'ssh-ed25519',
    hostKeyB64: 'aaa',
    hostKeyFingerprint: 'fp',
    hostKeyRecordedAt: '2026-01-01',
    hostKeyVerifiedAt: '2026-01-01',
    hostKeyPending: false,
    hostKeyPendingB64: null,
    hostKeyPendingFingerprint: null,
    hostKeyPendingToken: null,
    hostKeyPendingSource: null,
    commandDenyPatterns: overrides.commandDenyPatterns ?? null,
    commandAllowPatterns: overrides.commandAllowPatterns ?? null,
    remotePathPrefix: '/',
    allowRemoteUnrestricted: true,
    allowPrivateAddresses: true,
    enabled: true,
    disabledByAdmin: false,
    disabledByAdminReason: null,
    disabledByAdminAt: null,
    disabledByAdminUserId: null,
    createdAt: '2026-01-01',
    updatedAt: '2026-01-01',
  };
}

function mkStubSubsystem() {
  const audit = {
    beginAndComplete: vi.fn().mockReturnValue(1),
    begin: vi.fn().mockReturnValue(1),
    complete: vi.fn(),
    listAuditRows: vi.fn(),
    pruneOlderThan: vi.fn(),
    promotePendingToAborted: vi.fn(),
  };
  const registry = {
    get: vi.fn().mockReturnValue(null),
    register: vi.fn(),
    enforceCap: vi.fn().mockReturnValue([]),
    closeForTask: vi.fn().mockResolvedValue(undefined),
    listAll: vi.fn().mockReturnValue([]),
    listForConnection: vi.fn().mockReturnValue([]),
    sweep: vi.fn(),
    startSweepTimer: vi.fn(),
    stopSweepTimer: vi.fn(),
    shutdown: vi.fn(),
  };
  const connectionRepo = {
    resolveConnection: vi.fn().mockReturnValue(mkConn()),
  };
  const abuseRepo = {
    isLocked: vi.fn().mockReturnValue({ locked: false }),
    checkAndRecordFailure: vi.fn(),
    recordSuccess: vi.fn(),
  };
  const accessResolver = { resolveAccess: vi.fn().mockReturnValue({ allowed: true }) };
  const channel = {
    write: vi.fn(),
    end: vi.fn(),
    setWindow: vi.fn(),
    on: vi.fn(),
  };
  const client = { end: vi.fn(), on: vi.fn() };
  const openShellChannel = vi.fn().mockResolvedValue({
    channel,
    client,
    hostFingerprint: 'SHA256:fake',
  });
  const sub = {
    connectionRepo,
    auditRepo: audit,
    abuseRepo,
    accessResolver,
    sessionRegistry: registry,
    openShellChannel,
    getUserAccess: () => ({ isAdmin: false, orgIds: [] }),
    decryptKeyMaterial: () => Buffer.alloc(0),
    decryptPassphrase: () => null,
    sshExec: vi.fn(),
    sshUpload: vi.fn(),
    sshDownload: vi.fn(),
    maintenance: { isActive: () => false, snapshot: () => ({ active: false }), enter: () => {}, exit: () => {} } as SshSubsystem['maintenance'],
    config: {
      enabled: true,
      allowPrivateAddresses: true,
      callTimeoutSeconds: 30,
      maxOutputBytes: 1024,
      maxUploadSizeMb: 10,
      maxDownloadSizeMb: 10,
      auditRetentionDays: 90,
      adminBypassesGrants: true,
      abuseWindowMinutes: 10,
      abuseFailureThreshold: 5,
      abuseLockMinutes: 30,
      console: {
        enabled: true,
        idleTimeoutSeconds: 60,
        maxSessionDurationSeconds: 600,
        scrollbackBytes: 4096,
        maxSessionsPerConnection: 3,
        maxInputBytesPerSend: 1024,
        autoInjectScreenLines: 24,
        defaultCols: 80,
        defaultRows: 24,
      },
    },
  } as unknown as SshSubsystem;
  return { sub, audit, registry, openShellChannel, connectionRepo, channel };
}

function mkCtx(overrides: Partial<ToolContext> = {}): ToolContext {
  return {
    workspacePath: '/tmp',
    editAllowed: true,
    taskId: 'task-1',
    userId: 'u1',
    ownerId: 'u1',
    jobId: 'j1',
    pieceName: 'p',
    allowedSshConnections: ['*'],
    ...overrides,
  };
}

describe('SshConsoleEnsure', () => {
  beforeEach(() => setSshSubsystem(null));

  it('is registered in TOOL_DEFS', () => {
    expect(TOOL_DEFS.SshConsoleEnsure).toBeDefined();
    expect(TOOL_DEFS.SshConsoleSend).toBeDefined();
    expect(TOOL_DEFS.SshConsoleSnapshot).toBeDefined();
  });

  it('opens new session when none exists', async () => {
    const { sub, registry, openShellChannel } = mkStubSubsystem();
    setSshSubsystem(sub);
    const res = await executeTool('SshConsoleEnsure', { connection_id: 'conn-1' }, mkCtx());
    expect(res?.isError).toBe(false);
    expect(openShellChannel).toHaveBeenCalled();
    expect(registry.register).toHaveBeenCalled();
  });

  it('reuses existing session for same task + connection', async () => {
    const { sub, registry, openShellChannel } = mkStubSubsystem();
    const existing = {
      localTaskId: 'task-1',
      connectionId: 'conn-1',
      cols: 80,
      rows: 24,
      isClosed: false,
    };
    registry.get.mockReturnValue(existing);
    setSshSubsystem(sub);
    const res = await executeTool('SshConsoleEnsure', { connection_id: 'conn-1' }, mkCtx());
    expect(res?.isError).toBe(false);
    expect(openShellChannel).not.toHaveBeenCalled();
  });

  it('rejects mismatching connection_id by default and surfaces the active id', async () => {
    const { sub, registry } = mkStubSubsystem();
    registry.get.mockReturnValue({
      localTaskId: 'task-1',
      connectionId: 'conn-OLD',
      isClosed: false,
      startedAt: Date.now() - 60_000,
      lastActivityAt: Date.now() - 5_000,
    });
    setSshSubsystem(sub);
    const res = await executeTool('SshConsoleEnsure', { connection_id: 'conn-NEW' }, mkCtx());
    expect(res?.isError).toBe(true);
    expect(res?.output).toContain('conn-OLD');
    expect(res?.output).toContain('force_replace');
    expect(registry.closeForTask).not.toHaveBeenCalled();
  });

  it('closes old session and opens new when force_replace=true', async () => {
    const { sub, registry } = mkStubSubsystem();
    registry.get.mockReturnValue({
      localTaskId: 'task-1',
      connectionId: 'conn-OLD',
      isClosed: false,
      startedAt: Date.now() - 60_000,
      lastActivityAt: Date.now() - 5_000,
    });
    setSshSubsystem(sub);
    const res = await executeTool(
      'SshConsoleEnsure',
      { connection_id: 'conn-NEW', force_replace: true },
      mkCtx(),
    );
    expect(registry.closeForTask).toHaveBeenCalledWith('task-1', 'connection_change');
    expect(res?.isError).toBe(false);
  });

  it('rejects when console.enabled is false', async () => {
    const { sub } = mkStubSubsystem();
    sub.config.console.enabled = false;
    setSshSubsystem(sub);
    const res = await executeTool('SshConsoleEnsure', { connection_id: 'conn-1' }, mkCtx());
    expect(res?.isError).toBe(true);
  });
});

describe('SshConsoleSend', () => {
  beforeEach(() => setSshSubsystem(null));

  it('writes input to session and returns screen snapshot', async () => {
    const { sub, registry } = mkStubSubsystem();
    const writes: Buffer[] = [];
    const fakeSession = {
      localTaskId: 'task-1',
      connectionId: 'conn-1',
      cols: 80,
      rows: 24,
      isClosed: false,
      write: (b: Buffer) => writes.push(b),
      snapshotScreen: () => ({
        cols: 80,
        rows: 24,
        text: 'prompt$ ls\n',
        cursor: { x: 0, y: 1 },
      }),
      totalOutputBytes: 100,
    };
    registry.get.mockReturnValue(fakeSession);
    setSshSubsystem(sub);
    const res = await executeTool(
      'SshConsoleSend',
      { connection_id: 'conn-1', input: 'ls\n', wait_ms: 50 },
      mkCtx(),
    );
    expect(res?.isError).toBe(false);
    expect(writes[0]!.toString()).toBe('ls\n');
    const parsed = JSON.parse(res!.output);
    expect(parsed.bytes_sent).toBe(3);
    expect(parsed.screen_after).toContain('prompt');
    expect(parsed.warning).toBeUndefined();
  });

  it('auto-appends \\n when input is printable without line terminator', async () => {
    const { sub, registry, connectionRepo } = mkStubSubsystem();
    connectionRepo.resolveConnection.mockReturnValue(mkConn());
    const fakeSession = {
      localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24,
      isClosed: false,
      write: vi.fn(),
      snapshotScreen: () => ({ cols: 80, rows: 24, text: 'prompt$ ', cursor: { x: 0, y: 0 } }),
      totalOutputBytes: 0,
    };
    registry.get.mockReturnValue(fakeSession);
    setSshSubsystem(sub);
    const res = await executeTool(
      'SshConsoleSend',
      { connection_id: 'conn-1', input: 'ls -la' }, // no newline
      mkCtx(),
    );
    expect(res?.isError).toBe(false);
    const parsed = JSON.parse(res!.output);
    expect(parsed.auto_newline_appended).toBe(true);
    expect(parsed.warning).toBeUndefined();
    expect(parsed.bytes_sent).toBe(7); // 'ls -la' (6) + '\n' (1)
    // Verify the bytes actually written to PTY include the appended newline
    expect(fakeSession.write).toHaveBeenCalledTimes(1);
    const writtenBuf = fakeSession.write.mock.calls[0][0] as Buffer;
    expect(writtenBuf.toString('utf8')).toBe('ls -la\n');
  });

  it('does NOT auto-append newline for control bytes (Ctrl-C / Ctrl-D / Esc / Tab)', async () => {
    const { sub, registry, connectionRepo } = mkStubSubsystem();
    connectionRepo.resolveConnection.mockReturnValue(mkConn());
    const fakeSession = {
      localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24,
      isClosed: false, write: vi.fn(),
      snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 } }),
      totalOutputBytes: 0,
    };
    registry.get.mockReturnValue(fakeSession);
    setSshSubsystem(sub);
    for (const input of ['\x03', '\x04', '\x1b:q!', '\t']) {
      const res = await executeTool('SshConsoleSend', { connection_id: 'conn-1', input }, mkCtx());
      const parsed = JSON.parse(res!.output);
      expect(parsed.auto_newline_appended, `for ${JSON.stringify(input)}`).toBeFalsy();
    }
  });

  it('rejects when deny-list line hit', async () => {
    const { sub, registry, connectionRepo } = mkStubSubsystem();
    connectionRepo.resolveConnection.mockReturnValue(
      mkConn({ commandDenyPatterns: '^rm -rf /\\b' }),
    );
    const fakeSession = {
      localTaskId: 'task-1',
      connectionId: 'conn-1',
      cols: 80,
      rows: 24,
      isClosed: false,
      write: vi.fn(),
      snapshotScreen: () => ({
        cols: 80,
        rows: 24,
        text: '',
        cursor: { x: 0, y: 0 },
      }),
      totalOutputBytes: 0,
    };
    registry.get.mockReturnValue(fakeSession);
    setSshSubsystem(sub);
    const res = await executeTool(
      'SshConsoleSend',
      { connection_id: 'conn-1', input: 'rm -rf /\n' },
      mkCtx(),
    );
    expect(res?.isError).toBe(true);
    expect(fakeSession.write).not.toHaveBeenCalled();
  });

  it('rejects input over max_input_bytes_per_send', async () => {
    const { sub, registry } = mkStubSubsystem();
    sub.config.console.maxInputBytesPerSend = 4;
    registry.get.mockReturnValue({
      localTaskId: 'task-1',
      connectionId: 'conn-1',
      cols: 80,
      rows: 24,
      isClosed: false,
      write: vi.fn(),
      snapshotScreen: () => ({
        cols: 80,
        rows: 24,
        text: '',
        cursor: { x: 0, y: 0 },
      }),
      totalOutputBytes: 0,
    } as any);
    setSshSubsystem(sub);
    const res = await executeTool(
      'SshConsoleSend',
      { connection_id: 'conn-1', input: '12345' },
      mkCtx(),
    );
    expect(res?.isError).toBe(true);
  });

  it('omitting connection_id uses the active session', async () => {
    const { sub, registry, connectionRepo } = mkStubSubsystem();
    connectionRepo.resolveConnection.mockReturnValue(mkConn());
    const writes: Buffer[] = [];
    registry.get.mockReturnValue({
      localTaskId: 'task-1',
      connectionId: 'conn-1',
      cols: 80, rows: 24,
      isClosed: false,
      write: (b: Buffer) => writes.push(b),
      snapshotScreen: () => ({ cols: 80, rows: 24, text: 'ok', cursor: { x: 0, y: 0 } }),
      totalOutputBytes: 0,
    } as any);
    setSshSubsystem(sub);
    const res = await executeTool('SshConsoleSend', { input: 'whoami\n' }, mkCtx());
    expect(res?.isError).toBe(false);
    expect(writes[0]!.toString()).toBe('whoami\n');
  });

  it('rejects mismatching connection_id and surfaces the active id', async () => {
    const { sub, registry } = mkStubSubsystem();
    registry.get.mockReturnValue({
      localTaskId: 'task-1',
      connectionId: 'conn-ACTIVE',
      cols: 80, rows: 24,
      isClosed: false,
      write: vi.fn(),
      snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 } }),
      totalOutputBytes: 0,
    } as any);
    setSshSubsystem(sub);
    const res = await executeTool(
      'SshConsoleSend',
      { connection_id: 'conn-WRONG', input: 'ls\n' },
      mkCtx(),
    );
    expect(res?.isError).toBe(true);
    expect(res?.output).toContain('conn-ACTIVE');
    expect(res?.output).toContain('force_replace');
  });

  it('errors when no active session and no connection_id provided', async () => {
    const { sub, registry } = mkStubSubsystem();
    registry.get.mockReturnValue(null);
    setSshSubsystem(sub);
    const res = await executeTool('SshConsoleSend', { input: 'ls\n' }, mkCtx());
    expect(res?.isError).toBe(true);
    expect(res?.output).toContain('SshListConnections');
  });
});

describe('SshConsoleSnapshot', () => {
  beforeEach(() => setSshSubsystem(null));

  it('returns screen when kind=screen', async () => {
    const { sub, registry } = mkStubSubsystem();
    registry.get.mockReturnValue({
      localTaskId: 'task-1',
      connectionId: 'conn-1',
      cols: 80,
      rows: 24,
      isClosed: false,
      snapshotScreen: () => ({ cols: 80, rows: 24, text: 'screen', cursor: { x: 1, y: 2 } }),
      snapshotScrollback: () => ({ text: 'scroll', byteCount: 6, truncated: false }),
    } as any);
    setSshSubsystem(sub);
    const res = await executeTool('SshConsoleSnapshot', { connection_id: 'conn-1' }, mkCtx());
    expect(res?.isError).toBe(false);
    const data = JSON.parse(res!.output);
    expect(data.kind).toBe('screen');
    expect(data.text).toBe('screen');
    expect(data.cursor).toEqual({ x: 1, y: 2 });
  });

  it('returns scrollback when kind=scrollback', async () => {
    const { sub, registry } = mkStubSubsystem();
    registry.get.mockReturnValue({
      localTaskId: 'task-1',
      connectionId: 'conn-1',
      cols: 80,
      rows: 24,
      isClosed: false,
      snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 } }),
      snapshotScrollback: (_opts: { maxBytes: number }) => ({
        text: 'tail',
        byteCount: 9999,
        truncated: true,
      }),
    } as any);
    setSshSubsystem(sub);
    const res = await executeTool(
      'SshConsoleSnapshot',
      { connection_id: 'conn-1', kind: 'scrollback', max_bytes: 4 },
      mkCtx(),
    );
    expect(res?.isError).toBe(false);
    const data = JSON.parse(res!.output);
    expect(data.kind).toBe('scrollback');
    expect(data.text).toBe('tail');
    expect(data.truncated).toBe(true);
  });

  it('returns error when no active session', async () => {
    const { sub, registry } = mkStubSubsystem();
    registry.get.mockReturnValue(null);
    setSshSubsystem(sub);
    const res = await executeTool('SshConsoleSnapshot', { connection_id: 'conn-1' }, mkCtx());
    expect(res?.isError).toBe(true);
    expect(res?.output).toContain('no live session');
  });

  it('omitting connection_id uses the active session', async () => {
    const { sub, registry } = mkStubSubsystem();
    registry.get.mockReturnValue({
      localTaskId: 'task-1',
      connectionId: 'conn-1',
      cols: 80, rows: 24,
      isClosed: false,
      snapshotScreen: () => ({ cols: 80, rows: 24, text: 'auto', cursor: { x: 0, y: 0 } }),
      snapshotScrollback: () => ({ text: '', byteCount: 0, truncated: false }),
    } as any);
    setSshSubsystem(sub);
    const res = await executeTool('SshConsoleSnapshot', {}, mkCtx());
    expect(res?.isError).toBe(false);
    const data = JSON.parse(res!.output);
    expect(data.text).toBe('auto');
  });

  it('rejects mismatching connection_id and surfaces the active id', async () => {
    const { sub, registry } = mkStubSubsystem();
    registry.get.mockReturnValue({
      localTaskId: 'task-1',
      connectionId: 'conn-ACTIVE',
      cols: 80, rows: 24,
      isClosed: false,
      snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 } }),
    } as any);
    setSshSubsystem(sub);
    const res = await executeTool(
      'SshConsoleSnapshot',
      { connection_id: 'conn-WRONG' },
      mkCtx(),
    );
    expect(res?.isError).toBe(true);
    expect(res?.output).toContain('conn-ACTIVE');
  });
});
