/********************************************************************************
 *   Ledger Node JS API
 *   (c) 2017-2018 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 BIPPath from "bip32-path";
import {
  StellarHashSigningNotEnabledError,
  StellarDataParsingFailedError,
  StellarUserRefusedError,
  StellarDataTooLargeError,
} from "./errors";

const CLA = 0xe0;
const P1_FIRST = 0x00;
const P1_MORE = 0x80;
const P2_LAST = 0x00;
const P2_MORE = 0x80;
const P2_NON_CONFIRM = 0x00; // for getPublicKey
const P2_CONFIRM = 0x01; // for getPublicKey

const INS_GET_PK = 0x02;
const INS_SIGN_TX = 0x04;
const INS_GET_CONF = 0x06;
const INS_SIGN_HASH = 0x08;
const INS_SIGN_SOROBAN_AUTHORIZATION = 0x0a;
const INS_SIGN_MESSAGE = 0x0c;

const APDU_MAX_PAYLOAD = 255;

const SW_DENY = 0x6985;
const SW_HASH_SIGNING_MODE_NOT_ENABLED = 0x6c66;
const SW_DATA_TOO_LARGE = 0xb004;
const SW_DATA_PARSING_FAIL = 0xb005;

/**
 * Stellar API
 *
 * @param transport a transport for sending commands to a device
 * @param scrambleKey a scramble key
 *
 * @example
 * import Str from "@ledgerhq/hw-app-str";
 * const str = new Str(transport)
 */
export default class Str {
  private transport: Transport;

  constructor(transport: Transport, scrambleKey = "l0v") {
    this.transport = transport;
    transport.decorateAppAPIMethods(
      this,
      [
        "getAppConfiguration",
        "getPublicKey",
        "signTransaction",
        "signSorobanAuthorization",
        "signHash",
      ],
      scrambleKey,
    );
  }

  /**
   * Get Stellar application configuration.
   *
   * @returns an object with the application configuration, including the version,
   *    whether hash signing is enabled, and the maximum data size in bytes that the device can sign.
   * @example
   * str.getAppConfiguration().then(o => o.version)
   */
  async getAppConfiguration(): Promise<{
    version: string;
    hashSigningEnabled: boolean;
    maxDataSize?: number;
  }> {
    const resp = await this.sendToDevice(INS_GET_CONF, Buffer.alloc(0));
    const [hashSigningEnabled, major, minor, patch, maxDataSizeHi, maxDataSizeLo] = resp;
    return {
      hashSigningEnabled: hashSigningEnabled === 0x01,
      version: `${major}.${minor}.${patch}`,
      maxDataSize: resp.length > 4 ? (maxDataSizeHi << 8) | maxDataSizeLo : undefined, // For compatibility with older app, let's remove this in the future
    };
  }

  /**
   * Get Stellar raw public key for a given BIP 32 path.
   *
   * @param path a path in BIP 32 format
   * @param display if true, the device will ask the user to confirm the address on the device, if false, it will return the raw public key directly
   * @return an object with the raw ed25519 public key.
   *    If you want to convert it to string, you can use {@link https://stellar.github.io/js-stellar-base/StrKey.html#.encodeEd25519PublicKey StrKey.encodeEd25519PublicKey}
   * @example
   * str.getPublicKey("44'/148'/0'").then(o => o.rawPublicKey)
   */
  async getPublicKey(path: string, display = false): Promise<{ rawPublicKey: Buffer }> {
    const pathBuffer = pathToBuffer(path);
    const p2 = display ? P2_CONFIRM : P2_NON_CONFIRM;
    try {
      const data = await this.transport.send(CLA, INS_GET_PK, P1_FIRST, p2, pathBuffer);
      return { rawPublicKey: data.slice(0, -2) };
    } catch (e) {
      throw remapErrors(e);
    }
  }

  /**
   * Sign a Stellar transaction.
   *
   * @param path a path in BIP 32 format
   * @param transaction {@link https://stellar.github.io/js-stellar-base/Transaction.html#signatureBase signature base} of the transaction to sign
   * @return an object with the signature
   * @example
   * str.signTransaction("44'/148'/0'", signatureBase).then(o => o.signature)
   */
  async signTransaction(
    path: string,
    transaction: Buffer,
  ): Promise<{
    signature: Buffer;
  }> {
    const pathBuffer = pathToBuffer(path);
    const payload = Buffer.concat([pathBuffer, transaction]);
    const resp = await this.sendToDevice(INS_SIGN_TX, payload);
    return { signature: resp };
  }

  /**
   * Sign a Stellar Soroban authorization.
   *
   * @param path a path in BIP 32 format
   * @param hashIdPreimage the {@link https://github.com/stellar/stellar-xdr/blob/1a04392432dacc0092caaeae22a600ea1af3c6a5/Stellar-transaction.x#L702-L709 Soroban authorization hashIdPreimage} to sign
   * @return an object with the signature
   * @example
   * str.signSorobanAuthorization("44'/148'/0'", hashIdPreimage).then(o => o.signature)
   */
  async signSorobanAuthorization(
    path: string,
    hashIdPreimage: Buffer,
  ): Promise<{
    signature: Buffer;
  }> {
    const pathBuffer = pathToBuffer(path);
    const payload = Buffer.concat([pathBuffer, hashIdPreimage]);
    const resp = await this.sendToDevice(INS_SIGN_SOROBAN_AUTHORIZATION, payload);
    return { signature: resp };
  }

  /**
   * Sign a hash.
   *
   * @param path a path in BIP 32 format
   * @param hash the hash to sign
   * @return an object with the signature
   * @example
   * str.signHash("44'/148'/0'", hash).then(o => o.signature)
   */
  async signHash(
    path: string,
    hash: Buffer,
  ): Promise<{
    signature: Buffer;
  }> {
    const pathBuffer = pathToBuffer(path);
    const payload = Buffer.concat([pathBuffer, hash]);
    const resp = await this.sendToDevice(INS_SIGN_HASH, payload);
    return { signature: resp };
  }

  /**
   * Sign a message.
   *
   * @param path a path in BIP 32 format
   * @param message the message to sign
   * @return an object with the signature
   * @example
   * str.signMessage("44'/148'/0'", message).then(o => o.signature)
   */
  async signMessage(
    path: string,
    message: Buffer,
  ): Promise<{
    signature: Buffer;
  }> {
    const pathBuffer = pathToBuffer(path);
    const payload = Buffer.concat([pathBuffer, message]);
    const resp = await this.sendToDevice(INS_SIGN_MESSAGE, payload);
    return { signature: resp };
  }

  private async sendToDevice(instruction: number, payload: Buffer) {
    let response: Buffer = Buffer.alloc(0);
    let remaining = payload.length;
    // eslint-disable-next-line no-constant-condition
    while (true) {
      const chunkSize = remaining > APDU_MAX_PAYLOAD ? APDU_MAX_PAYLOAD : remaining;
      const p1 = remaining === payload.length ? P1_FIRST : P1_MORE;
      const p2 = remaining - chunkSize === 0 ? P2_LAST : P2_MORE;
      const chunk = payload.slice(
        payload.length - remaining,
        payload.length - remaining + chunkSize,
      );
      response = await this.transport.send(CLA, instruction, p1, p2, chunk).catch(e => {
        throw remapErrors(e);
      });
      remaining -= chunkSize;
      if (remaining === 0) {
        break;
      }
    }
    return response.slice(0, -2);
  }
}

const remapErrors = e => {
  if (e) {
    switch (e.statusCode) {
      case SW_DENY:
        return new StellarUserRefusedError("User refused the request", undefined, { cause: e });
      case SW_DATA_PARSING_FAIL:
        return new StellarDataParsingFailedError("Unable to parse the provided data", undefined, {
          cause: e,
        });
      case SW_HASH_SIGNING_MODE_NOT_ENABLED:
        return new StellarHashSigningNotEnabledError(
          "Hash signing not allowed. Have you enabled it in the app settings?",
          undefined,
          { cause: e },
        );
      case SW_DATA_TOO_LARGE:
        return new StellarDataTooLargeError(
          "The provided data is too large for the device to process",
          undefined,
          { cause: e },
        );
    }
  }
  return e;
};

const pathToBuffer = (originalPath: string) => {
  const path = originalPath
    .split("/")
    .map(value => (value.endsWith("'") || value.endsWith("h") ? value : `${value}'`))
    .join("/");
  const pathNums: number[] = BIPPath.fromString(path).toPathArray();
  return serializePath(pathNums);
};

const serializePath = (path: number[]) => {
  const buf = Buffer.alloc(1 + path.length * 4);
  buf.writeUInt8(path.length, 0);
  for (const [i, num] of path.entries()) {
    buf.writeUInt32BE(num, 1 + i * 4);
  }
  return buf;
};

export * from "./errors";
