/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { EventEmitter } from 'node:events';
import { WebSocketServer, type WebSocket } from 'ws';
import type {
  NetworkLog,
  ConsoleLogPayload,
  InspectorConsoleLog,
} from './types.js';
import { INDEX_HTML, CLIENT_JS } from './_client-assets.js';

export type {
  NetworkLog,
  ConsoleLogPayload,
  InspectorConsoleLog,
} from './types.js';

interface IncomingNetworkPayload extends Partial<NetworkLog> {
  chunk?: {
    index: number;
    data: string;
    timestamp: number;
  };
}

export interface SessionInfo {
  sessionId: string;
  ws: WebSocket;
  lastPing: number;
}

/**
 * DevTools Viewer
 *
 * Receives logs via WebSocket from CLI sessions.
 */
export class DevTools extends EventEmitter {
  private static instance: DevTools | undefined;
  private logs: NetworkLog[] = [];
  private consoleLogs: InspectorConsoleLog[] = [];
  private server: http.Server | null = null;
  private wss: WebSocketServer | null = null;
  private sessions = new Map<string, SessionInfo>();
  private heartbeatTimer: NodeJS.Timeout | null = null;
  private port = 25417;
  private static readonly DEFAULT_PORT = 25417;
  private static readonly MAX_PORT_RETRIES = 10;

  private constructor() {
    super();
    // Each SSE client adds 3 listeners; raise the limit to avoid warnings
    this.setMaxListeners(50);
  }

  static getInstance(): DevTools {
    if (!DevTools.instance) {
      DevTools.instance = new DevTools();
    }
    return DevTools.instance;
  }

  addInternalConsoleLog(
    payload: ConsoleLogPayload,
    sessionId?: string,
    timestamp?: number,
  ) {
    const entry: InspectorConsoleLog = {
      ...payload,
      id: randomUUID(),
      sessionId,
      timestamp: timestamp || Date.now(),
    };
    this.consoleLogs.push(entry);
    if (this.consoleLogs.length > 5000) this.consoleLogs.shift();
    this.emit('console-update', entry);
  }

  addInternalNetworkLog(
    payload: IncomingNetworkPayload,
    sessionId?: string,
    timestamp?: number,
  ) {
    if (!payload.id) return;
    const existingIndex = this.logs.findIndex((l) => l.id === payload.id);
    if (existingIndex > -1) {
      const existing = this.logs[existingIndex];

      // Handle chunk accumulation
      if (payload.chunk) {
        const chunks = existing.chunks || [];
        chunks.push(payload.chunk);
        this.logs[existingIndex] = {
          ...existing,
          chunks,
          sessionId: sessionId || existing.sessionId,
        };
      } else {
        this.logs[existingIndex] = {
          ...existing,
          ...payload,
          sessionId: sessionId || existing.sessionId,
          // Drop chunks once we have the full response body — the data
          // is redundant and keeping both can blow past V8's string limit
          // when serializing the snapshot.
          chunks: payload.response?.body ? undefined : existing.chunks,
          response: payload.response
            ? { ...existing.response, ...payload.response }
            : existing.response,
        } as NetworkLog;
      }
      this.emit('update', this.logs[existingIndex]);
    } else if (payload.url) {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
      const entry = {
        ...payload,
        sessionId,
        timestamp: timestamp || Date.now(),
        chunks: payload.chunk ? [payload.chunk] : undefined,
      } as NetworkLog;
      this.logs.push(entry);
      if (this.logs.length > 10) this.logs.shift();
      this.emit('update', entry);
    }
  }

  getUrl(): string {
    return `http://127.0.0.1:${this.port}`;
  }

  getPort(): number {
    return this.port;
  }

  stop(): Promise<void> {
    return new Promise((resolve) => {
      if (this.heartbeatTimer) {
        clearInterval(this.heartbeatTimer);
        this.heartbeatTimer = null;
      }
      if (this.wss) {
        this.wss.close();
        this.wss = null;
      }
      if (this.server) {
        this.server.close(() => resolve());
        this.server = null;
      } else {
        resolve();
      }
      // Reset singleton so a fresh start() is possible
      DevTools.instance = undefined;
    });
  }

  start(): Promise<string> {
    return new Promise((resolve, reject) => {
      if (this.server) {
        resolve(this.getUrl());
        return;
      }
      this.server = http.createServer((req, res) => {
        // Only allow same-origin requests — the client is served from this
        // server so cross-origin access is unnecessary and would let arbitrary
        // websites exfiltrate logs (which may contain API keys/headers).
        const origin = req.headers.origin;
        if (origin) {
          const allowed = `http://127.0.0.1:${this.port}`;
          if (origin === allowed) {
            res.setHeader('Access-Control-Allow-Origin', allowed);
          }
        }

        // API routes
        if (req.url === '/api/trigger-debugger' && req.method === 'POST') {
          let body = '';
          req.on('data', (chunk) => {
            body += chunk;
          });
          req.on('end', () => {
            try {
              const parsed: unknown = JSON.parse(body);
              if (
                typeof parsed !== 'object' ||
                parsed === null ||
                !('sessionId' in parsed) ||
                typeof parsed.sessionId !== 'string'
              ) {
                res.writeHead(400, { 'Content-Type': 'application/json' });
                res.end(JSON.stringify({ error: 'Invalid request' }));
                return;
              }
              const sessionId = parsed.sessionId;
              const session = this.sessions.get(sessionId);
              if (session) {
                session.ws.send(JSON.stringify({ type: 'trigger-debugger' }));

                res.writeHead(200, { 'Content-Type': 'application/json' });
                res.end(JSON.stringify({ success: true }));
              } else {
                res.writeHead(404, { 'Content-Type': 'application/json' });
                res.end(JSON.stringify({ error: 'Session not found' }));
              }
            } catch {
              res.writeHead(400, { 'Content-Type': 'application/json' });
              res.end(JSON.stringify({ error: 'Invalid request' }));
            }
          });
        } else if (req.url === '/events') {
          res.writeHead(200, {
            'Content-Type': 'text/event-stream',
            'Cache-Control': 'no-cache',
            Connection: 'keep-alive',
          });

          // Send full snapshot on connect
          const snapshot = JSON.stringify({
            networkLogs: this.logs,
            consoleLogs: this.consoleLogs,
            sessions: Array.from(this.sessions.keys()),
          });
          res.write(`event: snapshot\ndata: ${snapshot}\n\n`);

          // Incremental updates
          const onNetwork = (log: NetworkLog) => {
            res.write(`event: network\ndata: ${JSON.stringify(log)}\n\n`);
          };
          const onConsole = (log: InspectorConsoleLog) => {
            res.write(`event: console\ndata: ${JSON.stringify(log)}\n\n`);
          };
          const onSession = () => {
            const sessions = Array.from(this.sessions.keys());
            res.write(`event: session\ndata: ${JSON.stringify(sessions)}\n\n`);
          };
          this.on('update', onNetwork);
          this.on('console-update', onConsole);
          this.on('session-update', onSession);
          req.on('close', () => {
            this.off('update', onNetwork);
            this.off('console-update', onConsole);
            this.off('session-update', onSession);
          });
        } else if (req.url === '/' || req.url === '/index.html') {
          res.writeHead(200, { 'Content-Type': 'text/html' });
          res.end(INDEX_HTML);
        } else if (req.url === '/assets/main.js') {
          res.writeHead(200, { 'Content-Type': 'application/javascript' });
          res.end(CLIENT_JS);
        } else {
          res.writeHead(404);
          res.end('Not Found');
        }
      });
      this.server.on('error', (e: unknown) => {
        if (
          typeof e === 'object' &&
          e !== null &&
          'code' in e &&
          e.code === 'EADDRINUSE'
        ) {
          if (this.port - DevTools.DEFAULT_PORT >= DevTools.MAX_PORT_RETRIES) {
            reject(
              new Error(
                `DevTools: all ports ${DevTools.DEFAULT_PORT}–${this.port} in use`,
              ),
            );
            return;
          }
          this.port++;
          this.server?.listen(this.port, '127.0.0.1');
        } else {
          reject(e instanceof Error ? e : new Error(String(e)));
        }
      });
      this.server.listen(this.port, '127.0.0.1', () => {
        this.setupWebSocketServer();
        resolve(this.getUrl());
      });
    });
  }

  private setupWebSocketServer() {
    if (!this.server) return;

    this.wss = new WebSocketServer({ server: this.server, path: '/ws' });

    this.wss.on('connection', (ws: WebSocket) => {
      let sessionId: string | null = null;

      ws.on('message', (data: Buffer) => {
        try {
          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
          const message = JSON.parse(data.toString());

          // Handle registration first
          if (message.type === 'register') {
            sessionId = String(message.sessionId);
            if (!sessionId) return;

            this.sessions.set(sessionId, {
              sessionId,
              ws,
              lastPing: Date.now(),
            });

            // Notify session update
            this.emit('session-update');

            // Send registration acknowledgement
            ws.send(
              JSON.stringify({
                type: 'registered',
                sessionId,
                timestamp: Date.now(),
              }),
            );
          } else if (sessionId) {
            this.handleWebSocketMessage(sessionId, message);
          }
        } catch {
          // Invalid WebSocket message
        }
      });

      ws.on('close', () => {
        if (sessionId) {
          this.sessions.delete(sessionId);
          this.emit('session-update');
        }
      });

      ws.on('error', () => {
        // WebSocket error — no action needed
      });
    });

    // Heartbeat mechanism
    this.heartbeatTimer = setInterval(() => {
      const now = Date.now();
      this.sessions.forEach((session, sessionId) => {
        if (now - session.lastPing > 30000) {
          session.ws.close();
          this.sessions.delete(sessionId);
        } else {
          // Send ping
          session.ws.send(JSON.stringify({ type: 'ping', timestamp: now }));
        }
      });
    }, 10000);
    this.heartbeatTimer.unref();
  }

  private handleWebSocketMessage(
    sessionId: string,
    message: Record<string, unknown>,
  ) {
    const session = this.sessions.get(sessionId);
    if (!session) return;

    switch (message['type']) {
      case 'pong':
        session.lastPing = Date.now();
        break;

      case 'console':
        this.addInternalConsoleLog(
          // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
          message['payload'] as ConsoleLogPayload,
          sessionId,
          // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
          message['timestamp'] as number,
        );
        break;

      case 'network':
        this.addInternalNetworkLog(
          // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
          message['payload'] as IncomingNetworkPayload,
          sessionId,
          // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
          message['timestamp'] as number,
        );
        break;

      default:
        break;
    }
  }
}
