import { Injectable } from "@angular/core";
import { HttpClient, HttpHeaders } from "@angular/common/http";
import { CookieService } from "ngx-cookie";
import { SubscriptionClient } from "subscriptions-transport-ws";
import { CacheService, cacheCustomLogic } from "./cache.service";
import msgpack from "msgpack-lite";
import { RequestQueue } from "../request-queue";
import { OperationType, pingQuery, Query } from "../server-services/querys";
import { Observable } from "rxjs";
import { MatDialog, MatDialogRef } from "@angular/material/dialog";
import {
  DialogData,
  TwoFactorDialogComponent,
} from "../dialogs/two-factor-dialog/two-factor-dialog.component";
import { base64 } from "rfc4648";
import { EnvironmentService } from "src/environments/environment.service";
import {
  create_account_register_confirm_request_result,
  create_account_register_keyring_request_result,
} from "../crypto/auth/register";
import { create_account_recovery_confirm_request_type } from "../crypto/auth/recovery";

/**
 *
 */
export type QueryParam = {
  /**
   * it will be the string of the query, mutation, or subscription
   */
  query: Query;
  variables?: Object;
  useSecondarySocket?: boolean;

  /**
   * If you need some data to pass the encrypt/decrypt process, what you do not want to pass the server
   */
  extra?: Object;

  /**
   * Call crypto encryption callback with the variables before send it to the server,
   * but after the cache checking
   */
  encrypt?: (variables: Object, extra: Object) => Promise<Object>;

  /**
   * Call crypto decryption after the server returned with the value. Cache will be saved
   * decrypted. If the query already cached, return with the cached data and skip decryption
   */
  decrypt?: (data: Object, variables: Object, extra: Object) => Promise<Object>;
};

export enum TwoFactorAuthResponse {
  PREREQUISITE = "prerequisite", // need 2fa, send otpSalt
  REQUIRED = "required", // after otpSalt, we have got a code via email
  DENIED = "denied", // wrong code
}

export enum TwoFactorMode {
  NONE = 0,
  CODE = 10,
  // TODO - 30
}

export enum SpecificWebsocketError {
  CSRF = 4003,
  AUTH_CLOSE = 4001,
}

@Injectable({
  providedIn: "root",
})
export class ServerRestApiService {
  private wsClientPrimary: SubscriptionClient;
  private wsClientSecondary: SubscriptionClient; // for large payloads
  private requestQueueHandler: RequestQueue;

  private onInitCallbacks = [];
  private onAuthCloseCallbacks = [];
  private isConnected = false;

  private TWO_FACTOR_AUTH_RESPONSE_PROP = "2FA";
  private TWO_FACTOR_AUTH_CODE_PROP = "2faCode";
  private TWO_FACTOR_AUTH_OTP_SALT_PROP = "otpSalt";

  private CSRF_KEY = "__Secure-csrf";
  private CSRF_HEADER_KEY = "pcsrf";
  private isCSRFTokenPending = false;

  private pingTime;

  private defaultQueryParam: QueryParam = {
    query: {
      operation: OperationType.QUERY,
      endpoint: "notdefined",
    },
    variables: {},
    extra: {},
    useSecondarySocket: false,
    encrypt: (variables, extra) => {
      return Promise.resolve(variables);
    },
    decrypt: (data, variables, extra) => {
      return Promise.resolve(data);
    },
  };

  /**
   * make query string from typed param
   * result looks like this: 'query getMe {', 'mutation setPassword {', 'subscription sub {'
   */
  private parseQueryType(query: Query): string {
    return query.operation + " " + query.endpoint + " {";
  }

  private getDataFromQueryResponse(query: Query, response: Object) {
    return response["data"][query.endpoint];
  }

  constructor(
    private http: HttpClient,
    private cookie: CookieService,
    private cache: CacheService,
    private dialog: MatDialog,
    private environmentService: EnvironmentService
  ) {
    this.requestQueueHandler = new RequestQueue();
  }

  public makeTimestamp() {
    return (this.pingTime + performance.now()) / 1000.0;
  }

  public subscribeOnAuthCloseSubscribe(cb: Function) {
    this.onAuthCloseCallbacks.push(cb);
  }

  public unsubscribeOnAuthCloseSubscribe(cb: Function){
    let pos = this.onAuthCloseCallbacks.indexOf(cb);
    if (pos > -1){
      this.onAuthCloseCallbacks.splice(pos, 1);
    }
  }

  private getHttpHeaderOptions() {
    return {
      headers: new HttpHeaders({
        "Content-type": "application/json", // application/msgpack
        [this.CSRF_HEADER_KEY]: this.cookie.get(this.CSRF_KEY) || "conn",
      }),
      //responseType: 'arraybuffer' as 'arraybuffer'
    };
  }

  private resetCSRFToken(): Promise<any> {
    return this.http
      .get<any>(this.environmentService.url_api + "/r/csrf")
      .toPromise()
      .then(() => {
        console.log("new csrf", this.cookie.get(this.CSRF_KEY));
        return this.cookie.get(this.CSRF_KEY);
      });
  }

  public getCSRFToken(): any {
    return new Promise((resolve, reject) => {
      if (!this.cookie.get(this.CSRF_KEY)) {
        // we have to get it from the server
        this.resetCSRFToken().then((token) => {
          resolve(this.cookie.get(this.CSRF_KEY));
        });
      }

      resolve(this.cookie.get(this.CSRF_KEY));
    });
  }

  // for 2FA
  private generateOtpSalt() {
    return crypto.getRandomValues(new Uint8Array(16));
  }

  private handleHttpPostTwoFactorAuth(
    url: string,
    params: Object,
    requestResponse: Object
  ): Promise<any> {
    return new Promise((resolve, reject) => {
      // do we need a 2FA or not
      if (
        requestResponse &&
        requestResponse.hasOwnProperty(this.TWO_FACTOR_AUTH_RESPONSE_PROP)
      ) {
        let TwoFAPart = requestResponse[this.TWO_FACTOR_AUTH_RESPONSE_PROP];

        if (TwoFAPart.mode == TwoFactorMode.CODE) {
          if (TwoFAPart.error == TwoFactorAuthResponse.PREREQUISITE) {
            // prepare the server for the 2FA
            let otpSalt = this.generateOtpSalt();
            params[this.TWO_FACTOR_AUTH_OTP_SALT_PROP] =
              base64.stringify(otpSalt);

            this.makeAjaxPost(url, params, false).then((result) => {
              this.handleHttpPostTwoFactorAuth(url, params, result)
                .then((promiseResult) => {
                  resolve(promiseResult);
                })
                .catch(reject);
            });
          } else if (TwoFAPart.error == TwoFactorAuthResponse.REQUIRED) {
            // we prepared the server for the request, now use the code from the email

            // use a dialog for asking the code
            const dialogRef: MatDialogRef<
              TwoFactorDialogComponent,
              DialogData
            > = this.dialog.open<TwoFactorDialogComponent>(
              TwoFactorDialogComponent
            );

            dialogRef.afterClosed().subscribe((dialogResult) => {
              if (dialogResult?.code) {
                params[this.TWO_FACTOR_AUTH_CODE_PROP] = dialogResult.code;

                this.makeAjaxPost(url, params, false).then((result) => {
                  this.handleHttpPostTwoFactorAuth(url, params, result)
                    .then((promiseResult) => {
                      resolve(promiseResult);
                    })
                    .catch(reject);
                });
              } else {
                // pressed cancel on the dialog
                reject(new Error("Cancelled dialog"));
              }
            });
          } else if (TwoFAPart.error == TwoFactorAuthResponse.DENIED) {
            reject(new Error("Wrong code"));
          }
        } else {
          console.error("need to implement this 2FA mode", TwoFAPart.mode);
          reject(
            new Error("need to implement this 2FA mode: " + TwoFAPart.mode)
          );
        }
      } else {
        resolve(requestResponse);
      }
    });
  }

  private handleSocketTwoFactorAuth(
    param: QueryParam,
    requestResponse: Object
  ): Promise<any> {
    return new Promise((resolve, reject) => {
      // do we need a 2FA or not
      if (requestResponse.hasOwnProperty(this.TWO_FACTOR_AUTH_RESPONSE_PROP)) {
        let TwoFAPart = requestResponse[this.TWO_FACTOR_AUTH_RESPONSE_PROP];

        if (TwoFAPart.mode == TwoFactorMode.CODE) {
          if (TwoFAPart.error == TwoFactorAuthResponse.PREREQUISITE) {
            // prepare the server for the 2FA
            let otpSalt = this.generateOtpSalt();
            param.variables[this.TWO_FACTOR_AUTH_OTP_SALT_PROP] = otpSalt;

            this.getWebsocketClient(param.useSecondarySocket).then(
              (wsClient) => {
                wsClient
                  .request({
                    query: this.parseQueryType(param.query),
                    variables: param.variables,
                  })
                  .subscribe({
                    next: (result) => {
                      this.handleSocketTwoFactorAuth(
                        param,
                        result["data"][param.query.endpoint]
                      )
                        .then((promiseResult) => {
                          resolve(promiseResult);
                        })
                        .catch(reject);
                    },
                    error: reject,
                  });
              }
            );
          } else if (TwoFAPart.error == TwoFactorAuthResponse.REQUIRED) {
            // we prepared the server for the request, now use the code from the email

            // use a dialog for asking the code
            const dialogRef: MatDialogRef<
              TwoFactorDialogComponent,
              DialogData
            > = this.dialog.open<TwoFactorDialogComponent>(
              TwoFactorDialogComponent
            );

            dialogRef.afterClosed().subscribe((dialogResult) => {
              if (dialogResult.code) {
                param.variables[this.TWO_FACTOR_AUTH_CODE_PROP] =
                  dialogResult.code;

                this.getWebsocketClient(param.useSecondarySocket).then(
                  (wsClient) => {
                    wsClient
                      .request({
                        query: this.parseQueryType(param.query),
                        variables: param.variables,
                      })
                      .subscribe({
                        next: (result) => {
                          this.handleSocketTwoFactorAuth(
                            param,
                            result["data"][param.query.endpoint]
                          )
                            .then((promiseResult) => {
                              resolve(promiseResult);
                            })
                            .catch(reject);
                        },
                        error: reject,
                      });
                  }
                );
              } else {
                // pressed cancel on the dialog
                reject(new Error("Cancelled dialog"));
              }
            });
          } else if (TwoFAPart.error == TwoFactorAuthResponse.DENIED) {
            reject(new Error("Wrong code"));
          }
        } else {
          console.error("need to implement this 2FA mode", TwoFAPart.mode);
          reject(
            new Error("need to implement this 2FA mode: " + TwoFAPart.mode)
          );
        }
      } else {
        resolve(requestResponse);
      }
    });
  }

  public closeConnection() {
    if (this.wsClientPrimary) {
      console.log('connection close')
      this.wsClientPrimary.close();
    }
    if (this.wsClientSecondary) {
      console.log('connection close secondary')
      this.wsClientSecondary.close();
    }
    this.isConnected = false;
  }

  public startConnection(): Promise<void> {
    return this.getWebsocketClient().then((client) => {
      console.log("conn :3 - ws client created");

      return this.query({
        query: pingQuery,
      }).then((ping) => {
        this.pingTime = Math.round(ping["time"] * 1000);

        this.isConnected = true;

        console.log("conn :4 - ws ping confirmed");
        return;
      });
    });
  }

  private getWebsocketClient(
    useSecondarySocket: boolean = false
  ): Promise<SubscriptionClient> {
    return new Promise((resolve, reject) => {
      if (!useSecondarySocket) {
        if (this.wsClientPrimary) {
          resolve(this.wsClientPrimary);
        } else {
          this.wsClientPrimary = this.createWebsocket(
            this.environmentService.url_ws,
            (res) => {
              resolve(this.wsClientPrimary);
            }
          );
        }
      } else {
        if (!this.wsClientSecondary || this.wsClientSecondary.status == 3) {
          this.wsClientSecondary = this.createWebsocket(
            this.environmentService.url_sws,
            () => {
              resolve(this.wsClientSecondary);
            },
            false // we need to do manually the download resume
          );
        } else {
          resolve(this.wsClientSecondary);
        }
      }
    });
  }

  private createWebsocket(url, onConnected, autoReconnect: boolean = true) {
    let client = new SubscriptionClient(
      url,
      {
        reconnect: autoReconnect,
        connectionParams: () => {
          // this should be a funtction so the underlaying library always call it and get fresh cookie
          return {
            headers: {
              [this.CSRF_HEADER_KEY]: this.cookie.get(this.CSRF_KEY),
            },
          };
        },
        connectionCallback: () => {
          onConnected();
        },
      },
      null,
      null,
      msgpack.decode,
      msgpack.encode
    );
    client.onDisconnected((err) => {
      console.log("ws dc", err);
      if (err === SpecificWebsocketError.CSRF) {
        if (!this.isCSRFTokenPending) {
          this.isCSRFTokenPending = true;
          this.resetCSRFToken().finally(() => {
            setTimeout(() => {
              this.isCSRFTokenPending = false;
            }, 1000); // wait for SubscriptionClient repair
          });
        }
      }
      else if (err === SpecificWebsocketError.AUTH_CLOSE) {
        console.log("auth closed");
        this.onAuthCloseCallbacks.forEach((cb) => {
          cb();
        });
      }
    });

    // response for ack payload
    client.onConnected((res) => {
      console.log("ws on connected", res);
    });
    client.onReconnected((res) => {
      console.log("ws on reconnect", res);
    });
    client.onError((res) => {
      console.log("ws on error", res);
    });
    client.onConnecting((res) => {
      console.log("ws on connecting", res);
    });
    client.onReconnecting((res) => {
      console.log("ws on reconnecting", res);
    });

    return client;
  }

  public makeAjaxPost(
    url: string,
    params: Object,
    checkTwoFactor: boolean = true
  ) {
    let p = this.http
      .post(
        this.environmentService.url_api + "/" + url,
        params /*new Uint8Array(param).buffer*/,
        this.getHttpHeaderOptions()
      )
      .toPromise()
      .then((res) => {
        return res;
      })
      .catch((err) => {
        if (err["status"] === 403) {
          return this.getCSRFToken().then((token) => {
            return this.makeAjaxPost(url, params, checkTwoFactor);
          });
        } else {
          throw err;
        }
      });

    if (checkTwoFactor) {
      return p.then((result) => {
        return this.handleHttpPostTwoFactorAuth(url, params, result);
      });
    } else {
      return p;
    }
  }

  public makeAjaxGet(url: string) {
    return this.http
      .get(
        this.environmentService.url_api + "/" + url,
        this.getHttpHeaderOptions()
      )
      .toPromise()
      .then((res) => {
        return res;
      })
      .catch((err) => {
        if (err["status"] === 403) {
          return this.getCSRFToken().then((token) => {
            return this.makeAjaxGet(url);
          });
        } else {
          throw err;
        }
      });
  }

  public getLoginSalt(email) {
    // rest/login-salt
    return this.makeAjaxPost("r/login-salt", {
      email: email,
    });
  }

  public login(
    email: string,
    authKey: string,
    sessionKey: string,
    otpSalt?: string,
    tfaCode?: string,
    rememberMe: boolean = false
  ) {
    // rest/login

    let params = {
      email,
      authentication_key: authKey,
      session_enc_key: sessionKey,
    };

    if (otpSalt) {
      params["otpSalt"] = otpSalt;
    }

    if (tfaCode) {
      params["2faCode"] = tfaCode;
    }

    if (rememberMe) {
      params["remember_me"] = 1;
    }

    if (localStorage.getItem("lang")) {
      params["locale"] = localStorage.getItem("lang");
    }

    return this.makeAjaxPost("r/login", params);
  }

  public logout(): Promise<void> {
    return this.makeAjaxPost("r/logout", {}).then(()=>{
      this.requestQueueHandler.reset();
      return;
    })
  }

  /**
   * Register prepare
   */
  public register(
    email: string,
    client_salt_input: string,
    encrypted_master_key: string,
    hashed_authentication_key: string
  ) {
    let params = {
      email,
      client_salt_input,
      encrypted_master_key,
      hashed_authentication_key,
    };

    if (localStorage.getItem("lang")) {
      params["locale"] = localStorage.getItem("lang");
    }

    return this.makeAjaxPost("r/register-prepare", params);
  }

  public registerLoginSalt(
    registerToken: string,
    email
  ): Promise<{ salt: string }> {
    return this.makeAjaxPost("r/register-login-salt", {
      register_token: registerToken,
      email,
    });
  }

  public registerLoginCheck(
    registerToken,
    email,
    authKey
  ): Promise<{ account_id: string; encrypted_master_key: string }> {
    return this.makeAjaxPost("r/register-login-check", {
      register_token: registerToken,
      email,
      authentication_key: authKey,
    });
  }

  public registerChallenge(
    registerToken: string,
    authKey: string,
    keyringInfo: create_account_register_keyring_request_result
  ): Promise<{ public_key: string; challenge: string }> {
    let params = Object.assign({}, keyringInfo);
    params["register_token"] = registerToken;
    params["authentication_key"] = authKey;

    return this.makeAjaxPost("r/register-challenge", params);
  }

  public registerConfirm(
    confirmRequest: create_account_register_confirm_request_result
  ) {
    return this.makeAjaxPost("r/register-confirm", confirmRequest);
  }

  public accountRecoveryPrepare(email: string) {
    let params = {
      email,
    };

    if (localStorage.getItem("lang")) {
      params["locale"] = localStorage.getItem("lang");
    }

    return this.makeAjaxPost("r/recover-prepare", params);
  }

  public accountRecoveryChallenge(
    token: string
  ): Promise<{ account_id: string; signing_secret_ec: string }> {
    return this.makeAjaxPost("r/recover-challenge", { recovery_token: token });
  }

  public accountRecoverConfirm(
    param: create_account_recovery_confirm_request_type
  ) {
    return this.makeAjaxPost("r/recover-confirm", param);
  }

  /**
   * Start a query request, use cache if can
   * @param {queryParam} params - check queryParam doc for details
   * @return Promise with the result
   */
  query(params: QueryParam): Promise<any> {
    let param = Object.assign(
      Object.assign({}, this.defaultQueryParam),
      params
    );
    param.query.operation = OperationType.QUERY;

    return new Promise((resolve, reject) => {
      param
        .encrypt(param.variables, param.extra)
        .then((encryptedVariables) => {
          let cacheKey = cacheCustomLogic.makeCacheKey(
            param.query,
            encryptedVariables
          );
          if (
            this.cache.isDataNeedToBeUpdated(
              cacheKey,
              param.query,
              encryptedVariables
            )
          ) {
            if (this.requestQueueHandler.isRequestAlreadyFetched(cacheKey)) {
              this.requestQueueHandler.registerToRequestQueue(
                cacheKey,
                resolve
              );
            } else {
              this.requestQueueHandler.registerToRequestQueue(
                cacheKey,
                resolve
              );
              //let client = useSecondarySocket?this.wsClientSecondary:this.wsClientPrimary;

              this.getWebsocketClient(param.useSecondarySocket).then(
                (wsClient) => {
                  try {
                    wsClient
                      .request({
                        query: this.parseQueryType(param.query),
                        variables: encryptedVariables,
                      })
                      .subscribe({
                        next: (result) => {
                          // cut the query call tree structure, and return with the data only
                          result = this.getDataFromQueryResponse(
                            params.query,
                            result
                          );

                          try {
                            param
                              .decrypt(result, param.variables, param.extra)
                              .then((result) => {
                                this.cache.mergeQueryResult(
                                  param.query,
                                  encryptedVariables,
                                  result
                                );

                                //let cachedData = this.cache.getCachedData(cacheKey, query, variables)
                                let handlers =
                                  this.requestQueueHandler.getRequestQueue(
                                    cacheKey
                                  )
                                handlers.forEach((cb) => {
                                  cb(result); // the current resolver
                                });
                                this.requestQueueHandler.emptyRequestQueue(
                                  cacheKey
                                );
                              })
                              .catch((e) => {
                                reject(e);
                              });
                          } catch (e) {
                            reject(e);
                          }
                        },
                        error: (err) => {
                          this.requestQueueHandler.emptyRequestQueue(cacheKey);
                          reject(err);
                        },
                      });
                  } catch (e) {
                    reject(e);
                  }
                }
              );
            }
          } else {
            // simulate the result of a normal request cycle, but give the result immediatly,
            // because we can read it from the cache
            let cacheData = this.cache.get(cacheKey);
            if (cacheData === undefined) {
              return reject(
                "Can not find data in the cache but this query was an offline query"
              );
            } else {
              return resolve(this.cache.get(cacheKey));
            }
          }
        })
        .catch((err) => {
          reject(err);
        });
    });
  }

  /**
   * Start a mutate request
   * @param {queryParam} params - check queryParam doc for details
   * @return Promise with the result
   */
  mutate(params: QueryParam): Promise<any> {
    let param = Object.assign(
      Object.assign({}, this.defaultQueryParam),
      params
    );
    param.query.operation = OperationType.MUTATION;

    return new Promise((resolve, reject) => {
      param
        .encrypt(param.variables, param.extra)
        .then((encryptedVariables) => {
          this.getWebsocketClient(param.useSecondarySocket)
            .then((wsClient) => {
              try {
                let sub = wsClient
                  .request({
                    query: this.parseQueryType(param.query),
                    variables: encryptedVariables,
                  })
                  .subscribe({
                    next: (result) => {
                      // cut the query call tree structure, and return with the data only
                      result = this.getDataFromQueryResponse(
                        params.query,
                        result
                      );

                      this.handleSocketTwoFactorAuth(params, result)
                        .then((result) => {
                          try {
                            param
                              .decrypt(result, param.variables, param.extra)
                              .then((result) => {
                                this.cache.mergeQueryResult(
                                  param.query,
                                  encryptedVariables,
                                  result
                                );
                                resolve(result);
                                sub.unsubscribe();
                              })
                              .catch((e) => {
                                reject(e);
                              });
                          } catch (e) {
                            reject(e);
                          }
                        })
                        .catch(reject);
                    },
                    error: reject,
                  });
              } catch (e) {
                reject(e);
              }
            })

            .catch((err) => {
              reject(err);
            });
        })
        .catch((err) => {
          reject(err);
        });
    });
  }

  /**
   * Register a subscription request
   * @param {queryParam} params - check queryParam doc for details
   * @return Observer
   */
  subscribe(params: QueryParam) {
    let param = Object.assign(
      Object.assign({}, this.defaultQueryParam),
      params
    );
    param.query.operation = OperationType.SUBSCRIPTION;

    return new Observable((observer) => {
      let sub;
      let offError;
      let offDisconnect;

      let errorHandling = (err)=>{
        observer.error(err);
        sub && sub.unsubscribe()
        offError && offError(); // must or memory leak in request cuz of scope
        offDisconnect && offDisconnect();
      }

      param
        .encrypt(param.variables, param.extra)
        .then((encryptedVariables) => {
          this.getWebsocketClient(param.useSecondarySocket)
            .then((wsClient) => {

              offError = wsClient.onError((err)=>{
                console.error("<- ws client error", err)
                errorHandling(err)
              })
              offDisconnect = wsClient.onDisconnected((err)=>{
                console.error("<- ws client dc", err)
                errorHandling(err);
              })
              try {
                
                sub = wsClient
                  .request({
                    query: this.parseQueryType(param.query),
                    variables: encryptedVariables,
                  })
                  .subscribe({
                    next: async (result) => {
                      // cut the query call tree structure, and return with the data only
                      result = this.getDataFromQueryResponse(
                        params.query,
                        result
                      );

                      try {
                        await param
                          .decrypt(result, param.variables, param.extra)
                          .then((result) => {
                            this.cache.mergeQueryResult(
                              param.query,
                              encryptedVariables,
                              result
                            );
                            observer.next(result);
                          })
                          .catch((e) => {
                            errorHandling(e);
                          });
                      } catch (e) {
                        errorHandling(e);
                      }
                    },
                    error: (e) => {
                      errorHandling(e);
                    },
                    complete: ()=>{
                      // can not end here, because the last x part still in decryption
                      //observer.complete();
                      offError && offError();
                      offDisconnect && offDisconnect();
                    },
                  });
              } catch (e) {
                errorHandling(e);
              }
            })
            .catch((err) => {
              errorHandling(err);
            });
        })
        .catch((err) => {
          errorHandling(err);
        });

      // observer unsubscribe result
      return () => {
        offDisconnect && offDisconnect();
        offError && offError();

        if (sub) {
          sub.unsubscribe();
        } else {
          setTimeout(() => {
            // if you unsubscribed immediatly, you couldn't reset the inner observer
            // so just to be sure
            if (sub) sub.unsubscribe();
          }, 1000);
        }
      };
    });
  }

  
}
