/**
 * WebSocket upgrade handler for /api/workspaces/pty.
 *
 * OpenAlice serves HTTP via Hono on @hono/node-server, which exposes the
 * underlying Node http.Server. We attach a `ws.WebSocketServer({ noServer:
 * true })` and listen for `upgrade` events ourselves so the launcher's
 * existing raw-`ws` PTY frame handling (binary frames, backpressure,
 * close codes) ports over byte-faithfully.
 */

import type { Server as HttpServer, IncomingMessage } from 'node:http';
import { URL } from 'node:url';

import { WebSocketServer, type WebSocket } from 'ws';

import { logger as launcherLogger } from '../workspaces/logger.js';
import type { WorkspaceService } from '../workspaces/service.js';
import { validateAndTouch } from '@/services/auth/session-store.js';
import { isLoopbackIp, SESSION_COOKIE_NAME } from './middleware/auth.js';

const WS_PATH = '/api/workspaces/pty';

/**
 * WS upgrade requests don't traverse the Hono middleware chain (we own
 * the `upgrade` event ourselves). So we re-apply the same auth check
 * here: same localhost passthrough rules, same cookie lookup, same
 * default-deny. See safe/playbooks/07-websocket-auth.md.
 */
function readSessionCookie(cookieHeader: string | undefined): string | null {
  if (!cookieHeader) return null;
  for (const raw of cookieHeader.split(';')) {
    const entry = raw.trim();
    const eq = entry.indexOf('=');
    if (eq < 0) continue;
    if (entry.slice(0, eq) === SESSION_COOKIE_NAME) {
      const value = entry.slice(eq + 1).trim();
      return value.length > 0 ? decodeURIComponent(value) : null;
    }
  }
  return null;
}

async function isUpgradeAuthorized(req: IncomingMessage): Promise<boolean> {
  if (process.env['OPENALICE_DISABLE_AUTH'] === '1') return true;

  const trustedProxies = (process.env['OPENALICE_TRUSTED_PROXIES'] ?? '')
    .split(',').map((s) => s.trim()).filter(Boolean);

  // Localhost trust passthrough — only when no trusted proxy is configured.
  // Same rule as the HTTP middleware: with a trusted proxy in front,
  // every request looks like localhost, so we MUST require a cookie.
  if (trustedProxies.length === 0) {
    const remote = req.socket.remoteAddress ?? '';
    if (isLoopbackIp(remote)) return true;
  }

  const sid = readSessionCookie(req.headers.cookie);
  if (!sid) return false;
  const session = await validateAndTouch(sid);
  return session !== null;
}

export interface AttachedWS {
  /** Tear down the WebSocketServer and detach upgrade listener. */
  dispose(): void;
}

export function attachWorkspacesWS(httpServer: HttpServer, svc: WorkspaceService): AttachedWS {
  const wss = new WebSocketServer({ noServer: true });

  const onUpgrade = (req: IncomingMessage, socket: import('node:net').Socket, head: Buffer): void => {
    const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
    if (url.pathname !== WS_PATH) {
      // Not ours — leave for other upgrade listeners (none currently in OpenAlice).
      return;
    }

    if (svc.isShuttingDown()) {
      socket.write('HTTP/1.1 503 Service Unavailable\r\n\r\n');
      socket.destroy();
      return;
    }

    if (!isOriginAllowed(req, svc)) {
      launcherLogger.warn('upgrade.origin_rejected', {
        origin: req.headers.origin ?? null,
        remoteAddress: req.socket.remoteAddress ?? null,
      });
      socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
      socket.destroy();
      return;
    }

    // Auth check — same gate as the HTTP middleware.
    // Promise needed because session lookup is async (file-backed).
    isUpgradeAuthorized(req).then((authorized) => {
      if (!authorized) {
        launcherLogger.warn('upgrade.auth_rejected', {
          remoteAddress: req.socket.remoteAddress ?? null,
        });
        socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
        socket.destroy();
        return;
      }
      wss.handleUpgrade(req, socket, head, (ws) => {
        wss.emit('connection', ws, req, url);
      });
    }).catch((err) => {
      launcherLogger.error('upgrade.auth_check_failed', { err });
      socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
      socket.destroy();
    });
  };

  httpServer.on('upgrade', onUpgrade);

  wss.on('connection', (ws: WebSocket, req: IncomingMessage, url: URL) => {
    const cols = clampQuery(url.searchParams.get('cols'), 80, 1, 1000);
    const rows = clampQuery(url.searchParams.get('rows'), 24, 1, 1000);
    const sinceRaw = url.searchParams.get('since');
    const since = sinceRaw === null ? undefined : parseSince(sinceRaw);

    const sessionId = (url.searchParams.get('session') ?? '').slice(0, 64);
    if (!sessionId) {
      launcherLogger.warn('upgrade.missing_session_id');
      try { ws.close(4000, 'session id required'); } catch { /* ignore */ }
      return;
    }
    const session = svc.pool.get(sessionId);
    if (!session) {
      launcherLogger.warn('upgrade.unknown_session', { sessionId });
      try { ws.close(4404, 'session not found'); } catch { /* ignore */ }
      return;
    }
    launcherLogger.info('upgrade.accepted', {
      sessionId,
      wsId: session.wsId,
      cols,
      rows,
      since: since ?? null,
      origin: req.headers.origin ?? null,
      remoteAddress: req.socket.remoteAddress ?? null,
    });
    try {
      svc.pool.attachById(sessionId, ws, cols, rows, since);
    } catch (err) {
      launcherLogger.error('pool.attach_failed', { sessionId, err });
      try { ws.close(1011, 'attach failed'); } catch { /* ignore */ }
    }
  });

  return {
    dispose: () => {
      httpServer.off('upgrade', onUpgrade);
      wss.close();
    },
  };
}

function isOriginAllowed(req: IncomingMessage, svc: WorkspaceService): boolean {
  const cfg = svc.config;
  if (cfg.allowAnyOrigin) return true;
  const origin = req.headers.origin;
  if (typeof origin !== 'string' || origin.length === 0) return true;
  return cfg.allowedOrigins.has(origin);
}

function clampQuery(raw: string | null, fallback: number, lo: number, hi: number): number {
  if (raw === null) return fallback;
  const n = Number.parseInt(raw, 10);
  if (!Number.isFinite(n)) return fallback;
  if (n < lo) return lo;
  if (n > hi) return hi;
  return n;
}

function parseSince(raw: string): number | undefined {
  const n = Number.parseInt(raw, 10);
  if (!Number.isFinite(n) || n < 0) return undefined;
  return n;
}
