'use strict';
import Mnemonic from '@bcpros/bitcore-mnemonic';
import {
  BitcoreLib as Bitcore,
  BitcoreLibCash,
  Deriver,
  Transactions
} from '@bcpros/crypto-wallet-core';
import { singleton } from 'preconditions';
import sjcl from 'sjcl';
import 'source-map-support/register';
import Uuid from 'uuid';
import { Constants, Utils } from './common';
import { Credentials } from './credentials';
import { Errors } from './errors';
import log from './log';

const $ = singleton();

const wordsForLang: any = {
  en: Mnemonic.Words.ENGLISH,
  es: Mnemonic.Words.SPANISH,
  ja: Mnemonic.Words.JAPANESE,
  zh: Mnemonic.Words.CHINESE,
  fr: Mnemonic.Words.FRENCH,
  it: Mnemonic.Words.ITALIAN
};

// we always set 'livenet' for xprivs. it has no consequences
// other than the serialization
const NETWORK: string = 'livenet';

export class Key {
  #xPrivKey: string;
  #xPrivKeyEncrypted: string;
  #version: number;
  #mnemonic: string;
  #mnemonicEncrypted: string;
  #mnemonicHasPassphrase: boolean;

  public id: any;
  public use0forBCH: boolean;
  public use44forMultisig: boolean;
  public compliantDerivation: boolean;
  public BIP45: boolean;

  public fingerPrint: string;

  /*
   *  public readonly exportFields = {
   *    'xPrivKey': '#xPrivKey',
   *    'xPrivKeyEncrypted': '#xPrivKeyEncrypted',
   *    'mnemonic': '#mnemonic',
   *    'mnemonicEncrypted': '#mnemonicEncrypted',
   *    'version': '#version',
   *    'mnemonicHasPassphrase': 'mnemonicHasPassphrase',
   *    'fingerPrint': 'fingerPrint', //  32bit fingerprint
   *    'compliantDerivation': 'compliantDerivation',
   *    'BIP45': 'BIP45',
   *
   *    // data for derived credentials.
   *    'use0forBCH': 'use0forBCH', // use the 0 coin' path element in BCH  (legacy)
   *    'use44forMultisig': 'use44forMultisig', // use the purpose 44' for multisig wallts (legacy)
   *    'id': 'id',
   *  };
   */
  
  /**
   * @param {Object} opts
   * @param {String} opts.password   encrypting password
   * @param {String} seedType new|extendedPrivateKey|object|mnemonic
   * @param {String} seedData
   */
  constructor(
    opts: {
      id?: string;
      seedType: string;
      seedData?: any;
      passphrase?: string; // seed passphrase
      password?: string; // encrypting password
      sjclOpts?: any; // options to SJCL encrypt
      use0forBCH?: boolean;
      useLegacyPurpose?: boolean;
      useLegacyCoinType?: boolean;
      nonCompliantDerivation?: boolean;
      language?: string;
    } = { seedType: 'new' }
  ) {
    this.#version = 1;
    this.id = opts.id || Uuid.v4();

    // bug backwards compatibility flags
    this.use0forBCH = opts.useLegacyCoinType;
    this.use44forMultisig = opts.useLegacyPurpose;
    this.compliantDerivation = !opts.nonCompliantDerivation;

    let x = opts.seedData;

    switch (opts.seedType) {
      case 'new':
        if (opts.language && !wordsForLang[opts.language])
          throw new Error('Unsupported language');

        let m = new Mnemonic(wordsForLang[opts.language]);
        while (!Mnemonic.isValid(m.toString())) {
          m = new Mnemonic(wordsForLang[opts.language]);
        }
        this.setFromMnemonic(m, opts);
        break;
      case 'mnemonic':
        $.checkArgument(x, 'Need to provide opts.seedData');
        $.checkArgument(typeof x === 'string', 'sourceData need to be a string');
        this.setFromMnemonic(new Mnemonic(x), opts);
        break;
      case 'extendedPrivateKey':
        $.checkArgument(x, 'Need to provide opts.seedData');

        let xpriv;
        try {
          xpriv = new Bitcore.HDPrivateKey(x);
        } catch (e) {
          throw new Error('Invalid argument');
        }
        this.fingerPrint = xpriv.fingerPrint.toString('hex');

        if (opts.password) {
          this.#xPrivKeyEncrypted = sjcl.encrypt(
            opts.password,
            xpriv.toString(),
            opts
          );
          if (!this.#xPrivKeyEncrypted) throw new Error('Could not encrypt');
        } else {
          this.#xPrivKey = xpriv.toString();
        }
        this.#mnemonic = null;
        this.#mnemonicHasPassphrase = null;
        break;
      case 'object':
        $.shouldBeObject(x, 'Need to provide an object at opts.seedData');
        $.shouldBeUndefined(
          opts.password,
          'opts.password not allowed when source is object'
        );

        if (this.#version != x.version) {
          throw new Error('Bad Key version');
        }

        this.#xPrivKey = x.xPrivKey;
        this.#xPrivKeyEncrypted = x.xPrivKeyEncrypted;

        this.#mnemonic = x.mnemonic;
        this.#mnemonicEncrypted = x.mnemonicEncrypted;
        this.#mnemonicHasPassphrase = x.mnemonicHasPassphrase;
        this.#version = x.version;
        this.fingerPrint = x.fingerPrint;
        this.compliantDerivation = x.compliantDerivation;
        this.BIP45 = x.BIP45;
        this.id = x.id;
        this.use0forBCH = x.use0forBCH;
        this.use44forMultisig = x.use44forMultisig;

        $.checkState(
          this.#xPrivKey || this.#xPrivKeyEncrypted,
          'Failed state:  #xPrivKey || #xPrivKeyEncrypted at Key constructor'
        );
        break;

      case 'objectV1':
        // Default Values for V1
        this.use0forBCH = false;
        this.use44forMultisig = false;
        this.compliantDerivation = true;
        this.id = Uuid.v4();

        if (x.compliantDerivation != null)
          this.compliantDerivation = x.compliantDerivation;
        if (x.id != null) this.id = x.id;

        this.#xPrivKey = x.xPrivKey;
        this.#xPrivKeyEncrypted = x.xPrivKeyEncrypted;

        this.#mnemonic = x.mnemonic;
        this.#mnemonicEncrypted = x.mnemonicEncrypted;
        this.#mnemonicHasPassphrase = x.mnemonicHasPassphrase;
        this.#version = x.version || 1;
        this.fingerPrint = x.fingerPrint;

        // If the wallet was single seed... multisig walelts accounts
        // will be 48'
        this.use44forMultisig = x.n > 1 ? true : false;

        // if old credentials had use145forBCH...use it.
        // else,if the wallet is bch, set it to true.
        this.use0forBCH = x.use145forBCH
          ? false
          : x.coin == 'bch'
            ? true
            : false;

        this.BIP45 = x.derivationStrategy == 'BIP45';
        break;

      default:
        throw new Error('Unknown seed source: ' + opts.seedType);
    }
  }

  static match(a, b) {
    // fingerPrint is not always available (because xPriv could has
    // been imported encrypted)
    return a.id == b.id || a.fingerPrint == b.fingerPrint;
  }

  private setFromMnemonic(
    m,
    opts: { passphrase?: string; password?: string; sjclOpts?: any }
  ) {
    const xpriv = m.toHDPrivateKey(opts.passphrase, NETWORK);
    this.fingerPrint = xpriv.fingerPrint.toString('hex');

    if (opts.password) {
      this.#xPrivKeyEncrypted = sjcl.encrypt(
        opts.password,
        xpriv.toString(),
        opts.sjclOpts
      );
      if (!this.#xPrivKeyEncrypted) throw new Error('Could not encrypt');
      this.#mnemonicEncrypted = sjcl.encrypt(
        opts.password,
        m.phrase,
        opts.sjclOpts
      );
      if (!this.#mnemonicEncrypted) throw new Error('Could not encrypt');
    } else {
      this.#xPrivKey = xpriv.toString();
      this.#mnemonic = m.phrase;
      this.#mnemonicHasPassphrase = !!opts.passphrase;
    }
  }

  toObj() {
    const ret = {
      xPrivKey: this.#xPrivKey,
      xPrivKeyEncrypted: this.#xPrivKeyEncrypted,
      mnemonic: this.#mnemonic,
      mnemonicEncrypted: this.#mnemonicEncrypted,
      version: this.#version,
      mnemonicHasPassphrase: this.#mnemonicHasPassphrase,
      fingerPrint: this.fingerPrint, //  32bit fingerprint
      compliantDerivation: this.compliantDerivation,
      BIP45: this.BIP45,

      // data for derived credentials.
      use0forBCH: this.use0forBCH,
      use44forMultisig: this.use44forMultisig,
      id: this.id
    };
    return JSON.parse(JSON.stringify(ret));
  };

  isPrivKeyEncrypted() {
    return !!this.#xPrivKeyEncrypted && !this.#xPrivKey;
  };

  checkPassword(password) {
    if (this.isPrivKeyEncrypted()) {
      try {
        sjcl.decrypt(password, this.#xPrivKeyEncrypted);
      } catch (ex) {
        return false;
      }
      return true;
    }
    return null;
  };

  get(password) {
    let keys: any = {};
    let fingerPrintUpdated = false;

    if (this.isPrivKeyEncrypted()) {
      $.checkArgument(
        password,
        'Private keys are encrypted, a password is needed'
      );
      try {
        keys.xPrivKey = sjcl.decrypt(password, this.#xPrivKeyEncrypted);

        // update fingerPrint if not set.
        if (!this.fingerPrint) {
          let xpriv = new Bitcore.HDPrivateKey(keys.xPrivKey);
          this.fingerPrint = xpriv.fingerPrint.toString('hex');
          fingerPrintUpdated = true;
        }

        if (this.#mnemonicEncrypted) {
          keys.mnemonic = sjcl.decrypt(password, this.#mnemonicEncrypted);
        }
      } catch (ex) {
        throw new Error('Could not decrypt');
      }
    } else {
      keys.xPrivKey = this.#xPrivKey;
      keys.mnemonic = this.#mnemonic;
      if (fingerPrintUpdated) {
        keys.fingerPrintUpdated = true;
      }
    }
    keys.mnemonicHasPassphrase = this.#mnemonicHasPassphrase || false;
    return keys;
  };

  encrypt(password, opts) {
    if (this.#xPrivKeyEncrypted)
      throw new Error('Private key already encrypted');

    if (!this.#xPrivKey) throw new Error('No private key to encrypt');

    this.#xPrivKeyEncrypted = sjcl.encrypt(password, this.#xPrivKey, opts);
    if (!this.#xPrivKeyEncrypted) throw new Error('Could not encrypt');

    if (this.#mnemonic)
      this.#mnemonicEncrypted = sjcl.encrypt(password, this.#mnemonic, opts);

    this.#xPrivKey = null;
    this.#mnemonic = null;
  };

  decrypt(password) {
    if (!this.#xPrivKeyEncrypted)
      throw new Error('Private key is not encrypted');

    try {
      this.#xPrivKey = sjcl.decrypt(password, this.#xPrivKeyEncrypted);
      if (this.#mnemonicEncrypted) {
        this.#mnemonic = sjcl.decrypt(password, this.#mnemonicEncrypted);
      }
      this.#xPrivKeyEncrypted = null;
      this.#mnemonicEncrypted = null;
    } catch (ex) {
      log.error('error decrypting:', ex);
      throw new Error('Could not decrypt');
    }
  };

  derive(password, path): Bitcore.HDPrivateKey {
    $.checkArgument(path, 'no path at derive()');
    const xPrivKey = new Bitcore.HDPrivateKey(
      this.get(password).xPrivKey,
      NETWORK
    );
    const deriveFn = this.compliantDerivation
      ? xPrivKey.deriveChild.bind(xPrivKey)
      : xPrivKey.deriveNonCompliantChild.bind(xPrivKey);
    return deriveFn(path);
  };

  _checkChain(chain) {
    if (!Constants.CHAINS.includes(chain)) throw new Error('Invalid chain');
  };

  _checkNetwork(network) {
    if (!['livenet', 'testnet', 'regtest'].includes(network))
      throw new Error('Invalid network ' + network);
  };

  /*
   * This is only used on "create"
   * no need to include/support
   * BIP45
   */

  getBaseAddressDerivationPath(opts) {
    $.checkArgument(opts, 'Need to provide options');
    $.checkArgument(opts.n >= 1, 'n need to be >=1');

    const chain = opts.chain || Utils.getChain(opts.coin);
    let purpose = opts.n == 1 || this.use44forMultisig ? '44' : '48';
    let coinCode = '0';

    // checking in chains for simplicity
    if (
      ['testnet', 'regtest]'].includes(opts.network) &&
      Constants.UTXO_CHAINS.includes(chain)
    ) {
      coinCode = '1';
    } else if (chain == 'bch') {
      if (this.use0forBCH || opts.use0forBCH) {
        coinCode = '0';
      } else {
        coinCode = '145';
      }
    } else if (chain == 'btc') {
      coinCode = '0';
    } else if (chain == 'eth') {
      coinCode = '60';
    } else if (chain == 'matic') {
      coinCode = '60'; // the official matic derivation path is 966 but users will expect address to be same as ETH
    } else if (chain == 'arb') {
      coinCode = '60';
    } else if (chain == 'op') {
      coinCode = '60';
    } else if (chain == 'base') {
      coinCode = '60';
    } else if (chain == 'xrp') {
      coinCode = '144';
    } else if (chain == 'doge') {
      coinCode = '3';
    } else if (chain == 'xec') {
      coinCode = '899';
      if (opts.isSlpToken) {
        if (opts.isPath899) {
          coinCode = '899';
        } else {
          coinCode = '1899';
        }
        if (opts.isFromRaipay) {
          coinCode = '145';
        }
      }
    } else if (chain == 'xpi') {
      coinCode = '10605';
    } else if (chain == 'ltc') {
      coinCode = '2';
    } else {
      throw new Error('unknown chain: ' + chain);
    }

    return 'm/' + purpose + "'/" + coinCode + "'/" + opts.account + "'";
  };

  /*
   * opts.chain
   * opts.network
   * opts.account
   * opts.n
   */

  createCredentials(password, opts) {
    opts = opts || {};
    opts.chain = opts.chain || Utils.getChain(opts.coin);

    if (password) $.shouldBeString(password, 'provide password');

    this._checkNetwork(opts.network);
    $.shouldBeNumber(opts.account, 'Invalid account');
    $.shouldBeNumber(opts.n, 'Invalid n');

    $.shouldBeUndefined(opts.useLegacyCoinType);
    $.shouldBeUndefined(opts.useLegacyPurpose);

    let path = this.getBaseAddressDerivationPath(opts);
    let xPrivKey = this.derive(password, path);
    let requestPrivKey = this.derive(
      password,
      Constants.PATHS.REQUEST_KEY
    ).privateKey.toString();

    if (['testnet', 'regtest'].includes(opts.network)) {
      // Hacky: BTC/BCH xPriv depends on network: This code is to
      // convert a livenet xPriv to a testnet/regtest xPriv
      let x = xPrivKey.toObject();
      x.network = opts.network;
      delete x.xprivkey;
      delete x.checksum;
      x.privateKey = x.privateKey.padStart(64, '0');
      xPrivKey = new Bitcore.HDPrivateKey(x);
    }

    return Credentials.fromDerivedKey({
      xPubKey: xPrivKey.hdPublicKey.toString(),
      coin: opts.coin,
      chain: opts.chain?.toLowerCase() || Utils.getChain(opts.coin), // getChain -> backwards compatibility
      network: opts.network,
      account: opts.account,
      n: opts.n,
      rootPath: path,
      keyId: this.id,
      requestPrivKey,
      addressType: opts.addressType,
      walletPrivKey: opts.walletPrivKey,
      isSlpToken: !!opts.isSlpToken,
      isFromRaipay: !!opts.isFromRaipay,
      isPath899: !!opts.isPath899
    });
  };

  /**
   * @param {string} password
   * @param {Object} opts
   * @param {string} opts.path
   * @param {string|PrivateKey} [opts.requestPrivKey]
   */
  createAccess(password, opts) {
    opts = opts || {};
    $.shouldBeString(opts.path);

    var requestPrivKey = new Bitcore.PrivateKey(opts.requestPrivKey || null);
    var requestPubKey = requestPrivKey.toPublicKey().toString();

    var xPriv = this.derive(password, opts.path);
    var signature = Utils.signRequestPubKey(requestPubKey, xPriv);
    requestPrivKey = requestPrivKey.toString();

    return {
      signature,
      requestPrivKey
    };
  };

  sign(rootPath, txp, password, cb) {
    $.shouldBeString(rootPath);
    if (this.isPrivKeyEncrypted() && !password) {
      return cb(new Errors.ENCRYPTED_PRIVATE_KEY());
    }
    var privs = [];
    var derived: any = {};

    var derived = this.derive(password, rootPath);
    var xpriv = new Bitcore.HDPrivateKey(derived);

    var t = Utils.buildTx(txp);

    var chain = txp.chain?.toLowerCase() || Utils.getChain(txp.coin); // getChain -> backwards compatibility

    if (Constants.UTXO_CHAINS.includes(chain)) {
      for (const i of txp.inputs) {
        $.checkState(
          i.path,
          'Input derivation path not available (signing transaction)'
        );
        if (!derived[i.path]) {
          derived[i.path] = xpriv.deriveChild(i.path).privateKey;
          privs.push(derived[i.path]);
        }
      };

      var signatures = privs.map(function(priv, i) {
        return t.getSignatures(priv, undefined, txp.signingMethod);
      });

      signatures = signatures.flat().sort((a, b) => a.inputIndex - b.inputIndex);
      // DEBUG
      // for (let sig of signatures) {
      //   if (!t.isValidSignature(sig)) {
      //     throw new Error('INVALID SIGNATURE');
      //   }
      // }
      signatures = signatures.map(sig => sig.signature.toDER().toString('hex'));

      return signatures;
    } else {
      let tx = t.uncheckedSerialize();
      tx = typeof tx === 'string' ? [tx] : tx;
      const txArray = Array.isArray(tx) ? tx : [tx];
      const isChange = false;
      const addressIndex = 0;
      const { privKey, pubKey } = Deriver.derivePrivateKey(
        chain.toUpperCase(),
        txp.network,
        derived,
        addressIndex,
        isChange
      );
      let signatures = [];
      for (const rawTx of txArray) {
        const signed = Transactions.getSignature({
          chain: chain.toUpperCase(),
          tx: rawTx,
          key: { privKey, pubKey }
        });
        signatures.push(signed);
      }
      return signatures;
    }
  };
}
