/*
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *     http://www.apache.org/licenses/LICENSE-2.0
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { interval, Observable, Subject, Subscription } from 'rxjs';
import { delay, filter, map, mergeMap, retryWhen, take } from 'rxjs/operators';
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';

import { Ticket } from './interfaces/message-common.interface';
import {
  MessageDataTypeMap,
  MessageReceiveDataTypeMap,
  MessageSendDataTypeMap
} from './interfaces/message-data-type-map.interface';
import { ImportNote, Note, NoteConfig, PersonalizedMode, SendNote } from './interfaces/message-notebook.interface';
import { OP } from './interfaces/message-operator.interface';
import {
  DynamicFormParams,
  ParagraphConfig,
  ParagraphParams,
  SendParagraph
} from './interfaces/message-paragraph.interface';
import { WebSocketMessage } from './interfaces/websocket-message.interface';

export type ArgumentsType<T> = T extends (...args: infer U) => void ? U : never;

export type SendArgumentsType<K extends keyof MessageSendDataTypeMap> = MessageSendDataTypeMap[K] extends undefined
  ? ArgumentsType<(op: K) => void>
  : ArgumentsType<(op: K, data: MessageSendDataTypeMap[K]) => void>;

export type ReceiveArgumentsType<K extends keyof MessageReceiveDataTypeMap> =
  MessageReceiveDataTypeMap[K] extends undefined ? () => void : (data: MessageReceiveDataTypeMap[K]) => void;

export class Message {
  public connectedStatus = false;
  public connectedStatus$ = new Subject<boolean>();
  private ws: WebSocketSubject<WebSocketMessage<MessageDataTypeMap>> | null = null;
  private wsSubscription: Subscription | null = null;
  private open$ = new Subject<Event>();
  private close$ = new Subject<CloseEvent>();
  private sent$ = new Subject<WebSocketMessage<MessageSendDataTypeMap>>();
  private received$ = new Subject<WebSocketMessage<MessageReceiveDataTypeMap>>();
  private pingIntervalSubscription = new Subscription();
  private wsUrl?: string;
  private ticket?: Ticket;
  private uniqueClientId = Math.random().toString(36).substring(2, 7);
  private lastMsgIdSeqSent = 0;
  private readonly normalCloseCode = 1000;

  constructor() {
    this.open$.subscribe(() => {
      this.connectedStatus = true;
      this.connectedStatus$.next(this.connectedStatus);
      this.pingIntervalSubscription.unsubscribe();
      this.pingIntervalSubscription = interval(1000 * 10).subscribe(() => this.ping());
    });
    this.close$.subscribe(event => {
      this.connectedStatus = false;
      this.connectedStatus$.next(this.connectedStatus);
      this.pingIntervalSubscription.unsubscribe();

      if (event.code !== this.normalCloseCode) {
        console.log('WebSocket closed unexpectedly. Reconnecting...');
        this.connect();
      }
    });
  }

  bootstrap(ticket: Ticket, wsUrl: string) {
    this.setTicket(ticket);
    this.setWsUrl(wsUrl);
    this.connect();
  }

  getWsInstance(): Message['ws'] {
    return this.ws;
  }

  setWsUrl(wsUrl: string): void {
    this.wsUrl = wsUrl;
  }

  setTicket(ticket: Ticket): void {
    this.ticket = ticket;
  }

  interceptReceived(data: WebSocketMessage<MessageReceiveDataTypeMap>): WebSocketMessage<MessageReceiveDataTypeMap> {
    return data;
  }

  connect() {
    if (!this.wsUrl) {
      throw new Error('WebSocket URL is not set. Please call setWsUrl() before connect()');
    }

    // Unsubscribe from existing subscription first
    if (this.wsSubscription) {
      this.wsSubscription.unsubscribe();
      this.wsSubscription = null;
    }

    // Then close existing WebSocket
    if (this.ws) {
      this.ws.complete();
      this.ws = null;
    }

    this.ws = webSocket<WebSocketMessage<MessageDataTypeMap>>({
      url: this.wsUrl,
      openObserver: this.open$,
      closeObserver: this.close$
    });

    this.wsSubscription = this.ws
      .pipe(
        // reconnect
        retryWhen(errors => errors.pipe(mergeMap(() => this.close$.pipe(take(1), delay(4000)))))
      )
      .subscribe(e => {
        console.log('Receive:', e);
        this.received$.next(this.interceptReceived(e as WebSocketMessage<MessageReceiveDataTypeMap>));
      });
  }

  ping() {
    this.send<OP.PING>(OP.PING);
  }

  close() {
    this.close$.next();
  }

  opened(): Observable<Event> {
    return this.open$.asObservable();
  }

  closed(): Observable<CloseEvent> {
    return this.close$.asObservable();
  }

  sent(): Observable<WebSocketMessage<MessageSendDataTypeMap>> {
    return this.sent$.asObservable();
  }

  received(): Observable<WebSocketMessage<MessageReceiveDataTypeMap>> {
    return this.received$.asObservable();
  }

  send<K extends keyof MessageSendDataTypeMap>(...args: SendArgumentsType<K>): void {
    if (!this.ws) {
      throw new Error('WebSocket is not connected. Bootstrap first.');
    }
    const [op, data] = args;
    const message = {
      op,
      msgId: `${this.uniqueClientId}-${++this.lastMsgIdSeqSent}`,
      data,
      ...this.ticket
    };
    console.log('Send:', message);

    this.ws.next(message);
    this.sent$.next(message);
  }

  receive<K extends keyof MessageReceiveDataTypeMap>(op: K): Observable<Record<K, MessageReceiveDataTypeMap[K]>[K]> {
    return this.received$.pipe(
      filter(message => message.op === op),
      filter(message => {
        if (!message.msgId) {
          // when msgId is not specified, it is not response to client request.
          // always process them
          return true;
        }
        const uniqueClientId = message.msgId.split('-')[0];
        const msgIdSeqReceived = parseInt(message.msgId.split('-')[1], 10);
        const isResponseForRequestFromThisClient = uniqueClientId === this.uniqueClientId;

        if (message.op === OP.PARAGRAPH) {
          if (isResponseForRequestFromThisClient && this.lastMsgIdSeqSent > msgIdSeqReceived) {
            console.log('PARAPGRAPH is already updated by shortcircuit');
            return false;
          } else {
            return true;
          }
        } else {
          return true;
        }
      }),
      map(message => message.data)
    ) as Observable<Record<K, MessageReceiveDataTypeMap[K]>[K]>;
  }

  shortCircuit(message: WebSocketMessage<MessageReceiveDataTypeMap>) {
    this.received$.next(this.interceptReceived(message));
  }

  destroy(): void {
    if (this.wsSubscription) {
      this.wsSubscription.unsubscribe();
      this.wsSubscription = null;
    }
    if (this.ws) {
      this.ws.complete();
      this.ws = null;
    }
  }

  getHomeNote(): void {
    this.send<OP.GET_HOME_NOTE>(OP.GET_HOME_NOTE);
  }

  newNote(noteName: string, defaultInterpreterGroup?: string): void {
    this.send<OP.NEW_NOTE>(OP.NEW_NOTE, {
      name: noteName,
      defaultInterpreterGroup
    });
  }

  moveNoteToTrash(noteId: string): void {
    this.send<OP.MOVE_NOTE_TO_TRASH>(OP.MOVE_NOTE_TO_TRASH, {
      id: noteId
    });
  }

  restoreNote(noteId: string): void {
    this.send<OP.RESTORE_NOTE>(OP.RESTORE_NOTE, {
      id: noteId
    });
  }

  deleteNote(noteId: string): void {
    this.send<OP.DEL_NOTE>(OP.DEL_NOTE, {
      id: noteId
    });
  }

  restoreFolder(folderPath: string): void {
    this.send<OP.RESTORE_FOLDER>(OP.RESTORE_FOLDER, {
      id: folderPath
    });
  }

  removeFolder(folderPath: string): void {
    this.send<OP.REMOVE_FOLDER>(OP.REMOVE_FOLDER, {
      id: folderPath
    });
  }

  moveFolderToTrash(folderPath: string): void {
    this.send<OP.MOVE_FOLDER_TO_TRASH>(OP.MOVE_FOLDER_TO_TRASH, {
      id: folderPath
    });
  }

  restoreAll(): void {
    this.send<OP.RESTORE_ALL>(OP.RESTORE_ALL);
  }

  emptyTrash(): void {
    this.send<OP.EMPTY_TRASH>(OP.EMPTY_TRASH);
  }

  cloneNote(noteIdToClone: string, newNoteName: string): void {
    this.send<OP.CLONE_NOTE>(OP.CLONE_NOTE, { id: noteIdToClone, name: newNoteName });
  }

  /**
   * get nodes list
   */
  listNodes(): void {
    this.send<OP.LIST_NOTES>(OP.LIST_NOTES);
  }

  reloadAllNotesFromRepo(): void {
    this.send<OP.RELOAD_NOTES_FROM_REPO>(OP.RELOAD_NOTES_FROM_REPO);
  }

  reloadNote(noteId: string): void {
    this.send<OP.RELOAD_NOTE>(OP.RELOAD_NOTE, { id: noteId });
  }

  getNote(noteId: string): void {
    this.send<OP.GET_NOTE>(OP.GET_NOTE, { id: noteId });
  }

  updateNote(noteId: string, noteName: string, noteConfig: NoteConfig): void {
    this.send<OP.NOTE_UPDATE>(OP.NOTE_UPDATE, { id: noteId, name: noteName, config: noteConfig });
  }

  updatePersonalizedMode(noteId: string, modeValue: PersonalizedMode): void {
    this.send<OP.UPDATE_PERSONALIZED_MODE>(OP.UPDATE_PERSONALIZED_MODE, { id: noteId, personalized: modeValue });
  }

  noteRename(noteId: string, noteName: string, relative?: boolean): void {
    this.send<OP.NOTE_RENAME>(OP.NOTE_RENAME, { id: noteId, name: noteName, relative });
  }

  folderRename(folderId: string, folderPath: string): void {
    this.send<OP.FOLDER_RENAME>(OP.FOLDER_RENAME, { id: folderId, name: folderPath });
  }

  moveParagraph(paragraphId: string, newIndex: number): void {
    this.send<OP.MOVE_PARAGRAPH>(OP.MOVE_PARAGRAPH, { id: paragraphId, index: newIndex });
  }

  insertParagraph(newIndex: number): void {
    this.send<OP.INSERT_PARAGRAPH>(OP.INSERT_PARAGRAPH, { index: newIndex });
  }

  copyParagraph(
    newIndex: number,
    paragraphTitle: string | undefined,
    paragraphData: string,
    paragraphConfig: ParagraphConfig,
    paragraphParams: ParagraphParams
  ): void {
    this.send<OP.COPY_PARAGRAPH>(OP.COPY_PARAGRAPH, {
      index: newIndex,
      title: paragraphTitle,
      paragraph: paragraphData,
      config: paragraphConfig,
      params: paragraphParams
    });
  }

  angularObjectUpdate(
    noteId: string,
    paragraphId: string,
    name: string,
    value: string,
    interpreterGroupId: string
  ): void {
    this.send<OP.ANGULAR_OBJECT_UPDATED>(OP.ANGULAR_OBJECT_UPDATED, {
      noteId,
      paragraphId,
      name,
      value,
      interpreterGroupId
    });
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  angularObjectClientBind(noteId: string, name: string, value: any, paragraphId: string): void {
    this.send<OP.ANGULAR_OBJECT_CLIENT_BIND>(OP.ANGULAR_OBJECT_CLIENT_BIND, {
      noteId,
      name,
      value,
      paragraphId
    });
  }

  angularObjectClientUnbind(noteId: string, name: string, paragraphId: string): void {
    this.send<OP.ANGULAR_OBJECT_CLIENT_UNBIND>(OP.ANGULAR_OBJECT_CLIENT_UNBIND, {
      noteId,
      name,
      paragraphId
    });
  }

  cancelParagraph(paragraphId: string): void {
    this.send<OP.CANCEL_PARAGRAPH>(OP.CANCEL_PARAGRAPH, { id: paragraphId });
  }

  paragraphExecutedBySpell(
    paragraphId: string,
    paragraphTitle: string,
    paragraphText: string,
    paragraphResultsMsg: Array<{ data: string; type: string }>,
    paragraphStatus: string,
    paragraphErrorMessage: string,
    paragraphConfig: ParagraphConfig,
    paragraphParams: DynamicFormParams,
    paragraphDateStarted: string,
    paragraphDateFinished: string
  ): void {
    this.send<OP.PARAGRAPH_EXECUTED_BY_SPELL>(OP.PARAGRAPH_EXECUTED_BY_SPELL, {
      id: paragraphId,
      title: paragraphTitle,
      paragraph: paragraphText,
      results: {
        code: paragraphStatus,
        msg: paragraphResultsMsg.map(dataWithType => {
          const serializedData = dataWithType.data;
          return { type: dataWithType.type, serializedData };
        })
      },
      status: paragraphStatus,
      errorMessage: paragraphErrorMessage,
      config: paragraphConfig,
      params: paragraphParams,
      dateStarted: paragraphDateStarted,
      dateFinished: paragraphDateFinished
    });
  }

  runParagraph(
    paragraphId: string,
    paragraphTitle: string | undefined,
    paragraphData: string,
    paragraphConfig: ParagraphConfig,
    paragraphParams: ParagraphParams
  ): void {
    // short circuit update status without waiting for server response
    this.shortCircuit({
      op: OP.PARAGRAPH_STATUS,
      data: {
        id: paragraphId,
        status: 'PENDING'
      }
    });

    // send message to server
    this.send<OP.RUN_PARAGRAPH>(OP.RUN_PARAGRAPH, {
      id: paragraphId,
      title: paragraphTitle,
      paragraph: paragraphData,
      config: paragraphConfig,
      params: paragraphParams
    });
  }

  runAllParagraphs(noteId: string, paragraphs: SendParagraph[]): void {
    this.send<OP.RUN_ALL_PARAGRAPHS>(OP.RUN_ALL_PARAGRAPHS, {
      noteId,
      paragraphs: JSON.stringify(paragraphs)
    });
  }

  paragraphRemove(paragraphId: string): void {
    this.send<OP.PARAGRAPH_REMOVE>(OP.PARAGRAPH_REMOVE, { id: paragraphId });
  }

  paragraphClearOutput(paragraphId: string): void {
    this.send<OP.PARAGRAPH_CLEAR_OUTPUT>(OP.PARAGRAPH_CLEAR_OUTPUT, { id: paragraphId });
  }

  paragraphClearAllOutput(noteId: string): void {
    this.send<OP.PARAGRAPH_CLEAR_ALL_OUTPUT>(OP.PARAGRAPH_CLEAR_ALL_OUTPUT, { id: noteId });
  }

  completion(paragraphId: string, buf: string, cursor: number): void {
    this.send<OP.COMPLETION>(OP.COMPLETION, {
      id: paragraphId,
      buf,
      cursor
    });
  }

  commitParagraph(
    paragraphId: string,
    paragraphTitle: string | undefined,
    paragraphData: string,
    paragraphConfig: ParagraphConfig,
    paragraphParams: ParagraphConfig,
    noteId: string
  ): void {
    return this.send<OP.COMMIT_PARAGRAPH>(OP.COMMIT_PARAGRAPH, {
      id: paragraphId,
      noteId,
      title: paragraphTitle,
      paragraph: paragraphData,
      config: paragraphConfig,
      params: paragraphParams
    });
  }

  patchParagraph(paragraphId: string, noteId: string, patch: string): void {
    // javascript add "," if change contains several patches
    // but java library requires patch list without ","
    const normalPatch = patch.replace(/,@@/g, '@@');
    return this.send<OP.PATCH_PARAGRAPH>(OP.PATCH_PARAGRAPH, {
      id: paragraphId,
      noteId,
      patch: normalPatch
    });
  }

  importNote(note: ImportNote['note']): void {
    this.send<OP.IMPORT_NOTE>(OP.IMPORT_NOTE, {
      note
    });
  }

  checkpointNote(noteId: string, commitMessage: string): void {
    this.send<OP.CHECKPOINT_NOTE>(OP.CHECKPOINT_NOTE, {
      noteId,
      commitMessage
    });
  }

  setNoteRevision(noteId: string, revisionId: string): void {
    this.send<OP.SET_NOTE_REVISION>(OP.SET_NOTE_REVISION, {
      noteId,
      revisionId
    });
  }

  listRevisionHistory(noteId: string): void {
    this.send<OP.LIST_REVISION_HISTORY>(OP.LIST_REVISION_HISTORY, {
      noteId
    });
  }

  noteRevision(noteId: string, revisionId: string): void {
    this.send<OP.NOTE_REVISION>(OP.NOTE_REVISION, {
      noteId,
      revisionId
    });
  }

  noteRevisionForCompare(noteId: string, revisionId: string, position: string): void {
    this.send<OP.NOTE_REVISION_FOR_COMPARE>(OP.NOTE_REVISION_FOR_COMPARE, {
      noteId,
      revisionId,
      position
    });
  }

  editorSetting(paragraphId: string, paragraphText: string): void {
    this.send<OP.EDITOR_SETTING>(OP.EDITOR_SETTING, {
      paragraphId,
      paragraphText
    });
  }

  listNoteJobs(): void {
    this.send<OP.LIST_NOTE_JOBS>(OP.LIST_NOTE_JOBS);
  }

  unsubscribeUpdateNoteJobs(): void {
    this.send<OP.UNSUBSCRIBE_UPDATE_NOTE_JOBS>(OP.UNSUBSCRIBE_UPDATE_NOTE_JOBS);
  }

  getInterpreterBindings(noteId: string): void {
    this.send<OP.GET_INTERPRETER_BINDINGS>(OP.GET_INTERPRETER_BINDINGS, { noteId });
  }

  saveInterpreterBindings(noteId: string, selectedSettingIds: string[]): void {
    this.send<OP.SAVE_INTERPRETER_BINDINGS>(OP.SAVE_INTERPRETER_BINDINGS, {
      noteId,
      selectedSettingIds
    });
  }

  listConfigurations(): void {
    this.send<OP.LIST_CONFIGURATIONS>(OP.LIST_CONFIGURATIONS);
  }

  getInterpreterSettings(): void {
    this.send<OP.GET_INTERPRETER_SETTINGS>(OP.GET_INTERPRETER_SETTINGS);
  }

  saveNoteForms(note: SendNote): void {
    this.send<OP.SAVE_NOTE_FORMS>(OP.SAVE_NOTE_FORMS, {
      noteId: note.id,
      noteParams: note.noteParams
    });
  }

  removeNoteForms(note: Required<Note>['note'], formName: string): void {
    this.send<OP.REMOVE_NOTE_FORMS>(OP.REMOVE_NOTE_FORMS, {
      noteId: note.id,
      formName
    });
  }
}
