/**
 * Engine.IO client for imks WebSocket transport.
 *
 * The imks server exposes Engine.IO v4 over HTTP polling for handshake and
 * WebSocket for message transport.
 */

import { decodeEnginePacket, encodePong, isClose, isMessage, isOpen, isPing } from './protocol';

const EIO_VERSION = 4;

export interface EngineOptions {
  /** imks server base URL, e.g. http://localhost:3000 */
  url: string;
  /** Engine.IO path. Default: /engine.io/ */
  path?: string;
  /** Connection timeout in ms. */
  connectTimeout?: number;
  /** Reconnect delay in ms. */
  reconnectDelay?: number;
  /** Max reconnect attempts. */
  maxReconnectAttempts?: number;
}

export type EngineState = 'closed' | 'opening' | 'open' | 'closing';

export interface EngineHandshake {
  sid: string;
  upgrades: string[];
  pingInterval: number;
  pingTimeout: number;
  maxPayload: number;
}

type MessageHandler = (data: string) => void;
type StateHandler = (state: EngineState) => void;
type ErrorHandler = (error: Error) => void;

/** Engine.IO client that manages the raw WebSocket transport. */
export class EngineClient {
  private ws: WebSocket | null = null;
  private state: EngineState = 'closed';
  private sid: string | null = null;
  private pingInterval = 25000;
  private pingTimeout = 20000;
  private heartbeatTimeout: ReturnType<typeof setTimeout> | null = null;
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  private reconnectAttempts = 0;
  private manuallyClosed = false;

  private readonly options: Required<EngineOptions>;
  private messageHandlers = new Set<MessageHandler>();
  private stateHandlers = new Set<StateHandler>();
  private errorHandlers = new Set<ErrorHandler>();

  constructor(options: EngineOptions) {
    this.options = {
      path: '/engine.io/',
      connectTimeout: 10000,
      reconnectDelay: 2000,
      maxReconnectAttempts: 10,
      ...options,
    };
  }

  get currentState(): EngineState {
    return this.state;
  }

  get currentSid(): string | null {
    return this.sid;
  }

  onMessage(handler: MessageHandler): () => void {
    this.messageHandlers.add(handler);
    return () => this.messageHandlers.delete(handler);
  }

  onStateChange(handler: StateHandler): () => void {
    this.stateHandlers.add(handler);
    return () => this.stateHandlers.delete(handler);
  }

  onError(handler: ErrorHandler): () => void {
    this.errorHandlers.add(handler);
    return () => this.errorHandlers.delete(handler);
  }

  /** Connect to imks via Engine.IO handshake and WebSocket upgrade. */
  async connect(): Promise<EngineHandshake> {
    if (this.state !== 'closed') {
      throw new Error(`Cannot connect in state: ${this.state}`);
    }

    this.clearReconnectTimer();
    this.manuallyClosed = false;
    this.setState('opening');

    try {
      const handshake = await this.handshake();
      this.sid = handshake.sid;
      this.pingInterval = handshake.pingInterval;
      this.pingTimeout = handshake.pingTimeout;

      await this.upgrade(handshake.sid);
      this.setState('open');
      this.reconnectAttempts = 0;
      this.resetHeartbeatTimeout();

      return handshake;
    } catch (err) {
      this.stopHeartbeat();
      this.setState('closed');
      if (!this.manuallyClosed) {
        this.scheduleReconnect();
      }
      throw err;
    }
  }

  /** Send an Engine.IO-encoded payload through the WebSocket transport. */
  send(data: string): void {
    if (this.state !== 'open' || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
      console.warn('[Engine.IO] Cannot send — not connected');
      return;
    }

    this.ws.send(data);
  }

  /** Gracefully close the connection and disable automatic reconnect. */
  close(): void {
    this.manuallyClosed = true;
    this.clearReconnectTimer();
    this.setState('closing');
    this.stopHeartbeat();

    const ws = this.ws;
    this.ws = null;
    if (ws && ws.readyState !== WebSocket.CLOSED) {
      ws.close(1000, 'Client disconnect');
    }

    this.sid = null;
    this.setState('closed');
  }

  private async handshake(): Promise<EngineHandshake> {
    const { url, path, connectTimeout } = this.options;
    const query = `EIO=${EIO_VERSION}&transport=polling`;
    const handshakeUrl = `${joinEndpoint(url, path)}?${query}`;

    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), connectTimeout);

    try {
      const resp = await fetch(handshakeUrl, {
        signal: controller.signal,
        credentials: 'include',
      });

      if (!resp.ok) {
        throw new Error(`Handshake failed: HTTP ${resp.status}`);
      }

      const text = await resp.text();
      const packets = text.split('\x1e').filter(Boolean);
      for (const raw of packets) {
        const { type, data } = decodeEnginePacket(raw);
        if (type === '0') {
          return JSON.parse(data) as EngineHandshake;
        }
      }

      throw new Error('Handshake response missing OPEN packet');
    } finally {
      clearTimeout(timeoutId);
    }
  }

  private async upgrade(sid: string): Promise<void> {
    const { url, path, connectTimeout } = this.options;
    const wsBase = url.replace(/^http/, 'ws');
    const query = `EIO=${EIO_VERSION}&transport=websocket&sid=${encodeURIComponent(sid)}`;
    const wsUrl = `${joinEndpoint(wsBase, path)}?${query}`;

    return new Promise((resolve, reject) => {
      let settled = false;
      const finishResolve = () => {
        if (settled) return;
        settled = true;
        clearTimeout(timeoutId);
        resolve();
      };
      const finishReject = (error: Error) => {
        if (settled) return;
        settled = true;
        clearTimeout(timeoutId);
        reject(error);
      };

      const timeoutId = setTimeout(() => {
        ws.close();
        finishReject(new Error('WebSocket upgrade timed out'));
      }, connectTimeout);

      const ws = new WebSocket(wsUrl);
      this.ws = ws;

      ws.onmessage = (event: MessageEvent) => {
        if (this.ws !== ws) return;
        const raw = typeof event.data === 'string' ? event.data : '';

        if (isOpen(raw)) {
          finishResolve();
          return;
        }

        if (isPing(raw)) {
          if (ws.readyState === WebSocket.OPEN) {
            ws.send(encodePong());
          }
          this.resetHeartbeatTimeout();
          return;
        }

        if (isClose(raw)) {
          ws.close(1000, 'Server closed');
          return;
        }

        if (isMessage(raw)) {
          const { data } = decodeEnginePacket(raw);
          this.handleSocketMessage(data);
        }
      };

      ws.onerror = (event: Event) => {
        if (this.ws !== ws) return;
        console.error('[Engine.IO] WebSocket error:', event);
        finishReject(new Error('WebSocket connection error'));
      };

      ws.onclose = (event: CloseEvent) => {
        if (this.ws !== ws && this.ws !== null) return;
        if (!settled) {
          finishReject(new Error(`WebSocket closed before open: ${event.code}`));
        }
        this.handleDisconnect(ws, event);
      };
    });
  }

  private stopHeartbeat(): void {
    if (this.heartbeatTimeout) {
      clearTimeout(this.heartbeatTimeout);
      this.heartbeatTimeout = null;
    }
  }

  private resetHeartbeatTimeout(): void {
    this.stopHeartbeat();
    const timeout = this.pingInterval + this.pingTimeout;
    this.heartbeatTimeout = setTimeout(() => {
      console.warn('[Engine.IO] Heartbeat timeout — connection lost');
      this.ws?.close();
    }, timeout);
  }

  private handleSocketMessage(data: string): void {
    for (const handler of this.messageHandlers) {
      try {
        handler(data);
      } catch (err) {
        console.error('[Engine.IO] Message handler error:', err);
      }
    }
  }

  private handleDisconnect(ws: WebSocket, event: CloseEvent): void {
    if (this.ws !== ws && this.ws !== null) return;

    this.stopHeartbeat();
    if (this.ws === ws) {
      this.ws = null;
    }

    if (this.state === 'closing' || this.manuallyClosed) {
      this.setState('closed');
      return;
    }

    console.warn(`[Engine.IO] Disconnected (code=${event.code})`);
    this.setState('closed');
    this.scheduleReconnect();
  }

  private scheduleReconnect(): void {
    const { maxReconnectAttempts, reconnectDelay } = this.options;

    if (this.manuallyClosed || this.reconnectTimer) return;

    if (this.reconnectAttempts >= maxReconnectAttempts) {
      console.error('[Engine.IO] Max reconnect attempts reached');
      this.emitError(new Error('Max reconnect attempts reached'));
      return;
    }

    this.reconnectAttempts += 1;
    const delay = reconnectDelay * Math.min(this.reconnectAttempts, 5);

    console.info(
      `[Engine.IO] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${maxReconnectAttempts})`,
    );

    this.reconnectTimer = setTimeout(() => {
      this.reconnectTimer = null;
      if (this.state === 'closed' && !this.manuallyClosed) {
        this.connect().catch(() => {
          /* connect() schedules another reconnect on failure */
        });
      }
    }, delay);
  }

  private clearReconnectTimer(): void {
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer);
      this.reconnectTimer = null;
    }
  }

  private setState(newState: EngineState): void {
    if (this.state !== newState) {
      this.state = newState;
      for (const handler of this.stateHandlers) {
        try {
          handler(newState);
        } catch (err) {
          console.error('[Engine.IO] State handler error:', err);
        }
      }
    }
  }

  private emitError(error: Error): void {
    for (const handler of this.errorHandlers) {
      try {
        handler(error);
      } catch {
        /* ignore */
      }
    }
  }
}

function joinEndpoint(baseUrl: string, path: string): string {
  const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
  const normalizedPath = path.startsWith('/') ? path : `/${path}`;
  return `${base}${normalizedPath}`;
}
