import { Decimal } from 'decimal.js';
import { errorProxy } from './utility';
import {
  CurrencyCode,
  Network,
  AccountType,
  AccountJSON,
  AccountDataSnapshotJSON,
  Address,
  CardType,
  CardStatus,
  CardDetails,
  AuthenticationConfig,
  KbaAnswer,
  CustodyType,
  TradingPairJSON,
} from './interfaces';
import { Wallet } from './Wallet';
import { ZumoKitError } from './ZumoKitError';
import {
  Account,
  AccountFiatProperties,
  AccountDataSnapshot,
  Card,
  ComposedTransaction,
  ComposedExchange,
  Transaction,
  Exchange,
  TradingPair,
} from './models';

/**
 * User instance, obtained via {@link ZumoKit.signIn} method, provides methods for managing user wallet and accounts.
 * <p>
 * Refer to
 * <a href="https://developers.zumo.money/docs/guides/manage-user-wallet">Manage User Wallet</a>,
 * <a href="https://developers.zumo.money/docs/guides/create-fiat-account">Create Fiat Account</a>,
 * <a href="https://developers.zumo.money/docs/guides/view-user-accounts">View User Accounts</a> and
 * <a href="https://developers.zumo.money/docs/guides/get-account-data">Get Account Data</a>
 * guides for usage details.
 */
export class User {
  private zumoCoreModule: any;

  private userImpl: any;

  private accountDataListeners: Array<
    (state: Array<AccountDataSnapshot>) => void
  > = [];

  private accountDataListenersImpl: Array<any> = [];

  /** User identifier. */
  id: string;

  /** User integrator identifier. */
  integratorId: string;

  /** Indicator if user has wallet. */
  hasWallet: boolean;

  /** User accounts. */
  accounts: Array<Account>;

  /** @internal */
  constructor(zumoCoreModule: any, userImpl: any) {
    this.zumoCoreModule = zumoCoreModule;
    this.userImpl = userImpl;
    this.id = userImpl.getId();
    this.integratorId = userImpl.getIntegratorId();
    this.hasWallet = userImpl.hasWallet();
    this.accounts = JSON.parse(userImpl.getAccounts()).map(
      (json: AccountJSON) => new Account(json)
    );

    this.addAccountDataListener((snapshots) => {
      this.accounts = snapshots.map((snapshot) => snapshot.account);
    });
  }

  /**
   * Create user wallet seeded by provided mnemonic and encrypted with user's password.
   * <p>
   * Mnemonic can be generated by {@link Utils.generateMnemonic} utility method.
   * @param  mnemonic       mnemonic seed phrase
   * @param  password       user provided password
   */
  createWallet(mnemonic: string, password: string) {
    const { zumoCoreModule } = this;
    return errorProxy<Wallet>(zumoCoreModule, (resolve: any, reject: any) => {
      this.userImpl.createWallet(
        mnemonic,
        password,
        new zumoCoreModule.WalletCallbackWrapper({
          onError: (error: string) => {
            reject(new ZumoKitError(error));
          },
          onSuccess: (wallet: any) => {
            this.hasWallet = true;
            resolve(new Wallet(zumoCoreModule, wallet));
          },
        })
      );
    });
  }

  /**
   * Recover user wallet with mnemonic seed phrase corresponding to user's wallet.
   * This can be used if user forgets his password or wants to change his wallet password.
   * @param  mnemonic       mnemonic seed phrase corresponding to user's wallet
   * @param  password       user provided password
   */
  recoverWallet(mnemonic: string, password: string) {
    const { zumoCoreModule } = this;
    return errorProxy<Wallet>(zumoCoreModule, (resolve: any, reject: any) => {
      this.userImpl.recoverWallet(
        mnemonic,
        password,
        new zumoCoreModule.WalletCallbackWrapper({
          onError(error: string) {
            reject(new ZumoKitError(error));
          },
          onSuccess(wallet: any) {
            resolve(new Wallet(zumoCoreModule, wallet));
          },
        })
      );
    });
  }

  /**
   * Unlock user wallet with user's password.
   * @param  password       user provided password
   */
  unlockWallet(password: string) {
    const { zumoCoreModule } = this;
    return errorProxy<Wallet>(zumoCoreModule, (resolve: any, reject: any) => {
      this.userImpl.unlockWallet(
        password,
        new zumoCoreModule.WalletCallbackWrapper({
          onError(error: string) {
            reject(new ZumoKitError(error));
          },
          onSuccess(wallet: any) {
            resolve(new Wallet(zumoCoreModule, wallet));
          },
        })
      );
    });
  }

  /**
   * Reveal mnemonic seed phrase used to seed user wallet.
   * @param  password       user provided password
   */
  revealMnemonic(password: string) {
    const { zumoCoreModule } = this;
    return errorProxy<string>(zumoCoreModule, (resolve: any, reject: any) => {
      this.userImpl.revealMnemonic(
        password,
        new zumoCoreModule.MnemonicCallbackWrapper({
          onError(error: string) {
            reject(new ZumoKitError(error));
          },
          onSuccess(mnemonic: string) {
            resolve(mnemonic);
          },
        })
      );
    });
  }

  /**
   * Check if mnemonic seed phrase corresponds to user's wallet.
   * This is useful for validating seed phrase before trying to recover wallet.
   * @param  mnemonic       mnemonic seed phrase
   */
  isRecoveryMnemonic(mnemonic: string): boolean {
    try {
      return this.userImpl.isRecoveryMnemonic(mnemonic);
    } catch (exception) {
      throw new ZumoKitError(this.zumoCoreModule.getException(exception));
    }
  }

  /**
   * Get account in specific currency, on specific network, with specific type.
   * @param  currencyCode   currency code, e.g. 'BTC', 'ETH' or 'GBP'
   * @param  network        network type, e.g. 'MAINNET', 'TESTNET' or 'RINKEBY'
   * @param  type           account type, e.g. 'STANDARD', 'COMPATIBILITY' or 'SEGWIT'
   * @param  custodyType    custody type, e.g. 'CUSTODY' or 'NON-CUSTODY'
   */
  getAccount(
    currencyCode: CurrencyCode,
    network: Network,
    type: AccountType,
    custodyType: CustodyType
  ) {
    const account = this.userImpl.getAccount(
      currencyCode,
      network,
      type,
      custodyType
    );

    if (account.hasValue()) {
      return new Account(JSON.parse(account.get()));
    }

    return null;
  }

  /**
   * Check if user is a registered fiat customer.
   */
  isFiatCustomer(): boolean {
    return this.userImpl.isFiatCustomer();
  }

  /**
   * Make user fiat customer by providing user's personal details.
   * @param  firstName       first name
   * @param  middleName      middle name or null
   * @param  lastName        last name
   * @param  dateOfBirth     date of birth in ISO 8601 format, e.g '2020-08-12'
   * @param  email           email
   * @param  phone           phone number
   * @param  address         home address
   */
  makeFiatCustomer(
    firstName: string,
    middleName: string | null,
    lastName: string,
    dateOfBirth: string,
    email: string,
    phone: string,
    address: Address
  ) {
    return errorProxy<void>(
      this.zumoCoreModule,
      (resolve: any, reject: any) => {
        const optionalMiddleName = new this.zumoCoreModule.OptionalString();
        if (middleName) optionalMiddleName.set(middleName);

        this.userImpl.makeFiatCustomer(
          firstName,
          optionalMiddleName,
          lastName,
          dateOfBirth,
          email,
          phone,
          JSON.stringify(address),
          new this.zumoCoreModule.SuccessCallbackWrapper({
            onError(error: string) {
              reject(new ZumoKitError(error));
            },
            onSuccess() {
              resolve();
            },
          })
        );
      }
    );
  }

  /**
   * Create custody or fiat account for specified currency. When creating a fiat account,
   * user must already be fiat customer.
   * @param  currencyCode  country code in ISO 4217 format, e.g. 'GBP'
   */
  createAccount(currencyCode: CurrencyCode) {
    return errorProxy<Account>(
      this.zumoCoreModule,
      (resolve: any, reject: any) => {
        this.userImpl.createAccount(
          currencyCode,
          new this.zumoCoreModule.AccountCallbackWrapper({
            onError(error: string) {
              reject(new ZumoKitError(error));
            },
            onSuccess(account: string) {
              resolve(new Account(JSON.parse(account)));
            },
          })
        );
      }
    );
  }

  /**
   * Get nominated account details for specified account if it exists.
   * Refer to
   * <a href="https://developers.zumo.money/docs/guides/send-transactions#bitcoin">Create Fiat Account</a>
   * for explanation about nominated account.
   * @param  accountId     {@link Account Account} identifier
   */
  getNominatedAccountFiatProperties(accountId: string) {
    return errorProxy<AccountFiatProperties | null>(
      this.zumoCoreModule,
      (resolve: any) => {
        this.userImpl.getNominatedAccountFiatProperties(
          accountId,
          new this.zumoCoreModule.AccountFiatPropertiesCallbackWrapper({
            onError() {
              resolve(null);
            },
            onSuccess(accountFiatProperties: string) {
              resolve(
                new AccountFiatProperties(JSON.parse(accountFiatProperties))
              );
            },
          })
        );
      }
    );
  }

  /**
   * Fetch Strong Customer Authentication (SCA) config.
   */
  fetchAuthenticationConfig() {
    return errorProxy<void>(
      this.zumoCoreModule,
      (resolve: any, reject: any) => {
        this.userImpl.fetchAuthenticationConfig(
          new this.zumoCoreModule.AuthenticationConfigCallbackWrapper({
            onError(error: string) {
              reject(new ZumoKitError(error));
            },
            onSuccess(authConfig: string) {
              resolve(JSON.parse(authConfig) as AuthenticationConfig);
            },
          })
        );
      }
    );
  }

  /**
   * Create card for a fiat account.
   * <p>
   * At least one Knowledge-Based Authentication (KBA) answers should be defined,
   * answers are limited to 256 characters and cannot be null or empty and only
   * one answer per question type should be provided.
   * @param  fiatAccountId fiat {@link Account account} identifier
   * @param  cardType       'VIRTUAL' or 'PHYSICAL'
   * @param  mobileNumber   card holder mobile number, starting with a '+', followed by the country code and then the mobile number, or null
   * @param  knowledgeBase  list of KBA answers
   */
  createCard(
    fiatAccountId: string,
    cardType: CardType,
    mobileNumber: string,
    knowledgeBase: Array<KbaAnswer>
  ) {
    return errorProxy<void>(
      this.zumoCoreModule,
      (resolve: any, reject: any) => {
        this.userImpl.createCard(
          fiatAccountId,
          cardType,
          mobileNumber,
          JSON.stringify(knowledgeBase),
          new this.zumoCoreModule.CardCallbackWrapper({
            onError(error: string) {
              reject(new ZumoKitError(error));
            },
            onSuccess(card: string) {
              resolve(new Card(JSON.parse(card)));
            },
          })
        );
      }
    );
  }

  /**
   * Set card status to 'ACTIVE', 'BLOCKED' or 'CANCELLED'.
   * - To block card, set card status to 'BLOCKED'.
   * - To activate a physical card, set card status to 'ACTIVE' and provide PAN and CVC2 fields.
   * - To cancel a card, set card status to 'CANCELLED'.
   * - To unblock a card, set card status to 'ACTIVE.'.
   * @param  cardId        {@link Card card}  identifier
   * @param  cardStatus    new card status
   * @param  pan           PAN when activating a physical card, null otherwise (defaults to null)
   * @param  cvv2          CVV2 when activating a physical card, null otherwise (defaults to null)
   */
  setCardStatus(
    cardId: string,
    cardStatus: CardStatus,
    pan: string | null = null,
    cvv2: string | null = null
  ) {
    return errorProxy<void>(
      this.zumoCoreModule,
      (resolve: any, reject: any) => {
        const optionalPan = new this.zumoCoreModule.OptionalString();
        if (pan) optionalPan.set(pan);

        const optionalCvv2 = new this.zumoCoreModule.OptionalString();
        if (cvv2) optionalCvv2.set(cvv2);

        this.userImpl.setCardStatus(
          cardId,
          cardStatus,
          optionalPan,
          optionalCvv2,
          new this.zumoCoreModule.SuccessCallbackWrapper({
            onError(error: string) {
              reject(new ZumoKitError(error));
            },
            onSuccess() {
              resolve();
            },
          })
        );
      }
    );
  }

  /**
   * Reveals sensitive card details.
   * @param  cardId        card identifier
   */
  revealCardDetails(cardId: string) {
    return errorProxy<void>(
      this.zumoCoreModule,
      (resolve: any, reject: any) => {
        this.userImpl.revealCardDetails(
          cardId,
          new this.zumoCoreModule.CardDetailsCallbackWrapper({
            onError(error: string) {
              reject(new ZumoKitError(error));
            },
            onSuccess(cardDetails: string) {
              resolve(JSON.parse(cardDetails) as CardDetails);
            },
          })
        );
      }
    );
  }

  /**
   * Reveal card PIN.
   * @param  cardId        {@link Card card} identifier
   */
  revealPin(cardId: string) {
    return errorProxy<void>(
      this.zumoCoreModule,
      (resolve: any, reject: any) => {
        this.userImpl.revealPin(
          cardId,
          new this.zumoCoreModule.PinCallbackWrapper({
            onError(error: string) {
              reject(new ZumoKitError(error));
            },
            onSuccess(pin: number) {
              resolve(pin);
            },
          })
        );
      }
    );
  }

  /**
   * Unblock card PIN.
   * @param  cardId        {@link Card card} identifier
   */
  unblockPin(cardId: string) {
    return errorProxy<void>(
      this.zumoCoreModule,
      (resolve: any, reject: any) => {
        this.userImpl.unblockPin(
          cardId,
          new this.zumoCoreModule.SuccessCallbackWrapper({
            onError(error: string) {
              reject(new ZumoKitError(error));
            },
            onSuccess() {
              resolve();
            },
          })
        );
      }
    );
  }

  /**
   * Add KBA answers to a card without SCA.
   * <p>
   * This endpoint is used to set Knowledge-Based Authentication (KBA) answers to
   * a card without Strong Customer Authentication (SCA). Once it is set SCA flag
   * on corresponding card is set to true.
   * <p>
   * At least one answer should be defined, answers are limited to 256 characters and
   * cannot be null or empty and only one answer per question type should be provided.
   *
   * @param  cardId         card id
   * @param  knowledgeBase  list of KBA answers
   */
  setAuthentication(cardId: string, knowledgeBase: Array<KbaAnswer>) {
    return errorProxy<void>(
      this.zumoCoreModule,
      (resolve: any, reject: any) => {
        this.userImpl.setAuthentication(
          cardId,
          JSON.stringify(knowledgeBase),
          new this.zumoCoreModule.SuccessCallbackWrapper({
            onError(error: string) {
              reject(new ZumoKitError(error));
            },
            onSuccess() {
              resolve();
            },
          })
        );
      }
    );
  }

  /**
   * Listen to all account data changes.
   *
   * @param listener interface to listen to user changes
   */
  addAccountDataListener(
    listener: (snapshots: Array<AccountDataSnapshot>) => void
  ) {
    const listenerImpl = new this.zumoCoreModule.AccountDataListenerWrapper({
      onDataChange(snapshots: string) {
        listener(
          JSON.parse(snapshots).map(
            (json: AccountDataSnapshotJSON) => new AccountDataSnapshot(json)
          )
        );
      },
    });

    this.userImpl.addAccountDataListener(listenerImpl);

    this.accountDataListeners.push(listener);
    this.accountDataListenersImpl.push(listenerImpl);
  }

  /**
   * Remove listener to state changes.
   *
   * @param listener interface to listen to state changes
   */
  removeAccountDataListener(
    listener: (snapshots: Array<AccountDataSnapshot>) => void
  ) {
    let index;
    // eslint-disable-next-line no-cond-assign
    while ((index = this.accountDataListeners.indexOf(listener)) !== -1) {
      this.accountDataListeners.splice(index, 1);
      this.userImpl.removeAccountDataListener(
        this.accountDataListenersImpl.splice(index, 1)[0]
      );
    }
  }

  /**
   * Compose transaction between custody or fiat accounts in Zumo ecosystem.
   * Refer to <a href="https://developers.zumo.money/docs/guides/send-transactions#internal-transaction">Send Transactions</a>
   * guide for usage details.
   *
   * @param fromAccountId custody or fiat {@link  Account Account} identifier
   * @param toAccountId   custody or fiat {@link  Account Account} identifier
   * @param amount        amount in source account currency
   * @param sendMax       send maximum possible funds to destination (defaults to false)
   */
  composeTransaction(
    fromAccountId: string,
    toAccountId: string,
    amount: Decimal | null,
    sendMax = false
  ) {
    return errorProxy<ComposedTransaction>(
      this.zumoCoreModule,
      (resolve: any, reject: any) => {
        const amountOptional = new this.zumoCoreModule.OptionalDecimal();
        if (amount)
          amountOptional.set(
            new this.zumoCoreModule.Decimal(amount.toString())
          );

        this.userImpl.composeTransaction(
          fromAccountId,
          toAccountId,
          amountOptional,
          sendMax,
          new this.zumoCoreModule.ComposeTransactionCallbackWrapper({
            onError(error: string) {
              reject(new ZumoKitError(error));
            },
            onSuccess(composedTransaction: string) {
              resolve(new ComposedTransaction(JSON.parse(composedTransaction)));
            },
          })
        );
      }
    );
  }

  /**
   * Compose custody withdraw transaction from custody account.
   * Refer to <a href="https://developers.zumo.money/docs/guides/send-transactions#custody-withdraw-transaction">Send Transactions</a>
   * guide for usage details.
   *
   * @param fromAccountId custody or fiat {@link  Account Account} identifier
   * @param destination   destination address or non-custodial account identifier
   * @param amount        amount in source account currency
   * @param sendMax       send maximum possible funds to destination (defaults to false)
   */
  composeCustodyWithdrawTransaction(
    fromAccountId: string,
    destination: string,
    amount: Decimal | null,
    sendMax = false
  ) {
    return errorProxy<ComposedTransaction>(
      this.zumoCoreModule,
      (resolve: any, reject: any) => {
        const amountOptional = new this.zumoCoreModule.OptionalDecimal();
        if (amount)
          amountOptional.set(
            new this.zumoCoreModule.Decimal(amount.toString())
          );

        this.userImpl.composeCustodyWithdrawTransaction(
          fromAccountId,
          destination,
          amountOptional,
          sendMax,
          new this.zumoCoreModule.ComposeTransactionCallbackWrapper({
            onError(error: string) {
              reject(new ZumoKitError(error));
            },
            onSuccess(composedTransaction: string) {
              resolve(new ComposedTransaction(JSON.parse(composedTransaction)));
            },
          })
        );
      }
    );
  }

  /**
   * Compose transaction from user fiat account to user's nominated account.
   * Refer to <a href="https://developers.zumo.money/docs/guides/send-transactions#nominated-transaction">Send Transactions</a>
   * guide for usage details.
   *
   * @param fromAccountId {@link  Account Account} identifier
   * @param amount        amount in source account currency
   * @param sendMax       send maximum possible funds to destination (defaults to false)
   */
  composeNominatedTransaction(
    fromAccountId: string,
    amount: Decimal | null,
    sendMax = false
  ) {
    return errorProxy<ComposedTransaction>(
      this.zumoCoreModule,
      (resolve: any, reject: any) => {
        const amountOptional = new this.zumoCoreModule.OptionalDecimal();
        if (amount)
          amountOptional.set(
            new this.zumoCoreModule.Decimal(amount.toString())
          );

        this.userImpl.composeNominatedTransaction(
          fromAccountId,
          amountOptional,
          sendMax,
          new this.zumoCoreModule.ComposeTransactionCallbackWrapper({
            onError(error: string) {
              reject(new ZumoKitError(error));
            },
            onSuccess(composedTransaction: string) {
              resolve(new ComposedTransaction(JSON.parse(composedTransaction)));
            },
          })
        );
      }
    );
  }

  /**
   * Submit a transaction asynchronously.
   * Refer to <a href="https://developers.zumo.money/docs/guides/send-transactions#submit-transaction">Send Transactions</a>
   * guide for usage details.
   *
   * @param composedTransaction Composed transaction retrieved as a result
   *                            of one of the compose transaction methods
   * @param toAccountId         Debit account id override, only applicable to direct custody deposits.
   *                            In case no account id is specified senders custody account will be debited.
   * @param metadata            Optional metadata that will be attached to transaction
   */
  submitTransaction(
    composedTransaction: ComposedTransaction,
    toAccountId: string | null = null,
    metadata: any = null
  ) {
    const optionalToAccountId = new this.zumoCoreModule.OptionalString();
    if (toAccountId) optionalToAccountId.set(toAccountId);

    return errorProxy<Transaction>(
      this.zumoCoreModule,
      (resolve: any, reject: any) => {
        this.userImpl.submitTransaction(
          JSON.stringify(composedTransaction.json),
          optionalToAccountId,
          JSON.stringify(metadata),
          new this.zumoCoreModule.SubmitTransactionCallbackWrapper({
            onError(error: string) {
              reject(new ZumoKitError(error));
            },
            onSuccess(transaction: string) {
              resolve(new Transaction(JSON.parse(transaction)));
            },
          })
        );
      }
    );
  }

  /**
   * Fetch trading pairs that are currently supported.
   */
  fetchTradingPairs() {
    return errorProxy<void>(
      this.zumoCoreModule,
      (resolve: any, reject: any) => {
        this.userImpl.fetchTradingPairs(
          new this.zumoCoreModule.StringifiedJsonCallbackWrapper({
            onError(error: string) {
              reject(new ZumoKitError(error));
            },
            onSuccess(stringifiedJSON: string) {
              const tradingPairsJSON = JSON.parse(
                stringifiedJSON
              ) as TradingPairJSON[];

              resolve(tradingPairsJSON.map((json) => new TradingPair(json)));
            },
          })
        );
      }
    );
  }

  /**
   * Compose exchange asynchronously.
   * Refer to <a href="https://developers.zumo.money/docs/guides/make-exchanges#compose-exchange">Make Exchanges</a>
   * guide for usage details.
   *
   * @param debitAccountId      {@link  Account Account} identifier
   * @param creditAccountId     {@link  Account Account} identifier
   * @param debitAmount         amount to be debited from debit account
   * @param sendMax             exchange maximum possible funds (defaults to false)
   */
  composeExchange(
    debitAccountId: string,
    creditAccountId: string,
    debitAmount: Decimal | null,
    sendMax = false
  ) {
    return errorProxy<ComposedExchange>(
      this.zumoCoreModule,
      (resolve: any, reject: any) => {
        const amountOptional = new this.zumoCoreModule.OptionalDecimal();
        if (debitAmount)
          amountOptional.set(
            new this.zumoCoreModule.Decimal(debitAmount.toString())
          );

        this.userImpl.composeExchange(
          debitAccountId,
          creditAccountId,
          amountOptional,
          sendMax,
          new this.zumoCoreModule.ComposeExchangeCallbackWrapper({
            onError(error: string) {
              reject(new ZumoKitError(error));
            },
            onSuccess(composedExchange: string) {
              resolve(new ComposedExchange(JSON.parse(composedExchange)));
            },
          })
        );
      }
    );
  }

  /**
   * Submit an exchange asynchronously.
   * Refer to <a href="https://developers.zumo.money/docs/guides/make-exchanges#submit-exchange">Make Exchanges</a>
   * guide for usage details.
   *
   * @param composedExchange Composed exchange retrieved as the result
   *                          of {@link composeExchange} method
   */
  submitExchange(composedExchange: ComposedExchange) {
    return errorProxy<Exchange>(
      this.zumoCoreModule,
      (resolve: any, reject: any) => {
        this.userImpl.submitExchange(
          JSON.stringify(composedExchange.json),
          new this.zumoCoreModule.SubmitExchangeCallbackWrapper({
            onError(error: string) {
              reject(new ZumoKitError(error));
            },
            onSuccess(exchange: string) {
              resolve(new Exchange(JSON.parse(exchange)));
            },
          })
        );
      }
    );
  }
}
