import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import express from 'express';
import request from 'supertest';

// Mocks the 'ws' library's WebSocketServer so attachConsoleWs's upgrade
// handler can be exercised end-to-end (real URL parsing + session
// selection + decideAccess) without a real TCP handshake. handleUpgrade
// synchronously invokes the callback with a minimal fake WebSocket that
// records what handleConsoleSocket sends it — the attach frame's
// connection_id tells us which session actually got wired up.
const { wsInstances, handleUpgradeMock } = vi.hoisted(() => {
  const wsInstances: any[] = [];
  const handleUpgradeMock = (_req: any, _socket: any, _head: any, cb: (ws: any) => void) => {
    const fakeWs = {
      readyState: 1,
      OPEN: 1,
      sent: [] as Array<{ kind: 'text' | 'binary'; data: any }>,
      send(data: any, opts?: { binary?: boolean }) {
        if (opts?.binary) this.sent.push({ kind: 'binary', data });
        else this.sent.push({ kind: 'text', data: JSON.parse(data) });
      },
      on() { return this; },
      ping() {},
      terminate() {},
      close() {},
    };
    wsInstances.push(fakeWs);
    cb(fakeWs);
  };
  return { wsInstances, handleUpgradeMock };
});
vi.mock('ws', () => ({
  // Must be a real `function` (not an arrow) so `new WebSocketServer(...)`
  // in the module under test can construct it.
  WebSocketServer: vi.fn(function WebSocketServer() {
    return { handleUpgrade: handleUpgradeMock };
  }),
}));

import {
  decideAccess,
  handleConsoleSocket,
  createConsoleStatusRouter,
  createConsoleSessionRouter,
  createConsoleSessionsRouter,
  createConsoleSessionCloseRouter,
  computeCanWrite,
  parseConsoleWsUrl,
  selectConsoleSession,
  attachConsoleWs,
} from './console-ws-api.js';
import { runMigrations } from '../db/migrate.js';
import { createAccessResolver } from '../ssh/access.js';
import { createGrantsRepo } from '../ssh/grants-repo.js';
import type { SshConnection } from '../ssh/connection-repo.js';

const flush = () => new Promise((r) => setImmediate(r));

describe('decideAccess', () => {
  const baseTask = { id: 't1', ownerId: 'u1', visibility: 'private', pieceName: 'ssh-console' };

  it('rejects unauthenticated', () => {
    const r = decideAccess({ user: null, task: baseTask, session: null, accessAllowed: false });
    expect(r.allowed).toBe(false);
    expect((r as any).reason).toBe('unauthenticated');
  });

  it('rejects when task not visible', () => {
    const r = decideAccess({ user: { id: 'u2', role: 'user' } as any, task: null, session: null, accessAllowed: false });
    expect(r.allowed).toBe(false);
    expect((r as any).reason).toBe('task_not_visible');
  });

  it('rejects when no active session', () => {
    const r = decideAccess({ user: { id: 'u1', role: 'user' } as any, task: baseTask, session: null, accessAllowed: true });
    expect(r.allowed).toBe(false);
    expect((r as any).reason).toBe('no_session');
  });

  it('rejects when SSH access denied', () => {
    const session = { connectionId: 'c1' } as any;
    const r = decideAccess({ user: { id: 'u1', role: 'user' } as any, task: baseTask, session, accessAllowed: false });
    expect(r.allowed).toBe(false);
    expect((r as any).reason).toBe('no_grant');
  });

  it('owner gets canWrite=true', () => {
    const session = { connectionId: 'c1' } as any;
    const r = decideAccess({ user: { id: 'u1', role: 'user' } as any, task: baseTask, session, accessAllowed: true });
    expect(r.allowed).toBe(true);
    if (r.allowed) expect(r.canWrite).toBe(true);
  });

  it('non-owner with task visibility gets canWrite=false', () => {
    const session = { connectionId: 'c1' } as any;
    const r = decideAccess({ user: { id: 'other', role: 'user' } as any, task: { ...baseTask, visibility: 'org' } as any, session, accessAllowed: true });
    expect(r.allowed).toBe(true);
    if (r.allowed) expect(r.canWrite).toBe(false);
  });

  it('admin always canWrite=true', () => {
    const session = { connectionId: 'c1' } as any;
    const r = decideAccess({ user: { id: 'admin', role: 'admin' } as any, task: baseTask, session, accessAllowed: true });
    expect(r.allowed).toBe(true);
    if (r.allowed) expect(r.canWrite).toBe(true);
  });
});

class FakeWS extends EventEmitter {
  readyState = 1; // OPEN
  OPEN = 1;
  sent: Array<{ kind: 'text' | 'binary'; data: any }> = [];
  send(data: any, opts?: { binary?: boolean }) {
    if (opts?.binary) this.sent.push({ kind: 'binary', data });
    else this.sent.push({ kind: 'text', data: JSON.parse(data) });
  }
}

function fakeSessionForWs() {
  return {
    cols: 80, rows: 24,
    connectionId: 'c1',
    scrollbackBytes: () => Buffer.alloc(0),
    onOutput: (_cb: any) => () => {},
    write: vi.fn(),
    resize: vi.fn(),
    addViewer: vi.fn(() => () => {}),
    listViewers: vi.fn(() => []),
  } as any;
}

describe('handleConsoleSocket', () => {
  it('drops human input that fails deny-list and emits notice', () => {
    const ws = new FakeWS();
    const session = fakeSessionForWs();
    handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
    ws.emit('message', Buffer.from('rm -rf /\n'), true);
    expect(session.write).not.toHaveBeenCalled();
    const notice = ws.sent.find((s) => s.kind === 'text' && s.data.type === 'notice');
    expect(notice).toBeDefined();
    expect((notice as any).data.severity).toBe('error');
  });

  it('forwards safe human input to session.write', () => {
    const ws = new FakeWS();
    const session = fakeSessionForWs();
    handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
    ws.emit('message', Buffer.from('uptime\n'), true);
    expect(session.write).toHaveBeenCalled();
    const arg = session.write.mock.calls[0][0] as Buffer;
    expect(arg.toString()).toBe('uptime\n');
  });

  it('rejects input when canWrite=false', () => {
    const ws = new FakeWS();
    const session = fakeSessionForWs();
    handleConsoleSocket(ws as any, session, { id: 'u2', role: 'user' }, false, { deny: [], allow: [] });
    ws.emit('message', Buffer.from('uptime\n'), true);
    expect(session.write).not.toHaveBeenCalled();
    const notice = ws.sent.find((s) => s.kind === 'text' && s.data.type === 'notice');
    expect(notice).toBeDefined();
    expect((notice as any).data.severity).toBe('warn');
  });

  it('handles resize text frame when canWrite=true', () => {
    const ws = new FakeWS();
    const session = fakeSessionForWs();
    handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
    ws.emit('message', Buffer.from(JSON.stringify({ type: 'resize', cols: 100, rows: 40 })), false);
    expect(session.resize).toHaveBeenCalledWith(100, 40);
  });

  it('ignores resize text frame when canWrite=false', () => {
    const ws = new FakeWS();
    const session = fakeSessionForWs();
    handleConsoleSocket(ws as any, session, { id: 'u2', role: 'user' }, false, { deny: [], allow: [] });
    ws.emit('message', Buffer.from(JSON.stringify({ type: 'resize', cols: 100, rows: 40 })), false);
    expect(session.resize).not.toHaveBeenCalled();
  });

  it('registers a ViewerHandle with the session on attach', () => {
    const ws = new FakeWS();
    const session = fakeSessionForWs();
    handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
    expect(session.addViewer).toHaveBeenCalledTimes(1);
    const handle = (session.addViewer as any).mock.calls[0][0];
    expect(handle.userId).toBe('u1');
    expect(typeof handle.close).toBe('function');
  });

  it('viewer.close() sends a close message and ws.close(1008, reason)', () => {
    const ws = new FakeWS();
    (ws as any).close = vi.fn();
    const session = fakeSessionForWs();
    handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
    const handle = (session.addViewer as any).mock.calls[0][0];
    handle.close('access_revoked');
    const closeMsg = ws.sent.find((s) => s.kind === 'text' && s.data.type === 'close');
    expect(closeMsg).toBeDefined();
    expect((closeMsg as any).data.reason).toBe('access_revoked');
    expect((ws as any).close).toHaveBeenCalledWith(1008, 'access_revoked');
  });

  it('viewer unsubscribes from session on ws close', () => {
    const ws = new FakeWS();
    const unsubViewer = vi.fn();
    const session = fakeSessionForWs();
    (session.addViewer as any).mockReturnValue(unsubViewer);
    handleConsoleSocket(ws as any, session, { id: 'u1', role: 'user' }, true, { deny: [], allow: [] });
    ws.emit('close');
    expect(unsubViewer).toHaveBeenCalled();
  });
});

// Regression: PR fixing "wss error for non-admin owner of task with piece-specific grant".
// Documents the contract that the WS upgrade access check MUST pass the
// task's pieceName to accessResolver so piece-specific grants match.
// Before fix: server.ts resolveSshAccess hardcoded pieceName: '' → all
// piece-specific grants silently failed (no_grant) even when one existed.
describe('regression: piece-specific grant matching via accessResolver', () => {
  let tmpRoot: string;
  let db: Database.Database;
  const CONN_ID = 'conn-global-1';
  const USER_ID = 'user-non-admin-1';
  const PIECE = 'ssh-console';

  beforeEach(() => {
    tmpRoot = mkdtempSync(join(tmpdir(), 'console-ws-regression-'));
    db = new Database(join(tmpRoot, 'test.db'));
    runMigrations(db);
    // Insert a global connection (ownerId=NULL) so the owner branch in
    // access.ts doesn't short-circuit — forces grant lookup.
    db.prepare(`
      INSERT INTO ssh_connections (id, owner_id, label, host, port, username, private_key_enc, remote_path_prefix, enabled, created_at, updated_at)
      VALUES (?, NULL, 'global', 'host.example', 22, 'u', X'00', '/srv', 1, datetime('now'), datetime('now'))
    `).run(CONN_ID);
    // Insert a piece-specific grant for the user.
    const grantsRepo = createGrantsRepo(db);
    grantsRepo.create({
      connectionId: CONN_ID,
      subjectType: 'user',
      subjectId: USER_ID,
      pieceName: PIECE,
      appliesToAllPieces: false,
      grantedByUserId: 'admin-1',
      reason: 'regression test',
    });
  });

  afterEach(() => {
    db.close();
    rmSync(tmpRoot, { recursive: true, force: true });
  });

  function connection(): SshConnection {
    return {
      id: CONN_ID,
      ownerId: null,
      label: 'global',
      host: 'host.example',
      port: 22,
      username: 'u',
      privateKeyEnc: Buffer.alloc(0),
      passphraseEnc: null,
      keyVersion: 1,
      keyFingerprint: null,
      hostKeyType: null,
      hostKeyB64: null,
      hostKeyFingerprint: null,
      hostKeyRecordedAt: null,
      hostKeyVerifiedAt: null,
      hostKeyPending: false,
      hostKeyPendingB64: null,
      hostKeyPendingFingerprint: null,
      hostKeyPendingToken: null,
      hostKeyPendingSource: null,
      commandDenyPatterns: null,
      commandAllowPatterns: null,
      remotePathPrefix: '/srv',
      enabled: true,
      allowRemoteUnrestricted: false,
      allowPrivateAddresses: false,
      createdAt: '',
      updatedAt: '',
    } as unknown as SshConnection;
  }

  it('access GRANTED when pieceName matches the grant (the fix)', () => {
    const grants = createGrantsRepo(db);
    const resolver = createAccessResolver(grants, { adminBypassesGrants: true });
    const decision = resolver.resolveAccess({
      connection: connection(),
      userId: USER_ID,
      isAdmin: false,
      pieceName: PIECE, // <-- the fix passes the task's actual pieceName
      orgIds: [],
    });
    expect(decision.allowed).toBe(true);
    expect((decision as any).via).toBe('grant');
  });

  it('access DENIED when pieceName is empty (the original bug)', () => {
    const grants = createGrantsRepo(db);
    const resolver = createAccessResolver(grants, { adminBypassesGrants: true });
    const decision = resolver.resolveAccess({
      connection: connection(),
      userId: USER_ID,
      isAdmin: false,
      pieceName: '', // <-- pre-fix server.ts hardcoded this; grant never matches
      orgIds: [],
    });
    expect(decision.allowed).toBe(false);
    expect((decision as any).reason).toBe('no_grant');
  });

  it('access DENIED when pieceName differs from the grant', () => {
    const grants = createGrantsRepo(db);
    const resolver = createAccessResolver(grants, { adminBypassesGrants: true });
    const decision = resolver.resolveAccess({
      connection: connection(),
      userId: USER_ID,
      isAdmin: false,
      pieceName: 'unrelated-piece',
      orgIds: [],
    });
    expect(decision.allowed).toBe(false);
  });
});

describe('createConsoleStatusRouter', () => {
  // Issue #347 regression: the App.tsx-side poller fires
  // GET /api/local/tasks/:taskId/console/status every 5 seconds for
  // the currently-selected local task. Returning 404 when the task is
  // missing / not-visible logged an unsuppressible network error in
  // the browser DevTools console on every tick. The router now returns
  // 200 active=false instead, matching the no-session shape.
  function buildApp(opts: {
    resolveTask?: (id: string, user: any) => Promise<any>;
    registry?: { listForTask: (id: string) => any[] };
    resolveSshAccess?: (user: any, session: any, task: any) => Promise<boolean>;
    user?: any;
  }) {
    const app = express();
    if (opts.user) {
      app.use((req, _res, next) => { (req as any).user = opts.user; next(); });
    }
    app.use('/api', createConsoleStatusRouter({
      registry: (opts.registry ?? { listForTask: () => [] }) as any,
      requireAuth: (_req: any, _res: any, next: any) => next(),
      resolveTask: opts.resolveTask ?? (async () => null),
      resolveSshAccess: opts.resolveSshAccess ?? (async () => true),
    }));
    return app;
  }

  it('returns 200 active=false when task is not visible to the user (was 404)', async () => {
    const app = buildApp({
      user: { id: 'alice', role: 'user' },
      resolveTask: async () => null, // not visible
    });
    const res = await request(app).get('/api/local/tasks/182/console/status');
    expect(res.status).toBe(200);
    expect(res.body).toEqual({ active: false });
  });

  it('returns 200 active=false when task exists but no SSH session is open', async () => {
    const app = buildApp({
      user: { id: 'alice', role: 'user' },
      resolveTask: async () => ({ id: 't1' }),
      registry: { listForTask: () => [] },
    });
    const res = await request(app).get('/api/local/tasks/t1/console/status');
    expect(res.status).toBe(200);
    expect(res.body).toEqual({ active: false });
  });

  it('returns 200 active=true with session metadata when session is live', async () => {
    const now = Date.now();
    const app = buildApp({
      user: { id: 'alice', role: 'user' },
      resolveTask: async () => ({ id: 't1' }),
      registry: { listForTask: () => [{
        connectionId: 'conn-1',
        startedAt: now - 60_000,
        lastActivityAt: now,
        cols: 120,
        rows: 30,
      }] },
    });
    const res = await request(app).get('/api/local/tasks/t1/console/status');
    expect(res.status).toBe(200);
    expect(res.body.active).toBe(true);
    expect(res.body.connection_id).toBe('conn-1');
    expect(res.body.cols).toBe(120);
  });

  it('returns 200 active=false when the only session is unauthorized (per-session recheck)', async () => {
    const now = Date.now();
    const app = buildApp({
      user: { id: 'alice', role: 'user' },
      resolveTask: async () => ({ id: 't1', ownerId: 'bob', visibility: 'org', pieceName: 'ssh-console' }),
      registry: { listForTask: () => [{
        connectionId: 'conn-secret',
        startedAt: now - 60_000,
        lastActivityAt: now,
        cols: 80,
        rows: 24,
      }] },
      resolveSshAccess: async () => false, // authorized for the task, NOT this connection
    });
    const res = await request(app).get('/api/local/tasks/t1/console/status');
    expect(res.status).toBe(200);
    expect(res.body).toEqual({ active: false });
  });

  // Finding 2 (final-review): the poll used to call getMostRecentForTask()
  // and recheck ONLY that one session — an unauthorized most-recent session
  // hid an older, authorized, still-usable one (and with it the whole SSH
  // tab, since detailTabs.ts gates on status.active). The fix reuses
  // selectConsoleSession's omitted-connection_id fallback, which picks the
  // most-recent session among only the AUTHORIZED ones.
  it('returns 200 active=true for an older AUTHORIZED session when the most-recent session is unauthorized', async () => {
    const now = Date.now();
    const app = buildApp({
      user: { id: 'alice', role: 'user' },
      resolveTask: async () => ({ id: 't1', ownerId: 'bob', visibility: 'org', pieceName: 'ssh-console' }),
      registry: { listForTask: () => [
        {
          connectionId: 'conn-secret',
          startedAt: now - 10_000,
          lastActivityAt: now, // most recent
          cols: 80,
          rows: 24,
        },
        {
          connectionId: 'conn-good',
          startedAt: now - 120_000,
          lastActivityAt: now - 60_000, // older, but authorized
          cols: 100,
          rows: 40,
        },
      ] },
      resolveSshAccess: async (_user, session) => session.connectionId === 'conn-good',
    });
    const res = await request(app).get('/api/local/tasks/t1/console/status');
    expect(res.status).toBe(200);
    expect(res.body.active).toBe(true);
    expect(res.body.connection_id).toBe('conn-good');
    expect(res.body.cols).toBe(100);
  });

  it('still returns 401 when there is no authenticated user', async () => {
    const app = buildApp({}); // no user middleware
    const res = await request(app).get('/api/local/tasks/t1/console/status');
    expect(res.status).toBe(401);
  });

  it('no-auth (authActive=false): synthesizes a local admin user instead of 401', async () => {
    let seenUser: any;
    const app = express();
    app.use('/api', createConsoleStatusRouter({
      registry: { listForTask: () => [] } as any,
      requireAuth: (_req: any, _res: any, next: any) => next(),
      resolveTask: async (_id: string, user: any) => { seenUser = user; return null; },
      resolveSshAccess: async () => true,
      authActive: false,
    }));
    const res = await request(app).get('/api/local/tasks/t1/console/status');
    // 200 active=false (task not visible shape), NOT 401 — the synthetic user
    // cleared the auth guard. admin role makes null-owner no-auth tasks visible.
    expect(res.status).toBe(200);
    expect(seenUser).toEqual({ id: 'local', role: 'admin', orgIds: [] });
  });
});

describe('createConsoleSessionRouter', () => {
  it('no-auth (authActive=false): synthesizes a local admin user instead of 401', async () => {
    let seenUser: any;
    const app = express();
    app.use(express.json());
    app.use('/api', createConsoleSessionRouter({
      sub: {} as any,
      preflight: {} as any,
      requireAuth: (_req: any, _res: any, next: any) => next(),
      resolveTask: async (_id: string, user: any) => { seenUser = user; return null; },
      authActive: false,
    }));
    const res = await request(app)
      .post('/api/local/tasks/t1/console/session')
      .send({ connection_id: 'c1' });
    // 404 task_not_found (resolveTask returned null), NOT 401 — proves the
    // synthetic user passed the auth guard before task resolution.
    expect(res.status).toBe(404);
    expect(seenUser).toEqual({ id: 'local', role: 'admin', orgIds: [] });
  });

  it('still returns 401 when auth is active and there is no user', async () => {
    const app = express();
    app.use(express.json());
    app.use('/api', createConsoleSessionRouter({
      sub: {} as any,
      preflight: {} as any,
      requireAuth: (_req: any, _res: any, next: any) => next(),
      resolveTask: async () => null,
    }));
    const res = await request(app)
      .post('/api/local/tasks/t1/console/session')
      .send({ connection_id: 'c1' });
    expect(res.status).toBe(401);
  });
});

describe('computeCanWrite', () => {
  const task = { id: 't1', ownerId: 'owner', visibility: 'private', pieceName: 'ssh-console' } as any;
  it('owner → true', () => {
    expect(computeCanWrite({ id: 'owner', role: 'user' }, task)).toBe(true);
  });
  it('admin → true', () => {
    expect(computeCanWrite({ id: 'someone', role: 'admin' }, task)).toBe(true);
  });
  it('non-owner non-admin → false', () => {
    expect(computeCanWrite({ id: 'other', role: 'user' }, task)).toBe(false);
  });
});

describe('createConsoleSessionsRouter', () => {
  function fakeSession(over: Partial<Record<string, any>> = {}) {
    const now = Date.now();
    return {
      connectionId: 'A',
      startedAt: now - 120_000,
      lastActivityAt: now,
      lastAiInputAt: 0,
      cols: 80,
      rows: 24,
      startedByUserId: 'starter',
      isClosed: false,
      ...over,
    } as any;
  }

  function buildApp(opts: {
    user: any;
    task?: any;
    sessions: any[];
    resolveSshAccess: (user: any, session: any, task: any) => Promise<boolean>;
  }) {
    const app = express();
    app.use((req, _res, next) => { (req as any).user = opts.user; next(); });
    app.use('/api', createConsoleSessionsRouter({
      registry: { listForTask: () => opts.sessions } as any,
      requireAuth: (_req: any, _res: any, next: any) => next(),
      resolveTask: async () => opts.task ?? null,
      resolveSshAccess: opts.resolveSshAccess,
      resolveConnectionLabel: (id: string) => `label-${id}`,
    }));
    return app;
  }

  // Core IDOR guard (PR #725 background): for a legacy no-space task a user may
  // be authorized for connection A but NOT B. The list MUST filter per-session,
  // not per-task.
  it('returns only per-session authorized sessions (legacy no-space task)', async () => {
    const sessA = fakeSession({ connectionId: 'A' });
    const sessB = fakeSession({ connectionId: 'B' });
    const app = buildApp({
      user: { id: 'u1', role: 'user' },
      task: { id: 't1', ownerId: 'u1', visibility: 'private', pieceName: 'ssh-console', spaceId: null },
      sessions: [sessA, sessB],
      resolveSshAccess: async (_u, s) => s.connectionId === 'A',
    });
    const res = await request(app).get('/api/local/tasks/t1/console/sessions');
    expect(res.status).toBe(200);
    const ids = res.body.sessions.map((s: any) => s.connection_id);
    expect(ids).toEqual(['A']);
    const only = res.body.sessions[0];
    expect(only.connection_label).toBe('label-A');
    expect(only.can_write).toBe(true); // owner
    expect(only.can_close).toBe(true);
    expect(typeof only.started_at).toBe('string');
    expect(typeof only.last_activity_at).toBe('string');
    expect(only.status).toBe('connected');
    expect(only.agent_active).toBe(false);
    expect(only.cols).toBe(80);
    expect(only.rows).toBe(24);
  });

  it('computes idle status + agent_active window + non-owner read-only', async () => {
    const now = Date.now();
    const sess = fakeSession({
      connectionId: 'A',
      lastActivityAt: now - 120_000, // > 60s → idle
      lastAiInputAt: now - 5_000, // <= 15s → agent active
      startedByUserId: 'someone-else',
    });
    const app = buildApp({
      user: { id: 'viewer', role: 'user' },
      task: { id: 't1', ownerId: 'owner', visibility: 'org', pieceName: 'ssh-console', spaceId: null },
      sessions: [sess],
      resolveSshAccess: async () => true,
    });
    const res = await request(app).get('/api/local/tasks/t1/console/sessions');
    expect(res.status).toBe(200);
    const s = res.body.sessions[0];
    expect(s.status).toBe('idle');
    expect(s.agent_active).toBe(true);
    expect(s.can_write).toBe(false); // non-owner viewer
    expect(s.can_close).toBe(false); // not owner, not starter
  });
});

describe('createConsoleSessionCloseRouter', () => {
  const task = { id: 't1', ownerId: 'owner', visibility: 'private', pieceName: 'ssh-console', spaceId: null };
  const session = { connectionId: 'A', startedByUserId: 'starter' } as any;

  function buildApp(opts: { user: any; task?: any; session?: any; closeSession?: any }) {
    const closeSession = opts.closeSession ?? vi.fn(async () => ({ ok: true }));
    const app = express();
    app.use((req, _res, next) => { (req as any).user = opts.user; next(); });
    app.use('/api', createConsoleSessionCloseRouter({
      registry: { get: () => opts.session ?? null } as any,
      requireAuth: (_req: any, _res: any, next: any) => next(),
      resolveTask: async () => opts.task ?? null,
      closeSession,
    }));
    return { app, closeSession };
  }

  it('owner closes → 200 and closeSession called with the actor', async () => {
    const { app, closeSession } = buildApp({ user: { id: 'owner', role: 'user' }, task, session });
    const res = await request(app)
      .post('/api/local/tasks/t1/console/session/close')
      .send({ connection_id: 'A' });
    expect(res.status).toBe(200);
    expect(res.body).toEqual({ ok: true });
    expect(closeSession).toHaveBeenCalledWith({ taskId: 't1', connectionId: 'A', actorUserId: 'owner' });
  });

  it('the session starter (non-owner) closes → 200', async () => {
    const { app, closeSession } = buildApp({ user: { id: 'starter', role: 'user' }, task, session });
    const res = await request(app)
      .post('/api/local/tasks/t1/console/session/close')
      .send({ connection_id: 'A' });
    expect(res.status).toBe(200);
    expect(closeSession).toHaveBeenCalledWith({ taskId: 't1', connectionId: 'A', actorUserId: 'starter' });
  });

  it('an unrelated viewer → 403 and closeSession NOT called', async () => {
    const { app, closeSession } = buildApp({ user: { id: 'stranger', role: 'user' }, task, session });
    const res = await request(app)
      .post('/api/local/tasks/t1/console/session/close')
      .send({ connection_id: 'A' });
    expect(res.status).toBe(403);
    expect(closeSession).not.toHaveBeenCalled();
  });

  it('no such session → 404', async () => {
    const { app, closeSession } = buildApp({ user: { id: 'owner', role: 'user' }, task, session: null });
    const res = await request(app)
      .post('/api/local/tasks/t1/console/session/close')
      .send({ connection_id: 'A' });
    expect(res.status).toBe(404);
    expect(closeSession).not.toHaveBeenCalled();
  });

  it('missing connection_id → 400', async () => {
    const { app } = buildApp({ user: { id: 'owner', role: 'user' }, task, session });
    const res = await request(app)
      .post('/api/local/tasks/t1/console/session/close')
      .send({});
    expect(res.status).toBe(400);
  });

  it('task not visible → 404', async () => {
    const { app } = buildApp({ user: { id: 'owner', role: 'user' }, task: null, session });
    const res = await request(app)
      .post('/api/local/tasks/t1/console/session/close')
      .send({ connection_id: 'A' });
    expect(res.status).toBe(404);
  });
});

describe('parseConsoleWsUrl', () => {
  it('parses a plain path with no query string', () => {
    expect(parseConsoleWsUrl('/api/local/tasks/t1/console/ws')).toEqual({
      taskId: 't1',
      connectionId: undefined,
    });
  });

  it('extracts connection_id from the query string', () => {
    expect(parseConsoleWsUrl('/api/local/tasks/t1/console/ws?connection_id=conn-2')).toEqual({
      taskId: 't1',
      connectionId: 'conn-2',
    });
  });

  // Regression: req.url used to be matched against PATH_RE directly, and the
  // regex is anchored with `$` — a URL with ANY query string (not just
  // connection_id) used to fail the match and the whole upgrade was silently
  // dropped. Parsing the pathname via `new URL(...)` fixes this.
  it('still matches the path when a query string is present (regression)', () => {
    const parsed = parseConsoleWsUrl('/api/local/tasks/t1/console/ws?foo=bar&connection_id=conn-9');
    expect(parsed).not.toBeNull();
    expect(parsed?.taskId).toBe('t1');
    expect(parsed?.connectionId).toBe('conn-9');
  });

  it('returns null for a non-matching pathname', () => {
    expect(parseConsoleWsUrl('/api/local/tasks/t1/console/status')).toBeNull();
  });

  it('decodes a URL-encoded taskId', () => {
    expect(parseConsoleWsUrl('/api/local/tasks/task%20one/console/ws')).toEqual({
      taskId: 'task one',
      connectionId: undefined,
    });
  });
});

describe('selectConsoleSession', () => {
  function fakeSession(over: Partial<Record<string, any>> = {}) {
    return { connectionId: 'A', lastActivityAt: Date.now(), ...over } as any;
  }

  it('with connectionId given: does an exact (task, connection) registry lookup', async () => {
    const s = fakeSession({ connectionId: 'conn-2' });
    const registry = { get: vi.fn(() => s), listForTask: vi.fn(() => []) };
    const result = await selectConsoleSession({
      taskId: 't1',
      connectionId: 'conn-2',
      registry: registry as any,
      user: { id: 'u1', role: 'user' },
      task: { id: 't1', ownerId: 'u1', visibility: 'private', pieceName: 'ssh-console' },
      resolveSshAccess: vi.fn(),
    });
    expect(result).toBe(s);
    expect(registry.get).toHaveBeenCalledWith('t1', 'conn-2');
  });

  it('with connectionId given: returns null (no_session) without checking authorization', async () => {
    const registry = { get: vi.fn(() => null), listForTask: vi.fn(() => []) };
    const resolveSshAccess = vi.fn();
    const result = await selectConsoleSession({
      taskId: 't1',
      connectionId: 'conn-missing',
      registry: registry as any,
      user: { id: 'u1', role: 'user' },
      task: { id: 't1', ownerId: 'u1', visibility: 'private', pieceName: 'ssh-console' },
      resolveSshAccess,
    });
    expect(result).toBeNull();
    expect(resolveSshAccess).not.toHaveBeenCalled();
  });

  it('omitted connectionId: picks the most-recent AUTHORIZED session, not the unfiltered most-recent', async () => {
    const unauthorizedRecent = fakeSession({ connectionId: 'conn-bad', lastActivityAt: 500 });
    const authorizedOlder = fakeSession({ connectionId: 'conn-good', lastActivityAt: 100 });
    const registry = {
      get: vi.fn(),
      listForTask: vi.fn(() => [unauthorizedRecent, authorizedOlder]),
    };
    const result = await selectConsoleSession({
      taskId: 't1',
      connectionId: undefined,
      registry: registry as any,
      user: { id: 'u1', role: 'user' },
      task: { id: 't1', ownerId: 'other', visibility: 'org', pieceName: 'ssh-console' },
      resolveSshAccess: vi.fn(async (_u, s) => s.connectionId === 'conn-good'),
    });
    expect(result).toBe(authorizedOlder);
  });

  it('omitted connectionId: returns null when no session is authorized', async () => {
    const onlyUnauthorized = fakeSession({ connectionId: 'conn-bad', lastActivityAt: 100 });
    const registry = { get: vi.fn(), listForTask: vi.fn(() => [onlyUnauthorized]) };
    const result = await selectConsoleSession({
      taskId: 't1',
      connectionId: undefined,
      registry: registry as any,
      user: { id: 'u1', role: 'user' },
      task: { id: 't1', ownerId: 'other', visibility: 'org', pieceName: 'ssh-console' },
      resolveSshAccess: vi.fn(async () => false),
    });
    expect(result).toBeNull();
  });

  it('omitted connectionId: returns null without checking sessions when user or task is missing', async () => {
    const registry = { get: vi.fn(), listForTask: vi.fn(() => [fakeSession()]) };
    const resolveSshAccess = vi.fn();
    const result = await selectConsoleSession({
      taskId: 't1',
      connectionId: undefined,
      registry: registry as any,
      user: null,
      task: { id: 't1', ownerId: 'u1', visibility: 'private', pieceName: 'ssh-console' },
      resolveSshAccess,
    });
    expect(result).toBeNull();
    expect(resolveSshAccess).not.toHaveBeenCalled();
  });
});

describe('attachConsoleWs (upgrade wiring)', () => {
  function fakeSession(over: Partial<Record<string, any>> = {}) {
    return {
      connectionId: 'conn-1',
      localTaskId: 't1',
      cols: 80,
      rows: 24,
      lastActivityAt: Date.now(),
      scrollbackBytes: () => Buffer.alloc(0),
      onOutput: (_cb: any) => () => {},
      addViewer: vi.fn(() => () => {}),
      listViewers: vi.fn(() => []),
      ...over,
    } as any;
  }

  function setup(opts: {
    sessions: Record<string, any>;
    resolveSshAccess?: (user: any, session: any, task: any) => Promise<boolean>;
    task?: any;
    user?: any;
  }) {
    const server = new EventEmitter() as any;
    const registry = {
      get: (_taskId: string, connId: string) => opts.sessions[connId] ?? null,
      listForTask: (_taskId: string) => Object.values(opts.sessions),
    };
    const deps = {
      registry,
      resolveUserFromUpgrade: vi.fn().mockResolvedValue(opts.user ?? { id: 'u1', role: 'user' }),
      resolveTask: vi.fn().mockResolvedValue(
        opts.task ?? { id: 't1', ownerId: 'u1', visibility: 'private', pieceName: 'ssh-console' },
      ),
      resolveSshAccess: opts.resolveSshAccess ?? vi.fn().mockResolvedValue(true),
      denyPatterns: { getPatterns: vi.fn().mockResolvedValue({ deny: [], allow: [] }) },
    };
    attachConsoleWs(server, deps as any);
    const socket = { destroy: vi.fn() };
    const emit = (url: string) => server.emit('upgrade', { url } as any, socket, Buffer.alloc(0));
    return { emit, socket, deps };
  }

  beforeEach(() => {
    wsInstances.length = 0;
  });

  function lastAttachConnectionId(): string | undefined {
    const attach = wsInstances[wsInstances.length - 1]?.sent.find(
      (s: any) => s.kind === 'text' && s.data.type === 'attach',
    );
    return attach?.data.connection_id;
  }

  it('attaches to the session named by connection_id when multiple sessions are open', async () => {
    const { emit } = setup({
      sessions: {
        'conn-1': fakeSession({ connectionId: 'conn-1', lastActivityAt: 100 }),
        'conn-2': fakeSession({ connectionId: 'conn-2', lastActivityAt: 200 }),
      },
    });
    emit('/api/local/tasks/t1/console/ws?connection_id=conn-2');
    await flush();
    expect(wsInstances).toHaveLength(1);
    expect(lastAttachConnectionId()).toBe('conn-2');
  });

  it('regression: a query string on the URL does not break path matching', async () => {
    const { emit } = setup({
      sessions: { 'conn-1': fakeSession({ connectionId: 'conn-1' }) },
    });
    emit('/api/local/tasks/t1/console/ws?connection_id=conn-1');
    await flush();
    expect(wsInstances).toHaveLength(1);
    expect(lastAttachConnectionId()).toBe('conn-1');
  });

  it('omitted connection_id attaches to the most-recent AUTHORIZED session, never an unauthorized one', async () => {
    const { emit } = setup({
      sessions: {
        'conn-bad': fakeSession({ connectionId: 'conn-bad', lastActivityAt: 500 }),
        'conn-good': fakeSession({ connectionId: 'conn-good', lastActivityAt: 100 }),
      },
      resolveSshAccess: vi.fn(async (_u, s) => s.connectionId === 'conn-good'),
    });
    emit('/api/local/tasks/t1/console/ws');
    await flush();
    expect(wsInstances).toHaveLength(1);
    expect(lastAttachConnectionId()).toBe('conn-good');
  });

  it('omitted connection_id denies (no attach) when no live session is authorized', async () => {
    const { emit, socket } = setup({
      sessions: { 'conn-bad': fakeSession({ connectionId: 'conn-bad' }) },
      resolveSshAccess: vi.fn().mockResolvedValue(false),
    });
    emit('/api/local/tasks/t1/console/ws');
    await flush();
    expect(wsInstances).toHaveLength(0);
    expect(socket.destroy).toHaveBeenCalled();
  });
});
