/*
 * Copyright Node.js contributors. All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to
 * deal in the Software without restriction, including without limitation the
 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
 * sell copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
 * IN THE SOFTWARE.
 */
'use strict';
const Buffer = require('buffer').Buffer;
const crypto = require('crypto');
const { EventEmitter } = require('events');
const http = require('http');
const URL = require('url');
const util = require('util');

const debuglog = util.debuglog('inspect');

const kOpCodeText = 0x1;
const kOpCodeClose = 0x8;

const kFinalBit = 0x80;
const kReserved1Bit = 0x40;
const kReserved2Bit = 0x20;
const kReserved3Bit = 0x10;
const kOpCodeMask = 0xF;
const kMaskBit = 0x80;
const kPayloadLengthMask = 0x7F;

const kMaxSingleBytePayloadLength = 125;
const kMaxTwoBytePayloadLength = 0xFFFF;
const kTwoBytePayloadLengthField = 126;
const kEightBytePayloadLengthField = 127;
const kMaskingKeyWidthInBytes = 4;

function isEmpty(obj) {
  return Object.keys(obj).length === 0;
}

function unpackError({ code, message, data }) {
  const err = new Error(`${message} - ${data}`);
  err.code = code;
  Error.captureStackTrace(err, unpackError);
  return err;
}

function encodeFrameHybi17(payload) {
  var i;

  const dataLength = payload.length;

  let singleByteLength;
  let additionalLength;
  if (dataLength > kMaxTwoBytePayloadLength) {
    singleByteLength = kEightBytePayloadLengthField;
    additionalLength = Buffer.alloc(8);
    let remaining = dataLength;
    for (i = 0; i < 8; ++i) {
      additionalLength[7 - i] = remaining & 0xFF;
      remaining >>= 8;
    }
  } else if (dataLength > kMaxSingleBytePayloadLength) {
    singleByteLength = kTwoBytePayloadLengthField;
    additionalLength = Buffer.alloc(2);
    additionalLength[0] = (dataLength & 0xFF00) >> 8;
    additionalLength[1] = dataLength & 0xFF;
  } else {
    additionalLength = Buffer.alloc(0);
    singleByteLength = dataLength;
  }

  const header = Buffer.from([
    kFinalBit | kOpCodeText,
    kMaskBit | singleByteLength,
  ]);

  const mask = Buffer.alloc(4);
  const masked = Buffer.alloc(dataLength);
  for (i = 0; i < dataLength; ++i) {
    masked[i] = payload[i] ^ mask[i % kMaskingKeyWidthInBytes];
  }

  return Buffer.concat([header, additionalLength, mask, masked]);
}

function decodeFrameHybi17(data) {
  const dataAvailable = data.length;
  const notComplete = { closed: false, payload: null, rest: data };
  let payloadOffset = 2;
  if ((dataAvailable - payloadOffset) < 0) return notComplete;

  const firstByte = data[0];
  const secondByte = data[1];

  const final = (firstByte & kFinalBit) !== 0;
  const reserved1 = (firstByte & kReserved1Bit) !== 0;
  const reserved2 = (firstByte & kReserved2Bit) !== 0;
  const reserved3 = (firstByte & kReserved3Bit) !== 0;
  const opCode = firstByte & kOpCodeMask;
  const masked = (secondByte & kMaskBit) !== 0;
  const compressed = reserved1;
  if (compressed) {
    throw new Error('Compressed frames not supported');
  }
  if (!final || reserved2 || reserved3) {
    throw new Error('Only compression extension is supported');
  }

  if (masked) {
    throw new Error('Masked server frame - not supported');
  }

  let closed = false;
  switch (opCode) {
    case kOpCodeClose:
      closed = true;
      break;
    case kOpCodeText:
      break;
    default:
      throw new Error(`Unsupported op code ${opCode}`);
  }

  let payloadLength = secondByte & kPayloadLengthMask;
  switch (payloadLength) {
    case kTwoBytePayloadLengthField:
      payloadOffset += 2;
      payloadLength = (data[2] << 8) + data[3];
      break;

    case kEightBytePayloadLengthField:
      payloadOffset += 8;
      payloadLength = 0;
      for (var i = 0; i < 8; ++i) {
        payloadLength <<= 8;
        payloadLength |= data[2 + i];
      }
      break;

    default:
      // Nothing. We already have the right size.
  }
  if ((dataAvailable - payloadOffset - payloadLength) < 0) return notComplete;

  const payloadEnd = payloadOffset + payloadLength;
  return {
    payload: data.slice(payloadOffset, payloadEnd),
    rest: data.slice(payloadEnd),
    closed,
  };
}

class Client extends EventEmitter {
  constructor(port, host) {
    super();
    this.handleChunk = this._handleChunk.bind(this);

    this._port = port;
    this._host = host;

    this.reset();
  }

  _handleChunk(chunk) {
    this._unprocessed = Buffer.concat([this._unprocessed, chunk]);

    while (this._unprocessed.length > 2) {
      const {
        closed,
        payload: payloadBuffer,
        rest
      } = decodeFrameHybi17(this._unprocessed);
      this._unprocessed = rest;

      if (closed) {
        this.reset();
        return;
      }
      if (payloadBuffer === null) break;

      const payloadStr = payloadBuffer.toString();
      debuglog('< %s', payloadStr);
      const lastChar = payloadStr[payloadStr.length - 1];
      if (payloadStr[0] !== '{' || lastChar !== '}') {
        throw new Error(`Payload does not look like JSON: ${payloadStr}`);
      }
      let payload;
      try {
        payload = JSON.parse(payloadStr);
      } catch (parseError) {
        parseError.string = payloadStr;
        throw parseError;
      }

      const { id, method, params, result, error } = payload;
      if (id) {
        const handler = this._pending[id];
        if (handler) {
          delete this._pending[id];
          handler(error, result);
        }
      } else if (method) {
        this.emit('debugEvent', method, params);
        this.emit(method, params);
      } else {
        throw new Error(`Unsupported response: ${payloadStr}`);
      }
    }
  }

  reset() {
    if (this._http) {
      this._http.destroy();
    }
    this._http = null;
    this._lastId = 0;
    this._socket = null;
    this._pending = {};
    this._unprocessed = Buffer.alloc(0);
  }

  callMethod(method, params) {
    return new Promise((resolve, reject) => {
      if (!this._socket) {
        reject(new Error('Use `run` to start the app again.'));
        return;
      }
      const data = { id: ++this._lastId, method, params };
      this._pending[data.id] = (error, result) => {
        if (error) reject(unpackError(error));
        else resolve(isEmpty(result) ? undefined : result);
      };
      const json = JSON.stringify(data);
      debuglog('> %s', json);
      this._socket.write(encodeFrameHybi17(Buffer.from(json)));
    });
  }

  _fetchJSON(urlPath) {
    return new Promise((resolve, reject) => {
      const httpReq = http.get({
        host: this._host,
        port: this._port,
        path: urlPath,
      });

      const chunks = [];

      function onResponse(httpRes) {
        function parseChunks() {
          const resBody = Buffer.concat(chunks).toString();
          if (httpRes.statusCode !== 200) {
            reject(new Error(`Unexpected ${httpRes.statusCode}: ${resBody}`));
            return;
          }
          try {
            resolve(JSON.parse(resBody));
          } catch (parseError) {
            reject(new Error(`Response didn't contain JSON: ${resBody}`));
            return;
          }
        }

        httpRes.on('error', reject);
        httpRes.on('data', (chunk) => chunks.push(chunk));
        httpRes.on('end', parseChunks);
      }

      httpReq.on('error', reject);
      httpReq.on('response', onResponse);
    });
  }

  connect() {
    return this._discoverWebsocketPath()
      .then((urlPath) => this._connectWebsocket(urlPath));
  }

  _discoverWebsocketPath() {
    return this._fetchJSON('/json')
      .then(([{ webSocketDebuggerUrl }]) =>
        URL.parse(webSocketDebuggerUrl).path);
  }

  _connectWebsocket(urlPath) {
    this.reset();

    const key1 = crypto.randomBytes(16).toString('base64');
    debuglog('request websocket', key1);

    const httpReq = this._http = http.request({
      host: this._host,
      port: this._port,
      path: urlPath,
      headers: {
        Connection: 'Upgrade',
        Upgrade: 'websocket',
        'Sec-WebSocket-Key': key1,
        'Sec-WebSocket-Version': '13',
      },
    });
    httpReq.on('error', (e) => {
      this.emit('error', e);
    });
    httpReq.on('response', (httpRes) => {
      if (httpRes.statusCode >= 400) {
        process.stderr.write(`Unexpected HTTP code: ${httpRes.statusCode}\n`);
        httpRes.pipe(process.stderr);
      } else {
        httpRes.pipe(process.stderr);
      }
    });

    const handshakeListener = (res, socket) => {
      // TODO: we *could* validate res.headers[sec-websocket-accept]
      debuglog('websocket upgrade');

      this._socket = socket;
      socket.on('data', this.handleChunk);
      socket.on('close', () => {
        this.emit('close');
      });

      Promise.all([
        this.callMethod('Runtime.enable'),
        this.callMethod('Debugger.enable'),
        this.callMethod('Debugger.setPauseOnExceptions', { state: 'none' }),
        this.callMethod('Debugger.setAsyncCallStackDepth', { maxDepth: 0 }),
        this.callMethod('Profiler.enable'),
        this.callMethod('Profiler.setSamplingInterval', { interval: 100 }),
        this.callMethod('Debugger.setBlackboxPatterns', { patterns: [] }),
        this.callMethod('Runtime.runIfWaitingForDebugger'),
      ]).then(() => {
        this.emit('ready');
      }, (error) => {
        this.emit('error', error);
      });
    };

    return new Promise((resolve, reject) => {
      this.once('error', reject);
      this.once('ready', resolve);

      httpReq.on('upgrade', handshakeListener);
      httpReq.end();
    });
  }
}

module.exports = Client;
