/**
 * Tests for POST /api/local/tasks/:taskId/console/session — the
 * user-initiated SSH console session-open endpoint (Task 2).
 *
 * Strategy: mirror the hermetic stub-subsystem approach from
 * ssh-console.test.ts. We do NOT dial real SSH. The shell-open collaborator
 * (`openShellChannel`) is faked to return a channel/client/fingerprint, and a
 * real `SessionRegistry` is used so we can assert `registry.get(taskId)`
 * actually returns a session after a successful open. The access gate
 * (preflight + accessResolver + host-key check) runs for real inside the
 * shared `openConsoleSession` core the endpoint calls — the test only fakes
 * the network dial, not the gate.
 */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { createConsoleSessionRouter } from './console-ws-api.js';
import { finalizeServer } from './server.js';
import { preflight, type SshSubsystem } from '../engine/tools/ssh.js';
import { SessionRegistry } from '../ssh/console-registry.js';
import type { SimpleTask, SimpleUser } from './console-ws-api.js';

function mkConn(overrides: Partial<{ hostKeyVerifiedAt: string | null; enabled: boolean }> = {}) {
  return {
    id: 'conn-1',
    ownerId: 'owner-1',
    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: overrides.hostKeyVerifiedAt === undefined ? '2026-01-01' : overrides.hostKeyVerifiedAt,
    hostKeyPending: false,
    hostKeyPendingB64: null,
    hostKeyPendingFingerprint: null,
    hostKeyPendingToken: null,
    hostKeyPendingSource: null,
    commandDenyPatterns: null,
    commandAllowPatterns: null,
    remotePathPrefix: '/',
    allowRemoteUnrestricted: true,
    allowPrivateAddresses: true,
    enabled: overrides.enabled === undefined ? true : overrides.enabled,
    disabledByAdmin: false,
    disabledByAdminReason: null,
    disabledByAdminAt: null,
    disabledByAdminUserId: null,
    createdAt: '2026-01-01',
    updatedAt: '2026-01-01',
  };
}

interface Harness {
  sub: SshSubsystem;
  registry: SessionRegistry;
  openShellChannel: ReturnType<typeof vi.fn>;
  closeForTaskSpy: ReturnType<typeof vi.fn>;
}

function mkSub(opts: {
  conn?: ReturnType<typeof mkConn>;
  accessAllowed?: boolean;
  accessReason?: string;
  isAdmin?: boolean;
} = {}): Harness {
  const conn = opts.conn ?? mkConn();
  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 = new SessionRegistry({
    idleTimeoutMs: 60_000,
    maxSessionDurationMs: 600_000,
    maxSessionsPerConnection: 3,
  });
  // Wrap closeForTask so we can assert force-replace behavior without
  // depending on a real channel teardown.
  const closeForTaskSpy = vi.fn(registry.closeForTask.bind(registry));
  (registry as any).closeForTask = closeForTaskSpy;

  const connectionRepo = {
    resolveConnection: vi.fn().mockReturnValue(conn),
  };
  const abuseRepo = {
    isLocked: vi.fn().mockReturnValue({ locked: false }),
    checkAndRecordFailure: vi.fn(),
    recordSuccess: vi.fn(),
  };
  const accessResolver = {
    resolveAccess: vi.fn().mockReturnValue(
      opts.accessAllowed === false
        ? { allowed: false, reason: opts.accessReason ?? 'no_grant' }
        : { 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: opts.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, registry, openShellChannel, closeForTaskSpy };
}

const OWNER: SimpleUser = { id: 'owner-1', role: 'user' };

function mkTask(overrides: Partial<SimpleTask> = {}): SimpleTask {
  return {
    id: '1',
    ownerId: 'owner-1',
    visibility: 'private',
    pieceName: 'ssh-console',
    ...overrides,
  };
}

function buildApp(opts: {
  sub: SshSubsystem;
  user?: SimpleUser | null;
  resolveTask?: (id: string, user: SimpleUser) => Promise<SimpleTask | null>;
}) {
  const app = express();
  const user = opts.user === undefined ? OWNER : opts.user;
  if (user) {
    app.use((req, _res, next) => { (req as any).user = user; next(); });
  }
  // Mirrors the production mount in server.ts: NO mount-level express.json()
  // — the session route carries its own scoped parser.
  app.use(
    '/api',
    createConsoleSessionRouter({
      sub: opts.sub,
      preflight,
      requireAuth: (_req: any, _res: any, next: any) => next(),
      resolveTask: opts.resolveTask ?? (async () => mkTask()),
    }),
  );
  return app;
}

describe('POST /api/local/tasks/:taskId/console/session', () => {
  beforeEach(() => vi.clearAllMocks());

  it('does not intercept large JSON bodies destined for other /api routes (issue: PR #431 mounted a 100kb parser on all of /api)', async () => {
    const h = mkSub();
    const app = buildApp({ sub: h.sub });
    // Downstream route with its own large-limit parser, mounted after the
    // console router — mirrors the /api/local/tasks attachment upload path.
    app.post('/api/local/tasks', express.json({ limit: '5mb' }), (req, res) => {
      res.json({ received: (req.body?.attachments?.[0]?.contentBase64 ?? '').length });
    });
    finalizeServer(app); // production 404 + error handlers
    const big = 'a'.repeat(300 * 1024); // well over express.json's 100kb default
    const res = await request(app)
      .post('/api/local/tasks')
      .send({ attachments: [{ name: 'x.pdf', contentBase64: big }] });
    expect(res.status).toBe(200);
    expect(res.body.received).toBe(big.length);
  });

  it('rejects oversized bodies on the session route itself with 413 (scoped 4kb limit + production error handler)', async () => {
    const h = mkSub();
    const app = buildApp({ sub: h.sub });
    finalizeServer(app); // production error handler must keep this a 413, not a generic 500
    const res = await request(app)
      .post('/api/local/tasks/1/console/session')
      .send({ connection_id: 'conn-1', padding: 'x'.repeat(8 * 1024) });
    expect(res.status).toBe(413);
    expect(res.body.error).toMatch(/too large/i);
  });

  it('connection owner → 200 and a session is registered for the task', async () => {
    const h = mkSub();
    const app = buildApp({ sub: h.sub });
    const res = await request(app)
      .post('/api/local/tasks/1/console/session')
      .send({ connection_id: 'conn-1' });
    expect(res.status).toBe(200);
    expect(res.body.ok).toBe(true);
    expect(res.body.connection_id).toBe('conn-1');
    expect(h.openShellChannel).toHaveBeenCalledTimes(1);
    // The real registry now holds a live session keyed by the task id.
    expect(h.registry.get('1')).toBeTruthy();
    expect(h.registry.get('1')!.connectionId).toBe('conn-1');
  });

  it('non-owner without grant → 403 no_grant', async () => {
    const h = mkSub({ accessAllowed: false, accessReason: 'no_grant' });
    const app = buildApp({
      sub: h.sub,
      user: { id: 'someone-else', role: 'user' },
    });
    const res = await request(app)
      .post('/api/local/tasks/1/console/session')
      .send({ connection_id: 'conn-1' });
    expect(res.status).toBe(403);
    expect(res.body.error).toBe('no_grant');
    expect(h.openShellChannel).not.toHaveBeenCalled();
    expect(h.registry.get('1')).toBeNull();
  });

  it('host key not verified → 409 host_key_not_verified', async () => {
    const h = mkSub({ conn: mkConn({ hostKeyVerifiedAt: null }) });
    const app = buildApp({ sub: h.sub });
    const res = await request(app)
      .post('/api/local/tasks/1/console/session')
      .send({ connection_id: 'conn-1' });
    expect(res.status).toBe(409);
    expect(res.body.error).toBe('host_key_not_verified');
    expect(h.openShellChannel).not.toHaveBeenCalled();
  });

  it('task not visible to the user → 404 task_not_found', async () => {
    const h = mkSub();
    const app = buildApp({ sub: h.sub, resolveTask: async () => null });
    const res = await request(app)
      .post('/api/local/tasks/999/console/session')
      .send({ connection_id: 'conn-1' });
    expect(res.status).toBe(404);
    expect(res.body.error).toBe('task_not_found');
    expect(h.openShellChannel).not.toHaveBeenCalled();
  });

  it('already-active same connection → 200 already_active:true (no re-dial)', async () => {
    const h = mkSub();
    const app = buildApp({ sub: h.sub });
    // First open establishes the session.
    const first = await request(app)
      .post('/api/local/tasks/1/console/session')
      .send({ connection_id: 'conn-1' });
    expect(first.status).toBe(200);
    expect(h.openShellChannel).toHaveBeenCalledTimes(1);
    // Second open on the same connection reuses it.
    const second = await request(app)
      .post('/api/local/tasks/1/console/session')
      .send({ connection_id: 'conn-1' });
    expect(second.status).toBe(200);
    expect(second.body.ok).toBe(true);
    expect(second.body.already_active).toBe(true);
    // Still only one dial total.
    expect(h.openShellChannel).toHaveBeenCalledTimes(1);
  });

  it('already-active different connection without force_replace → 409, with it → 200', async () => {
    const h = mkSub();
    // conn-1 resolves first; for the swap, return conn-2.
    const conn2 = mkConn();
    (conn2 as any).id = 'conn-2';
    const app = buildApp({ sub: h.sub });

    // Open conn-1.
    const first = await request(app)
      .post('/api/local/tasks/1/console/session')
      .send({ connection_id: 'conn-1' });
    expect(first.status).toBe(200);
    expect(h.openShellChannel).toHaveBeenCalledTimes(1);

    // conn-2 now resolves for the swap attempts.
    (h.sub.connectionRepo.resolveConnection as any).mockReturnValue(conn2);

    // Swap without force → 409 connection_change_requires_force.
    const noForce = await request(app)
      .post('/api/local/tasks/1/console/session')
      .send({ connection_id: 'conn-2' });
    expect(noForce.status).toBe(409);
    expect(noForce.body.error).toBe('connection_change_requires_force');
    expect(h.openShellChannel).toHaveBeenCalledTimes(1); // no new dial

    // Swap with force → 200, old closed, new dialed.
    const force = await request(app)
      .post('/api/local/tasks/1/console/session')
      .send({ connection_id: 'conn-2', force_replace: true });
    expect(force.status).toBe(200);
    expect(force.body.ok).toBe(true);
    expect(h.closeForTaskSpy).toHaveBeenCalledWith('1', 'connection_change');
    expect(h.openShellChannel).toHaveBeenCalledTimes(2);
    expect(h.registry.get('1')!.connectionId).toBe('conn-2');
  });

  it('missing connection_id → 400', async () => {
    const h = mkSub();
    const app = buildApp({ sub: h.sub });
    const res = await request(app)
      .post('/api/local/tasks/1/console/session')
      .send({});
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('missing_connection_id');
  });
});
