import { default as insecureNodeFetch, Response } from 'node-fetch';
import https from 'https';

import { Snode } from './snodePool';
import ByteBuffer from 'bytebuffer';
import { OnionPaths } from '../onions';
import { fromBase64ToArrayBuffer, toHex } from '../utils/String';

export enum RequestError {
  BAD_PATH = 'BAD_PATH',
  OTHER = 'OTHER',
  ABORTED = 'ABORTED',
}

/**
 * When sending a request over onion, we might get two status.
 * The first one, on the request itself, the other one in the json returned.
 *
 * If the request failed to reach the one of the node of the onion path, the one on the request is set.
 * But if the request reaches the destination node and it fails to process the request (bad node for this pubkey), you will get a 200 on the request itself, but the json you get will contain the real status.
 */
export interface SnodeResponse {
  body: string;
  status: number;
}

// Returns the actual ciphertext, symmetric key that will be used
// for decryption, and an ephemeral_key to send to the next hop
async function encryptForPubKey(pubKeyX25519hex: string, reqObj: any): Promise<DestinationContext> {
  const reqStr = JSON.stringify(reqObj);

  const textEncoder = new TextEncoder();
  const plaintext = textEncoder.encode(reqStr);

  return window.libloki.crypto.encryptForPubkey(pubKeyX25519hex, plaintext);
}

export type DestinationRelayV2 = {
  host?: string;
  protocol?: string;
  port?: number;
  destination?: string;
  method?: string;
  target?: string;
};

// `ctx` holds info used by `node` to relay further
async function encryptForRelayV2(
  relayX25519hex: string,
  destination: DestinationRelayV2,
  ctx: DestinationContext
) {
  const { log } = window;

  if (!destination.host && !destination.destination) {
    log.warn('loki_rpc::encryptForRelayV2 - no destination', destination);
  }

  const reqObj = {
    ...destination,
    ephemeral_key: toHex(ctx.ephemeralKey),
  };

  const plaintext = encodeCiphertextPlusJson(ctx.ciphertext, reqObj);

  return window.libloki.crypto.encryptForPubkey(relayX25519hex, plaintext);
}

/// Encode ciphertext as (len || binary) and append payloadJson as utf8
function encodeCiphertextPlusJson(
  ciphertext: Uint8Array,
  payloadJson: Record<string, any>
): Uint8Array {
  const payloadStr = JSON.stringify(payloadJson);

  const bufferJson = ByteBuffer.wrap(payloadStr, 'utf8');

  const len = ciphertext.length;
  const arrayLen = bufferJson.buffer.length + 4 + len;
  const littleEndian = true;
  const buffer = new ByteBuffer(arrayLen, littleEndian);

  buffer.writeInt32(len);
  buffer.append(ciphertext);
  buffer.append(bufferJson);

  return new Uint8Array(buffer.buffer);
}

async function buildOnionCtxs(
  nodePath: Array<Snode>,
  destCtx: DestinationContext,
  targetED25519Hex?: string,
  finalRelayOptions?: FinalRelayOptions,
  id = ''
) {
  const { log } = window;

  const ctxes = [destCtx];
  // from (3) 2 to 0
  const firstPos = nodePath.length - 1;

  for (let i = firstPos; i > -1; i -= 1) {
    let dest: DestinationRelayV2;
    const relayingToFinalDestination = i === firstPos; // if last position

    if (relayingToFinalDestination && finalRelayOptions) {
      let target = '/loki/v2/lsrpc';

      const isCallToPn = finalRelayOptions?.host === 'live.apns.getsession.org';
      if (!isCallToPn && window.lokiFeatureFlags.useFileOnionRequestsV2) {
        target = '/loki/v3/lsrpc';
      }

      dest = {
        host: finalRelayOptions.host,
        target,
        method: 'POST',
      };
      // FIXME http open groups v2 are not working
      // tslint:disable-next-line: no-http-string
      if (finalRelayOptions?.protocol === 'http') {
        dest.protocol = finalRelayOptions.protocol;
        dest.port = finalRelayOptions.port || 80;
      }
    } else {
      // set x25519 if destination snode
      let pubkeyHex = targetED25519Hex; // relayingToFinalDestination
      // or ed25519 snode destination
      if (!relayingToFinalDestination) {
        pubkeyHex = nodePath[i + 1].pubkey_ed25519;
        if (!pubkeyHex) {
          log.error(
            `loki_rpc:::buildOnionGuardNodePayload ${id} - no ed25519 for`,
            nodePath[i + 1],
            'path node',
            i + 1
          );
        }
      }
      // destination takes a hex key
      dest = {
        destination: pubkeyHex,
      };
    }
    try {
      // eslint-disable-next-line no-await-in-loop
      const ctx = await encryptForRelayV2(nodePath[i].pubkey_x25519, dest, ctxes[ctxes.length - 1]);
      ctxes.push(ctx);
    } catch (e) {
      log.error(
        `loki_rpc:::buildOnionGuardNodePayload ${id} - encryptForRelayV2 failure`,
        e.code,
        e.message
      );
      throw e;
    }
  }

  return ctxes;
}

// we just need the targetNode.pubkey_ed25519 for the encryption
// targetPubKey is ed25519 if snode is the target
async function buildOnionGuardNodePayload(
  nodePath: Array<Snode>,
  destCtx: DestinationContext,
  targetED25519Hex?: string,
  finalRelayOptions?: FinalRelayOptions,
  id = ''
) {
  const ctxes = await buildOnionCtxs(nodePath, destCtx, targetED25519Hex, finalRelayOptions, id);

  // this is the OUTER side of the onion, the one encoded with multiple layer
  // So the one we will send to the first guard node.
  const guardCtx = ctxes[ctxes.length - 1]; // last ctx

  // New "semi-binary" encoding

  const guardPayloadObj = {
    ephemeral_key: toHex(guardCtx.ephemeralKey),
  };

  return encodeCiphertextPlusJson(guardCtx.ciphertext, guardPayloadObj);
}

// Process a response as it arrives from `fetch`, handling
// http errors and attempting to decrypt the body with `sharedKey`
// May return false BAD_PATH, indicating that we should try a new path.
const processOnionResponse = async (
  reqIdx: number,
  response: Response,
  symmetricKey: ArrayBuffer,
  debug: boolean,
  abortSignal?: AbortSignal
): Promise<SnodeResponse | RequestError> => {
  const { log, libloki } = window;

  if (abortSignal?.aborted) {
    log.warn(`(${reqIdx}) [path] Call aborted`);
    return RequestError.ABORTED;
  }

  // FIXME: 401/500 handling?

  // detect SNode is deregisted?
  if (response.status === 502) {
    log.warn(`(${reqIdx}) [path] Got 502: snode not found`);

    return RequestError.BAD_PATH;
  }

  // detect SNode is not ready (not in swarm; not done syncing)
  if (response.status === 503) {
    log.warn(`(${reqIdx}) [path] Got 503: snode not ready`);

    return RequestError.BAD_PATH;
  }

  if (response.status === 504) {
    log.warn(`(${reqIdx}) [path] Got 504: Gateway timeout`);
    return RequestError.BAD_PATH;
  }

  if (response.status === 404) {
    // Why would we get this error on testnet?
    log.warn(`(${reqIdx}) [path] Got 404: Gateway timeout`);
    return RequestError.BAD_PATH;
  }

  if (response.status !== 200) {
    const rsp = await response.text();

    log.warn(
      `(${reqIdx}) [path] lokiRpc::processOnionResponse - fetch unhandled error code: ${response.status}: ${rsp}`
    );
    // FIXME audric
    // this is pretty strong but on the current setup.
    // we have to increase a snode invididually and only mark later the path as bad
    // the way it works on mobile is that we treat a node as bad in that case, and if it then reaches a failure count of 3 or so we kick it out and rebuild the path
    return RequestError.BAD_PATH;
  }

  let ciphertext = await response.text();
  if (!ciphertext) {
    log.warn(
      `(${reqIdx}) [path] lokiRpc::processOnionResponse - Target node return empty ciphertext`
    );
    return RequestError.OTHER;
  }
  if (debug) {
    log.debug(`(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertext`, ciphertext);
  }

  let plaintext;
  let ciphertextBuffer;

  try {
    const jsonRes = JSON.parse(ciphertext);
    ciphertext = jsonRes.result;
  } catch (e) {
    // just try to get a json object from what is inside (for PN requests), if it fails, continue ()
  }
  try {
    ciphertextBuffer = fromBase64ToArrayBuffer(ciphertext);

    if (debug) {
      log.debug(
        `(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertextBuffer`,
        toHex(ciphertextBuffer)
      );
    }

    const plaintextBuffer = await libloki.crypto.DecryptAESGCM(symmetricKey, ciphertextBuffer);
    if (debug) {
      log.debug('lokiRpc::processOnionResponse - plaintextBuffer', plaintextBuffer.toString());
    }

    plaintext = new TextDecoder().decode(plaintextBuffer);
  } catch (e) {
    log.error(`(${reqIdx}) [path] lokiRpc::processOnionResponse - decode error`, e);
    log.error(
      `(${reqIdx}) [path] lokiRpc::processOnionResponse - symmetricKey`,
      toHex(symmetricKey)
    );
    if (ciphertextBuffer) {
      log.error(
        `(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertextBuffer`,
        toHex(ciphertextBuffer)
      );
    }
    return RequestError.OTHER;
  }

  if (debug) {
    log.debug('lokiRpc::processOnionResponse - plaintext', plaintext);
  }

  try {
    const jsonRes: SnodeResponse = JSON.parse(plaintext, (key, value) => {
      if (typeof value === 'number' && value > Number.MAX_SAFE_INTEGER) {
        window.log.warn('Received an out of bounds js number');
      }
      return value;
    });

    return jsonRes;
  } catch (e) {
    log.error(
      `(${reqIdx}) [path] lokiRpc::processOnionResponse - parse error outer json ${e.code} ${e.message} json: '${plaintext}'`
    );
    return RequestError.OTHER;
  }
};

export const snodeHttpsAgent = new https.Agent({
  rejectUnauthorized: false,
});

export type FinalRelayOptions = {
  host: string;
  protocol?: 'http' | 'https'; // default to https
  port?: number; // default to 443
};

export type DestinationContext = {
  ciphertext: Uint8Array;
  symmetricKey: ArrayBuffer;
  ephemeralKey: ArrayBuffer;
};

export type FinalDestinationOptions = {
  destination_ed25519_hex?: string;
  headers?: Record<string, string>;
  body?: string;
};

/**
 *
 * Onion request looks like this
 * Sender -> 1 -> 2 -> 3 -> Receiver
 * 1, 2, 3 = onion Snodes
 *
 *
 * @param nodePath the onion path to use to send the request
 * @param finalDestOptions those are the options for the request from 3 to R. It contains for instance the payload and headers.
 * @param finalRelayOptions  those are the options 3 will use to make a request to R. It contains for instance the host to make the request to
 */
const sendOnionRequest = async (
  reqIdx: number,
  nodePath: Array<Snode>,
  destX25519Any: string,
  finalDestOptions: {
    destination_ed25519_hex?: string;
    headers?: Record<string, string>;
    body?: string;
  },
  finalRelayOptions?: FinalRelayOptions,
  lsrpcIdx?: number,
  abortSignal?: AbortSignal
): Promise<SnodeResponse | RequestError> => {
  const { log } = window;

  let id = '';
  if (lsrpcIdx !== undefined) {
    id += `${lsrpcIdx}=>`;
  }
  if (reqIdx !== undefined) {
    id += `${reqIdx}`;
  }

  // get destination pubkey in array buffer format
  let destX25519hex = destX25519Any;
  if (typeof destX25519hex !== 'string') {
    // convert AB to hex
    window.log.warn('destX25519hex was not a string');
    destX25519hex = toHex(destX25519Any as any);
  }

  // safely build destination
  let targetEd25519hex;

  if (finalDestOptions.destination_ed25519_hex) {
    // snode destination
    targetEd25519hex = finalDestOptions.destination_ed25519_hex;
    // eslint-disable-next-line no-param-reassign
    delete finalDestOptions.destination_ed25519_hex;
  }

  const options = finalDestOptions; // lint
  // do we need this?
  options.headers = options.headers || {};

  const isLsrpc = !!finalRelayOptions;

  let destCtx: DestinationContext;
  try {
    if (!isLsrpc) {
      const body = options.body || '';
      delete options.body;

      const textEncoder = new TextEncoder();
      const bodyEncoded = textEncoder.encode(body);

      const plaintext = encodeCiphertextPlusJson(bodyEncoded, options);
      destCtx = await window.libloki.crypto.encryptForPubkey(destX25519hex, plaintext);
    } else {
      destCtx = await encryptForPubKey(destX25519hex, options);
    }
  } catch (e) {
    log.error(
      `loki_rpc::sendOnionRequest ${id} - encryptForPubKey failure [`,
      e.code,
      e.message,
      '] destination X25519',
      destX25519hex.substr(0, 32),
      '...',
      destX25519hex.substr(32),
      'options',
      options
    );
    throw e;
  }

  const payload = await buildOnionGuardNodePayload(
    nodePath,
    destCtx,
    targetEd25519hex,
    finalRelayOptions,
    id
  );

  const guardFetchOptions = {
    method: 'POST',
    body: payload,
    // we are talking to a snode...
    agent: snodeHttpsAgent,
    abortSignal,
  };

  const target = '/onion_req/v2';

  const guardUrl = `https://${nodePath[0].ip}:${nodePath[0].port}${target}`;
  // no logs for that one as we do need to call insecureNodeFetch to our guardNode
  // window.log.info('insecureNodeFetch => plaintext for sendOnionRequest');

  const response = await insecureNodeFetch(guardUrl, guardFetchOptions);
  return processOnionResponse(reqIdx, response, destCtx.symmetricKey, false, abortSignal);
};

async function sendOnionRequestSnodeDest(
  reqIdx: any,
  nodePath: Array<Snode>,
  targetNode: Snode,
  plaintext: any
) {
  return sendOnionRequest(
    reqIdx,
    nodePath,
    targetNode.pubkey_x25519,
    {
      destination_ed25519_hex: targetNode.pubkey_ed25519,
      body: plaintext,
    },
    undefined,
    undefined
  );
}

// need relay node's pubkey_x25519_hex
export async function sendOnionRequestLsrpcDest(
  reqIdx: number,
  nodePath: Array<Snode>,
  destX25519Any: string,
  finalRelayOptions: FinalRelayOptions,
  payloadObj: FinalDestinationOptions,
  lsrpcIdx: number,
  abortSignal?: AbortSignal
): Promise<SnodeResponse | RequestError> {
  return sendOnionRequest(
    reqIdx,
    nodePath,
    destX25519Any,
    payloadObj,
    finalRelayOptions,
    lsrpcIdx,
    abortSignal
  );
}

function getPathString(pathObjArr: Array<any>): string {
  return pathObjArr.map(node => `${node.ip}:${node.port}`).join(', ');
}

export async function lokiOnionFetch(body: any, targetNode: Snode): Promise<SnodeResponse | false> {
  const { log } = window;

  // Loop until the result is not BAD_PATH
  // tslint:disable-next-line no-constant-condition
  while (true) {
    // Get a path excluding `targetNode`:
    // eslint-disable-next-line no-await-in-loop
    const path = await OnionPaths.getInstance().getOnionPath(targetNode);
    const thisIdx = OnionPaths.getInstance().assignOnionRequestNumber();

    // At this point I only care about BAD_PATH

    // eslint-disable-next-line no-await-in-loop
    const result = await sendOnionRequestSnodeDest(thisIdx, path, targetNode, body);

    if (result === RequestError.BAD_PATH) {
      log.error(
        `[path] Error on the path: ${getPathString(path)} to ${targetNode.ip}:${targetNode.port}`
      );
      OnionPaths.getInstance().markPathAsBad(path);
      return false;
    } else if (result === RequestError.OTHER) {
      // could mean, fail to parse results
      // or status code wasn't 200
      // or can't decrypt
      // it's not a bad_path, so we don't need to mark the path as bad
      log.error(
        `[path] sendOnionRequest gave false for path: ${getPathString(path)} to ${targetNode.ip}:${
          targetNode.port
        }`
      );
      return false;
    } else if (result === RequestError.ABORTED) {
      // could mean, fail to parse results
      // or status code wasn't 200
      // or can't decrypt
      // it's not a bad_path, so we don't need to mark the path as bad
      log.error(
        `[path] sendOnionRequest gave aborted for path: ${getPathString(path)} to ${
          targetNode.ip
        }:${targetNode.port}`
      );
      return false;
    } else {
      return result;
    }
  }
}
