/********************************************************************************
 *   Ledger Node JS API
 *   (c) 2016-2017 Ledger
 *
 *  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 type Transport from "@ledgerhq/hw-transport";
import sha256 from "fast-sha256";

export type GetPublicKeyResult = {
  publicKey: Uint8Array;
  address: Uint8Array | null;
};
export type SignTransactionResult = {
  signature: Uint8Array;
};
export type GetVersionResult = {
  major: number;
  minor: number;
  patch: number;
};

/**
 * Common API for ledger apps
 *
 * @example
 * import Kadena from "hw-app-kda";
 * const kda = new Kadena(transport)
 */

export class Common {
  transport: Transport;
  appName: string | null;
  verbose: boolean | null;

  constructor(transport: Transport, scrambleKey: string, appName: string | null = null, verbosity: boolean | null = null) {
    this.transport = transport;
    this.appName = appName;
    this.verbose = verbosity === true;
    transport.decorateAppAPIMethods(
      this,
      ["menu", "getPublicKey", "signTransaction", "getVersion"],
      scrambleKey
    );
  }

  /**
    * Retrieves the public key associated with a particular BIP32 path from the ledger app.
    *
    * @param path - the path to retrieve.
    */
  async getPublicKey(
    path: string,
  ): Promise<GetPublicKeyResult> {
    const cla = 0x00;
    const ins = 0x02;
    const p1 = 0;
    const p2 = 0;
    const payload = buildBip32KeyPayload(path);
    const response = await this.sendChunks(cla, ins, p1, p2, payload);
    const keySize = response[0];
    const publicKey = response.slice(1, keySize+1); // slice uses end index.
    let address : Uint8Array | null = null;
    if (response.length > keySize+2) {
      const addressSize = response[keySize+1];
      address = response.slice(keySize+2, keySize+2+addressSize);
    }
    const res: GetPublicKeyResult = {
      publicKey: publicKey,
      address: address,
    };
    return res;
  }

  /**
    * Sign a transaction with the key at a BIP32 path.
    *
    * @param txn - The transaction; this can be any of a node Buffer, Uint8Array, or a hexadecimal string, encoding the form of the transaction appropriate for hashing and signing.
    * @param path - the path to use when signing the transaction.
    */
  async signTransaction(
    path: string,
    txn: string | Buffer | Uint8Array,
  ): Promise<SignTransactionResult> {
    const paths = splitPath(path);
    const cla = 0x00;
    const ins = 0x03;
    const p1 = 0;
    const p2 = 0;
    // Transaction payload is the byte length as uint32le followed by the bytes
    // Type guard not actually required but TypeScript can't tell that.
    if(this.verbose) this.log(txn);
    const rawTxn = typeof txn == "string" ? Buffer.from(txn, "hex") : Buffer.from(txn);
    const hashSize = Buffer.alloc(4);
    hashSize.writeUInt32LE(rawTxn.length, 0);
    // Bip32key payload same as getPublicKey
    const bip32KeyPayload = buildBip32KeyPayload(path);
    // These are just squashed together
    const payload_txn = Buffer.concat([hashSize, rawTxn]);
    this.log("Payload Txn", payload_txn);
    // TODO batch this since the payload length can be uint32le.max long
    const signature = await this.sendChunks(cla, ins, p1, p2, [payload_txn, bip32KeyPayload]);
    return {
      signature,
    };
  }

  /**
    * Retrieve the app version on the attached ledger device.
    * @alpha TODO this doesn't exist yet
    */

  async getVersion(): Promise<GetVersionResult> {
    const [major, minor, patch, ...appName] = await this.sendChunks(
      0x00,
      0x00,
      0x00,
      0x00,
      Buffer.alloc(1)
    );
    return {
      major,
      minor,
      patch
    };
  }
  
  /**
   * Send a raw payload as chunks to a particular APDU instruction.
   *
   * @remarks
   *
   * This is intended to be used to implement a more useful API in this class and subclasses of it, not for end use.
   */
  async sendChunks(
    cla: number,
    ins: number,
    p1: number,
    p2: number,
    payload: Buffer | Buffer[]
  ): Promise<Buffer> {
    let rv = Buffer.alloc(0);
    let chunkSize=230;
    if( payload instanceof Array ){
      payload = Buffer.concat(payload);
    }
    for(let i=0;i<payload.length;i+=chunkSize) {
      rv = await this.transport.send(cla, ins, p1, p2, payload.slice(i, i+chunkSize));
    }
    // Remove the status code here instead of in signTransaction, because sendWithBlocks _has_ to handle it.
    return rv.slice(0,-2);
  }

  /**
   * Convert a raw payload into what is essentially a singly-linked list of chunks, which
   allows the ledger to re-seek the data in a secure fashion.
  */
  async sendWithBlocks(
    cla: number,
    ins: number,
    p1: number,
    p2: number,
    payload: Buffer | Buffer[],
    // Constant (protocol dependent) data that the ledger may want to refer to
    // besides the payload.
    extraData: Map<String, Buffer> = new Map<String, Buffer>()
  ): Promise<Buffer> {
    let rv;
    let chunkSize=180;
    if(!( payload instanceof Array)) {
      payload = [payload];
    }
    let parameterList : Buffer[] = [];
    let data = new Map<String, Buffer>(extraData);
    for(let j=0; j<payload.length; j++) {
      let chunkList : Buffer[] = [];
      for(let i=0; i<payload[j].length; i+=chunkSize) {
        let cur = payload[j].slice(i, i+chunkSize);
        chunkList.push(cur);
      }
      // Store the hash that points to the "rest of the list of chunks"
      let lastHash = Buffer.alloc(32);
      this.log(lastHash);
      // Since we are doing a foldr, we process the last chunk first
      // We have to do it this way, because a block knows the hash of
      // the next block.
      data = chunkList.reduceRight((blocks, chunk) => {
        let linkedChunk = Buffer.concat([lastHash, chunk]);
        this.log("Chunk: ", chunk);
        this.log("linkedChunk: ", linkedChunk);
        lastHash = Buffer.from(sha256(linkedChunk));
        blocks.set(lastHash.toString('hex'), linkedChunk);
        return blocks;
      }, data);
      parameterList.push(lastHash);
      lastHash = Buffer.alloc(32);
    }
    this.log(data);
    return await this.handleBlocksProtocol(cla, ins, p1, p2, Buffer.concat([Buffer.from([HostToLedger.START])].concat(parameterList)), data);
  }

  async handleBlocksProtocol(
    cla: number,
    ins: number,
    p1: number,
    p2: number,
    initialPayload: Buffer,
    data: Map<String, Buffer>
  ): Promise<Buffer> {
    let payload = initialPayload;
    let result = Buffer.alloc(0);
    do {
      this.log("Sending payload to ledger: ", payload.toString('hex'));
      let rv = await this.transport.send(cla, ins, p1, p2, payload);
      this.log("Received response: ", rv);
      var rv_instruction = rv[0];
      let rv_payload = rv.slice(1,rv.length-2); // Last two bytes are a return code.
      if ( ! (rv_instruction in LedgerToHost) ) {
        throw new TypeError("Unknown instruction returned from ledger");
      }
      switch(rv_instruction) {
        case LedgerToHost.RESULT_ACCUMULATING:
        case LedgerToHost.RESULT_FINAL:
          result = Buffer.concat([result, rv_payload]);
          // Won't actually send this if we drop out of the loop for RESULT_FINAL
          payload = Buffer.from([HostToLedger.RESULT_ACCUMULATING_RESPONSE]);
          break;
        case LedgerToHost.GET_CHUNK:
          let chunk = data.get(rv_payload.toString('hex'));
          this.log("Getting block ", rv_payload);
          this.log("Found block ", chunk);
          if( chunk ) {
            payload = Buffer.concat([Buffer.from([HostToLedger.GET_CHUNK_RESPONSE_SUCCESS]), chunk]);
          } else {
            payload = Buffer.from([HostToLedger.GET_CHUNK_RESPONSE_FAILURE]);
          }
          break;
        case LedgerToHost.PUT_CHUNK:
          data.set(Buffer.from(sha256(rv_payload)).toString('hex'), rv_payload);
          payload = Buffer.from([HostToLedger.PUT_CHUNK_RESPONSE]);
          break;
      }
    } while (rv_instruction != 1);
    return result;
  }

  log(...args: any[]) {
    if(this.verbose) console.log(args);
  }
}

enum LedgerToHost {
  RESULT_ACCUMULATING = 0,
    RESULT_FINAL = 1,
    GET_CHUNK = 2,
    PUT_CHUNK = 3
};

enum HostToLedger {
  START = 0,
    GET_CHUNK_RESPONSE_SUCCESS = 1,
    GET_CHUNK_RESPONSE_FAILURE = 2,
    PUT_CHUNK_RESPONSE = 3,
    RESULT_ACCUMULATING_RESPONSE = 4
};

export function buildBip32KeyPayload(path: string): Buffer {
  const paths = splitPath(path);
  // Bip32Key payload is:
  // 1 byte with number of elements in u32 array path
  // Followed by the u32 array itself
  const payload = Buffer.alloc(1 + paths.length * 4);
  payload[0] = paths.length
  paths.forEach((element, index) => {
    payload.writeUInt32LE(element, 1 + 4 * index);
  });
  return payload
}

// TODO use bip32-path library
export function splitPath(path: string): number[] {
  const result: number[] = [];
  const components = path.split("/");
  components.forEach((element) => {
    let number = parseInt(element, 10);

    if (isNaN(number)) {
      return; // FIXME shouldn't it throws instead?
    }

    if (element.length > 1 && element[element.length - 1] === "'") {
      number += 0x80000000;
    }

    result.push(number);
  });
  return result;
}
