export interface EventSourceType {
  readonly url: string;
  readonly readyState: number;

  onopen: ((this: EventSourceType, ev: Event) => any) | null;
  onmessage: ((this: EventSourceType, ev: MessageEvent) => any) | null;
  onerror: ((this: EventSourceType, ev: Event) => any) | null;

  close(): void;
}

export interface EventSourceConstructor {
  OPEN: number;
  CONNECTING: number;
  CLOSED: number;
  new (url: string): EventSourceType;
}

let _connId = 0;

type Conn = EventSourceType | WebSocket;

type OpenEvent<T extends Conn> = {
  target: Connection<T>;
};

type MessageData = {
  op: string;
  [key: string]: any;
};

type SendMessageData = {
  'client-event-id': string;
  [key: string]: any;
};

type MsgEvent<T extends Conn> = {
  target: Connection<T>;
  message: MessageData | MessageData[];
};

type CloseEvent<T extends Conn> = {
  target: Connection<T>;
};

interface ErrorEvent<T extends Conn> {
  target: Connection<T>;
}

export type TransportType = 'ws' | 'sse';

export interface Connection<T extends Conn> {
  conn: T;
  type: 'ws' | 'sse';
  id: string;
  close(): void;
  isOpen(): boolean;
  isConnecting(): boolean;
  send(msg: SendMessageData): void;
  onopen: ((event: OpenEvent<T>) => void) | null;
  onmessage: ((event: MsgEvent<T>) => void) | null;
  onclose: ((event: CloseEvent<T>) => void) | null;
  onerror: ((event: ErrorEvent<T>) => void) | null;
}

export class WSConnection implements Connection<WebSocket> {
  type: TransportType = 'ws';
  conn: WebSocket;
  id: string;
  onopen: (event: OpenEvent<WebSocket>) => void;
  onmessage: (event: MsgEvent<WebSocket>) => void;
  onclose: (event: CloseEvent<WebSocket>) => void;
  onerror: (event: ErrorEvent<WebSocket>) => void;
  constructor(url: string) {
    this.id = `${this.type}_${_connId++}`;
    this.conn = new WebSocket(url);
    this.conn.onopen = (_e) => {
      if (this.onopen) {
        this.onopen({ target: this });
      }
    };
    this.conn.onmessage = (e) => {
      if (this.onmessage) {
        this.onmessage({
          target: this,
          message: JSON.parse(e.data.toString()),
        });
      }
    };
    this.conn.onclose = (_e) => {
      if (this.onclose) {
        this.onclose({ target: this });
      }
    };
    this.conn.onerror = (_e) => {
      if (this.onerror) {
        this.onerror({ target: this });
      }
    };
  }

  close() {
    this.conn.close();
  }

  isOpen(): boolean {
    return this.conn.readyState === (WebSocket.OPEN ?? 1);
  }

  isConnecting(): boolean {
    return this.conn.readyState === (WebSocket.CONNECTING ?? 0);
  }

  send(msg: SendMessageData) {
    return this.conn.send(JSON.stringify(msg));
  }
}

type SSEInitParams = {
  machineId: string;
  sessionId: string;
  sseToken: string;
};

export class SSEConnection implements Connection<EventSourceType> {
  type: TransportType = 'sse';
  private initParams: SSEInitParams | null = null;
  private sendQueue: any[] = [];
  private sendPromise: Promise<void> | null;
  private closeFired: boolean = false;
  private sseInitTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
  private ES: EventSourceConstructor;
  private messageUrl: string;
  conn: EventSourceType;
  url: string;
  id: string;
  onopen: (event: OpenEvent<EventSourceType>) => void;
  onmessage: (event: MsgEvent<EventSourceType>) => void;
  onclose: (event: CloseEvent<EventSourceType>) => void;
  onerror: (event: ErrorEvent<EventSourceType>) => void;

  constructor(ES: EventSourceConstructor, url: string, messageUrl?: string) {
    this.id = `${this.type}_${_connId++}`;
    this.url = url;
    this.messageUrl = messageUrl || this.url;
    this.ES = ES;
    this.conn = new ES(url);

    // Close the connection if we didn't get an init within 10 seconds
    this.sseInitTimeout = setTimeout(() => {
      if (!this.initParams) {
        this.handleError();
      }
    }, 10000);

    this.conn.onmessage = (e) => {
      const message = JSON.parse(e.data);
      if (Array.isArray(message)) {
        for (const msg of message) {
          this.handleMessage(msg);
        }
      } else {
        this.handleMessage(message);
      }
    };

    this.conn.onerror = (e) => {
      this.handleError();
    };
  }

  private handleMessage(msg: MessageData) {
    if (msg.op === 'sse-init') {
      this.initParams = {
        machineId: msg['machine-id'],
        sessionId: msg['session-id'],
        sseToken: msg['sse-token'],
      };
      if (this.onopen) {
        this.onopen({ target: this });
      }
      clearTimeout(this.sseInitTimeout);
      return;
    }
    if (this.onmessage) {
      this.onmessage({
        target: this,
        message: msg,
      });
    }
  }

  // Runs the onerror and closes the connection
  private handleError() {
    try {
      if (this.onerror) {
        this.onerror({ target: this });
      }
    } finally {
      this.handleClose();
    }
  }

  private handleClose() {
    this.conn.close();
    if (this.onclose && !this.closeFired) {
      this.closeFired = true;
      this.onclose({ target: this });
    }
  }

  private async postMessages(messages: any[]): Promise<void> {
    // TODO(dww): Create a connection with chunked encoding so we can
    //            send multiple messages over one request
    try {
      const resp = await fetch(this.messageUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          machine_id: this.initParams?.machineId,
          session_id: this.initParams?.sessionId,
          sse_token: this.initParams?.sseToken,
          messages,
        }),
      });
      if (!resp.ok) {
        this.handleError();
      }
    } catch (e) {
      this.handleError();
    }
  }

  private async flushQueue() {
    if (this.sendPromise || !this.sendQueue.length) return;

    const messages = this.sendQueue;
    this.sendQueue = [];
    const sendPromise = this.postMessages(messages);
    this.sendPromise = sendPromise;
    sendPromise.then(() => {
      this.sendPromise = null;
      this.flushQueue();
    });
  }

  send(msg: SendMessageData) {
    if (!this.isOpen() || !this.initParams) {
      if (this.isConnecting()) {
        throw new Error(
          `Failed to execute 'send' on 'EventSource': Still in CONNECTING state.`,
        );
      }
      if (this.conn.readyState === this.ES.CLOSED) {
        throw new Error(`EventSource is already in CLOSING or CLOSED state.`);
      }
      throw new Error(`EventSource is in invalid state.`);
    }
    this.sendQueue.push(msg);
    this.flushQueue();
  }

  isOpen(): boolean {
    return this.conn.readyState === this.ES.OPEN && this.initParams !== null;
  }

  isConnecting(): boolean {
    return (
      this.conn.readyState === this.ES.CONNECTING ||
      (this.conn.readyState === this.ES.OPEN && this.initParams === null)
    );
  }

  close() {
    this.handleClose();
  }
}
