import type { HDPublicKeyBuffers, HDPublicKeyDto, HDPublicKeyMinimalDto, IHDPrivateKey, IHDPublicKey } from "../common/interfaces";
import HDKeyUtils from "./hdkey.utils";
import { networks } from "../core/network/network-manager";
import BufferUtils from "../utils/buffer.utils";
import type { Network } from "../core/network/network";
import ValidationUtils from "../utils/validation.utils";
import { InvalidB58Checksum, InvalidDerivationArgument, InvalidHardenedIndex, InvalidPath } from "./exceptions";
import Base58Check from "../encoding/base58check";
import PublicKey from "./publickey";
import Hash from "../crypto/hash";
import BN from "../crypto/bn.extension";
import Point from "../crypto/point";
import { AddressType } from "../core/address/address-formatter";
import Address from "../core/address/address";

export default class HDPublicKey implements IHDPublicKey {

  private static readonly PublicKeySize = 33;
  private static readonly PublicKeyStart = HDKeyUtils.ChainCodeEnd;
  private static readonly PublicKeyEnd = this.PublicKeyStart + this.PublicKeySize;
  private static readonly ChecksumStart = this.PublicKeyEnd;
  private static readonly ChecksumEnd = this.ChecksumStart + HDKeyUtils.CheckSumSize;

  readonly publicKey!: PublicKey;
  readonly network!: Network;
  readonly depth!: number;
  readonly parentFingerPrint!: Uint8Array;
  readonly fingerPrint!: Uint8Array;
  readonly chainCode!: Uint8Array;
  readonly childIndex!: number;
  readonly checksum!: Uint8Array;
  readonly xpubkey!: string;

  /**
   * The representation of an hierarchically derived public key.
   *
   * See https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki
   *
   * @param arg
   */
  constructor(arg: string | Uint8Array | HDPublicKeyDto | HDPublicKeyMinimalDto | IHDPublicKey | IHDPrivateKey) {
    if (arg == null) {
      throw new TypeError("Must supply an argument to create a HDPublicKey");
    }
    if (arg instanceof HDPublicKey) {
      return arg;
    }

    let params = HDPublicKey._classifyArgument(arg);

    this.publicKey = params.publicKey as PublicKey;
    this.network = params.network;
    this.depth = params.depth;
    this.parentFingerPrint = params.parentFingerPrint;
    this.fingerPrint = params.fingerPrint;
    this.chainCode = params.chainCode;
    this.childIndex = params.childIndex;
    this.xpubkey = params.xpubkey;
    this.checksum = params.checksum;
  }

  private static _classifyArgument(arg: string | Uint8Array | HDPublicKeyDto | HDPublicKeyMinimalDto | IHDPublicKey | IHDPrivateKey): IHDPublicKey {
    if (typeof arg === 'string' || BufferUtils.isBuffer(arg)) {
      let xpubkey = typeof arg === 'string' ? arg : BufferUtils.bufferToUtf8(arg);
      if (HDKeyUtils.isValidSerialized(xpubkey)) {
        return this._buildFromSerialized(xpubkey);
      }
      throw HDKeyUtils.getSerializedError(arg);
    }

    if (this._isIHDPubKey(arg)) {
      return arg;
    }
    if (this._isMinimalDtoObject(arg)) {
      return this._buildFromMinimalObject(arg);
    }
    if (this._isDtoObject(arg)) {
      return this._buildFromObject(arg);
    }
    if (this._isIHDPrivKey(arg)) {
      return this._buildFromHDPrivateKey(arg);
    }

    throw new TypeError('Invalid argument type for creation, must be string, json, buffer, or object');
  }

  private static _isDtoObject(data: unknown): data is HDPublicKeyDto {
    return typeof data === 'object' && data !== null && 'xpubkey' in data && !('privateKey' in data) && 'publicKey' in data && typeof data.publicKey === 'string';
  }

  private static _isMinimalDtoObject(data: unknown): data is HDPublicKeyMinimalDto {
    return typeof data === 'object' && data !== null && !('xpubkey' in data) && !('privateKey' in data) && 'publicKey' in data && typeof data.publicKey === 'string';
  }

  private static _isIHDPubKey(data: unknown): data is IHDPublicKey {
    return  typeof data === 'object' && data !== null && 'xpubkey' in data && !('privateKey' in data) && 'publicKey' in data && data.publicKey instanceof PublicKey;
  }

  private static _isIHDPrivKey(data: unknown): data is IHDPrivateKey {
    return  typeof data === 'object' && data !== null && 'privateKey' in data && typeof data.privateKey === 'object' && data.privateKey !== null && 'bn' in data.privateKey;
  }
  
  /**
   * Verifies that a given path is valid.
   *
   * @param arg
   * @return {boolean}
   */
  public static isValidPath(arg: string | number): boolean {
    if (typeof arg === 'string') {
      let indexes = HDKeyUtils.getDerivationIndexes(arg);
      return indexes !== null && indexes.every(HDPublicKey.isValidPath);
    }

    if (typeof arg === 'number') {
      return arg >= 0 && arg < HDKeyUtils.Hardened;
    }

    return false;
  }

  /**
   * Create a HDPublicKey from a buffer argument
   *
   * @param buf
   */
  public static fromBuffer(buf: Uint8Array): HDPublicKey {
    ValidationUtils.validateArgument(BufferUtils.isBuffer(buf), 'No valid Buffer was provided');
    return new HDPublicKey(buf);
  }

  /**
   * Return a buffer representation of the xpubkey
   */
  public toBuffer(): Uint8Array {
    return BufferUtils.utf8ToBuffer(this.xpubkey);
  }

  public static fromString(xpubkey: string): HDPublicKey {
    ValidationUtils.validateArgument(typeof xpubkey === 'string', 'No valid string was provided');
    return new HDPublicKey(xpubkey);
  }
  
  /**
   * Returns the base58 checked representation of the public key
   * @return a string starting with "xpub..." in livenet
   */
  public toString(): string {
    return this.xpubkey;
  }

  /**
   * Returns the console representation of this extended public key.
   */
  public inspect(): string {
    return `<HDPublicKey: ${this}>`;
  }

  public static fromObject(arg: HDPublicKeyDto): HDPublicKey {
    ValidationUtils.validateArgument(typeof arg === 'object' && arg !== null, 'No valid argument was provided');
    return new HDPublicKey(arg);
  }

  public static fromMinimalObject(arg: HDPublicKeyMinimalDto): HDPublicKey {
    ValidationUtils.validateArgument(typeof arg === 'object' && arg !== null, 'No valid argument was provided');
    let params = this._buildFromMinimalObject(arg);
    return new HDPublicKey(params);
  }

  public toJSON = this.toObject;

  /**
   * Returns a plain JavaScript object with information to reconstruct a key.
   */
  public toObject(): HDPublicKeyDto {
    return {
      network: this.network.name,
      depth: this.depth,
      fingerPrint: BufferUtils.integerFromBuffer(this.fingerPrint),
      parentFingerPrint: BufferUtils.integerFromBuffer(this.parentFingerPrint),
      childIndex: this.childIndex,
      chainCode: BufferUtils.bufferToHex(this.chainCode),
      publicKey: this.publicKey.toString(),
      checksum: BufferUtils.integerFromBuffer(this.checksum),
      xpubkey: this.xpubkey
    };
  }

  /**
   * Will return an address for the hdpubkey with its defined network
   * 
   * @param type - optional parameter specifying the desired type of the address.
   *  default {@link AddressType.PayToScriptTemplate}
   * 
   * @returns An address generated from the hd public key
   */
  public toAddress(type = AddressType.PayToScriptTemplate): Address {
    return Address.fromPublicKey(this.publicKey, this.network, type);
  }

  /**
   * Get a derivated child based on a string or number.
   *
   * If the first argument is a string, it's parsed as the full path of
   * derivation. Valid values for this argument include "m" (which returns the
   * same public key), "m/0/1/40/2/1000".
   *
   * Note that hardened keys can't be derived from a public extended key.
   *
   * If the first argument is a number, the child with that index will be
   * derived. See the example usage for clarification.
   *
   * @example
   * ```javascript
   * let parent = new HDPublicKey('xpub...');
   * let child_0_1_2 = parent.deriveChild(0).deriveChild(1).deriveChild(2);
   * let copy_of_child_0_1_2 = parent.deriveChild("m/0/1/2");
   * assert(child_0_1_2.xpubkey === copy_of_child_0_1_2.xpubkey);
   * ```
   *
   * @param {string|number} arg
   */
  public deriveChild(arg: string | number, hardened?: boolean): HDPublicKey {
    if (typeof arg === 'number') {
      return this._deriveWithNumber(arg, hardened);
    } 
    if (typeof arg === 'string') {
      return this._deriveFromString(arg);
    } 
    throw new InvalidDerivationArgument(arg);
  }

  private _deriveWithNumber(index: number, hardened?: boolean): HDPublicKey {
    if (index >= HDKeyUtils.Hardened || hardened) {
      throw new InvalidHardenedIndex();
    }
    if (index < 0) {
      throw new InvalidPath(index);
    }
  
    let indexBuffer = BufferUtils.integerAsBuffer(index);
    let data = BufferUtils.concat([this.publicKey.toBuffer(), indexBuffer]);
    let hash = Hash.sha512hmac(data, this.chainCode);
    let leftPart = BN.fromBuffer(hash.subarray(0, 32), {size: 32});
    let chainCode = hash.subarray(32, 64);
  
    let publicKey: PublicKey;
    try {
      publicKey = PublicKey.fromPoint(Point.getG().mul(leftPart).add(this.publicKey.point));
    } catch {
      return this._deriveWithNumber(index + 1);
    }

    let buffers: HDPublicKeyBuffers = {
      version: BufferUtils.integerAsBuffer(this.network.xpubkey),
      depth: BufferUtils.integerAsSingleByteBuffer(this.depth + 1),
      parentFingerPrint: this.fingerPrint,
      childIndex: BufferUtils.integerAsBuffer(index),
      publicKey: publicKey.toBuffer(),
      chainCode: chainCode
    };
    let derived = HDPublicKey._buildFromBuffers(buffers);
  
    return new HDPublicKey(derived);
  }

  private _deriveFromString(path: string): HDPublicKey {
    if (path.includes("'")) {
      throw new InvalidHardenedIndex();
    }
    if (!HDPublicKey.isValidPath(path)) {
      throw new InvalidPath(path);
    }

    const deriveKeys = (baseKey: HDPublicKey, indexes: number[]): HDPublicKey => {
      return indexes.reduce((prev, idx) => prev._deriveWithNumber(idx), baseKey);
    }
  
    let indexes = HDKeyUtils.getDerivationIndexes(path);
    return deriveKeys(this, indexes!);
  }

  private static _buildFromObject(arg: HDPublicKeyDto): IHDPublicKey {
    return {
      network: networks.get(arg.network)!,
      depth: arg.depth,
      fingerPrint: BufferUtils.integerAsBuffer(arg.fingerPrint),
      parentFingerPrint: BufferUtils.integerAsBuffer(arg.parentFingerPrint),
      childIndex: arg.childIndex,
      chainCode: BufferUtils.hexToBuffer(arg.chainCode),
      xpubkey: arg.xpubkey,
      checksum: BufferUtils.integerAsBuffer(arg.checksum),
      publicKey: PublicKey.fromString(arg.publicKey, networks.get(arg.network)!)
    }
  }

  private static _buildFromMinimalObject(arg: HDPublicKeyMinimalDto): IHDPublicKey {
    let buffers = {
      version: BufferUtils.integerAsBuffer(networks.get(arg.network)!.xpubkey),
      depth: BufferUtils.integerAsSingleByteBuffer(arg.depth),
      parentFingerPrint: BufferUtils.integerAsBuffer(arg.parentFingerPrint),
      childIndex: BufferUtils.integerAsBuffer(arg.childIndex),
      chainCode: BufferUtils.hexToBuffer(arg.chainCode),
      publicKey: BufferUtils.hexToBuffer(arg.publicKey),
    };
    return this._buildFromBuffers(buffers);
  }

  private static _buildFromHDPrivateKey(hdPrivateKey: IHDPrivateKey): IHDPublicKey {
    let point = Point.getG().mul(hdPrivateKey.privateKey.bn);
    let buffers: HDPublicKeyBuffers = {
      version: BufferUtils.integerAsBuffer(hdPrivateKey.network.xpubkey),
      depth: BufferUtils.integerAsSingleByteBuffer(hdPrivateKey.depth),
      parentFingerPrint: hdPrivateKey.parentFingerPrint,
      childIndex: BufferUtils.integerAsBuffer(hdPrivateKey.childIndex),
      chainCode: hdPrivateKey.chainCode,
      publicKey: Point.pointToCompressed(point),
    };

    return this._buildFromBuffers(buffers);
  }

  private static _buildFromSerialized(xpubkey: string): IHDPublicKey {
    let decoded = Base58Check.decode(xpubkey);
    let buffers: HDPublicKeyBuffers = {
      version: decoded.subarray(HDKeyUtils.VersionStart, HDKeyUtils.VersionEnd),
      depth: decoded.subarray(HDKeyUtils.DepthStart, HDKeyUtils.DepthEnd),
      parentFingerPrint: decoded.subarray(HDKeyUtils.ParentFingerPrintStart, HDKeyUtils.ParentFingerPrintEnd),
      childIndex: decoded.subarray(HDKeyUtils.ChildIndexStart, HDKeyUtils.ChildIndexEnd),
      chainCode: decoded.subarray(HDKeyUtils.ChainCodeStart, HDKeyUtils.ChainCodeEnd),
      publicKey: decoded.subarray(this.PublicKeyStart, this.PublicKeyEnd),
      checksum: decoded.subarray(this.ChecksumStart, this.ChecksumEnd),
    };

    return this._buildFromBuffers(buffers);
  }
  
  /**
   * Receives a object with buffers in all the properties and populates the
   * internal structure
   *
   * @param arg
   */
  private static _buildFromBuffers(arg: HDPublicKeyBuffers): IHDPublicKey {
    HDPublicKey._validateBufferArguments(arg);

    let sequence = [ arg.version, arg.depth, arg.parentFingerPrint, arg.childIndex, arg.chainCode, arg.publicKey ];
    let concat = BufferUtils.concat(sequence);
    let checksum = Base58Check.checksum(concat);

    if (!arg.checksum || !arg.checksum.length) {
      arg.checksum = checksum;
    } else if (BufferUtils.bufferToHex(arg.checksum) !== BufferUtils.bufferToHex(checksum)) {
      throw new InvalidB58Checksum(concat);
    }

    let network = networks.get(BufferUtils.integerFromBuffer(arg.version))!;
    let publicKey = PublicKey.fromBuffer(arg.publicKey, true, network);

    return {
      xpubkey: Base58Check.encode(concat),
      network: network,
      depth: BufferUtils.integerFromSingleByteBuffer(arg.depth),
      publicKey: publicKey,
      fingerPrint: Hash.sha256ripemd160(publicKey.toBuffer()).subarray(0, HDKeyUtils.ParentFingerPrintSize),
      chainCode: arg.chainCode,
      childIndex: BufferUtils.integerFromBuffer(arg.childIndex),
      parentFingerPrint: arg.parentFingerPrint,
      checksum: arg.checksum
    };
  }

  private static _validateBufferArguments(arg: HDPublicKeyBuffers): void {
    const checkBuffer = (name: string, size: number): void => {
      let buff = arg[name as keyof HDPublicKeyBuffers];
      ValidationUtils.validateArgument(BufferUtils.isBuffer(buff), name + ` argument is not a buffer, it\'s ${typeof buff}`);
      ValidationUtils.validateArgument(buff!.length === size, name + ' has not the expected size: found ' + buff!.length + ', expected ' + size);
    };
    
    checkBuffer('version', HDKeyUtils.VersionSize);
    checkBuffer('depth', HDKeyUtils.DepthSize);
    checkBuffer('parentFingerPrint', HDKeyUtils.ParentFingerPrintSize);
    checkBuffer('childIndex', HDKeyUtils.ChildIndexSize);
    checkBuffer('chainCode', HDKeyUtils.ChainCodeSize);
    checkBuffer('publicKey', HDPublicKey.PublicKeySize);
    if (arg.checksum?.length) {
      checkBuffer('checksum', HDKeyUtils.CheckSumSize);
    }
  }
}