'use strict';

const {
  ArrayPrototypePush,
  ErrorCaptureStackTrace,
  FunctionPrototypeBind,
  JSONParse,
  JSONStringify,
  ObjectKeys,
  Promise,
} = primordials;

const Buffer = require('buffer').Buffer;
const crypto = require('crypto');
const { ERR_DEBUGGER_ERROR } = require('internal/errors').codes;
const { EventEmitter } = require('events');
const http = require('http');
const { URL } = require('internal/url');

const debuglog = require('internal/util/debuglog').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;

// This guid is defined in the Websocket Protocol RFC
// https://tools.ietf.org/html/rfc6455#section-1.3
const WEBSOCKET_HANDSHAKE_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

function unpackError({ code, message }) {
  const err = new ERR_DEBUGGER_ERROR(`${message}`);
  err.code = code;
  ErrorCaptureStackTrace(err, unpackError);
  return err;
}

function validateHandshake(requestKey, responseKey) {
  const expectedResponseKeyBase = requestKey + WEBSOCKET_HANDSHAKE_GUID;
  const shasum = crypto.createHash('sha1');
  shasum.update(expectedResponseKeyBase);
  const shabuf = shasum.digest();

  if (shabuf.toString('base64') !== responseKey) {
    throw new ERR_DEBUGGER_ERROR(
      `WebSocket secret mismatch: ${requestKey} did not match ${responseKey}`,
    );
  }
}

function encodeFrameHybi17(payload) {
  const dataLength = payload.length;

  let singleByteLength;
  let additionalLength;
  if (dataLength > kMaxTwoBytePayloadLength) {
    singleByteLength = kEightBytePayloadLengthField;
    additionalLength = Buffer.alloc(8);
    let remaining = dataLength;
    for (let 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 (let 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 ERR_DEBUGGER_ERROR('Compressed frames not supported');
  }
  if (!final || reserved2 || reserved3) {
    throw new ERR_DEBUGGER_ERROR('Only compression extension is supported');
  }

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

  let closed = false;
  switch (opCode) {
    case kOpCodeClose:
      closed = true;
      break;
    case kOpCodeText:
      break;
    default:
      throw new ERR_DEBUGGER_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 (let 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() {
    super();
    this.handleChunk = FunctionPrototypeBind(this._handleChunk, this);

    this._port = undefined;
    this._host = undefined;

    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 || payloadBuffer.length === 0) break;

      const payloadStr = payloadBuffer.toString();
      debuglog('< %s', payloadStr);
      const lastChar = payloadStr[payloadStr.length - 1];
      if (payloadStr[0] !== '{' || lastChar !== '}') {
        throw new ERR_DEBUGGER_ERROR(`Payload does not look like JSON: ${payloadStr}`);
      }
      let payload;
      try {
        payload = JSONParse(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 ERR_DEBUGGER_ERROR(`Unsupported response: ${payloadStr}`);
      }
    }
  }

  reset() {
    if (this._http) {
      this._http.destroy();
    }
    if (this._socket) {
      this._socket.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 ERR_DEBUGGER_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(ObjectKeys(result).length ? result : undefined);
      };
      const json = JSONStringify(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 ERR_DEBUGGER_ERROR(`Unexpected ${httpRes.statusCode}: ${resBody}`));
            return;
          }
          try {
            resolve(JSONParse(resBody));
          } catch {
            reject(new ERR_DEBUGGER_ERROR(`Response didn't contain JSON: ${resBody}`));

          }
        }

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

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

  async connect(port, host) {
    this._port = port;
    this._host = host;
    const urlPath = await this._discoverWebsocketPath();
    return this._connectWebsocket(urlPath);
  }

  async _discoverWebsocketPath() {
    const { 0: { webSocketDebuggerUrl } } = await this._fetchJSON('/json');
    const { pathname, search } = new URL(webSocketDebuggerUrl);
    return `${pathname}${search}`;
  }

  _connectWebsocket(urlPath) {
    this.reset();

    const requestKey = crypto.randomBytes(16).toString('base64');
    debuglog('request WebSocket', requestKey);

    const httpReq = this._http = http.request({
      host: this._host,
      port: this._port,
      path: urlPath,
      headers: {
        'Connection': 'Upgrade',
        'Upgrade': 'websocket',
        'Sec-WebSocket-Key': requestKey,
        '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) => {
      validateHandshake(requestKey, res.headers['sec-websocket-accept']);
      debuglog('websocket upgrade');

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

      this.emit('ready');
    };

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

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

module.exports = Client;
