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';
import { decideAccess, handleConsoleSocket, createConsoleStatusRouter, createConsoleSessionRouter } 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';

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?: { get: (id: string) => any };
    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 ?? { get: () => null }) as any,
      requireAuth: (_req: any, _res: any, next: any) => next(),
      resolveTask: opts.resolveTask ?? (async () => null),
    }));
    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: { get: () => null },
    });
    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: { get: () => ({
        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('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: { get: () => null } 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).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);
  });
});
