'use strict';
const common = require('../common');
const assert = require('assert');
const fs = require('fs');
const http = require('http');
const path = require('path');
const spawn = require('child_process').spawn;
const url = require('url');

const DEBUG = false;

const TIMEOUT = 15 * 1000;

const mainScript = path.join(common.fixturesDir, 'loop.js');

function send(socket, message, id, callback) {
  const msg = JSON.parse(JSON.stringify(message)); // Clone!
  msg['id'] = id;
  if (DEBUG)
    console.log('[sent]', JSON.stringify(msg));
  const messageBuf = Buffer.from(JSON.stringify(msg));

  const wsHeaderBuf = Buffer.allocUnsafe(16);
  wsHeaderBuf.writeUInt8(0x81, 0);
  let byte2 = 0x80;
  const bodyLen = messageBuf.length;
  let maskOffset = 2;
  if (bodyLen < 126) {
    byte2 = 0x80 + bodyLen;
  } else if (bodyLen < 65536) {
    byte2 = 0xFE;
    wsHeaderBuf.writeUInt16BE(bodyLen, 2);
    maskOffset = 4;
  } else {
    byte2 = 0xFF;
    wsHeaderBuf.writeUInt32BE(bodyLen, 2);
    wsHeaderBuf.writeUInt32BE(0, 6);
    maskOffset = 10;
  }
  wsHeaderBuf.writeUInt8(byte2, 1);
  wsHeaderBuf.writeUInt32BE(0x01020408, maskOffset);

  for (let i = 0; i < messageBuf.length; i++)
    messageBuf[i] = messageBuf[i] ^ (1 << (i % 4));
  socket.write(
      Buffer.concat([wsHeaderBuf.slice(0, maskOffset + 4), messageBuf]),
      callback);
}

function parseWSFrame(buffer, handler) {
  if (buffer.length < 2)
    return 0;
  assert.strictEqual(0x81, buffer[0]);
  let dataLen = 0x7F & buffer[1];
  let bodyOffset = 2;
  if (buffer.length < bodyOffset + dataLen)
    return 0;
  if (dataLen === 126) {
    dataLen = buffer.readUInt16BE(2);
    bodyOffset = 4;
  } else if (dataLen === 127) {
    dataLen = buffer.readUInt32BE(2);
    bodyOffset = 10;
  }
  if (buffer.length < bodyOffset + dataLen)
    return 0;
  const message = JSON.parse(
      buffer.slice(bodyOffset, bodyOffset + dataLen).toString('utf8'));
  if (DEBUG)
    console.log('[received]', JSON.stringify(message));
  handler(message);
  return bodyOffset + dataLen;
}

function tearDown(child, err) {
  child.kill();
  if (err instanceof Error) {
    console.error(err.stack);
    process.exit(1);
  }
}

function checkHttpResponse(port, path, callback) {
  http.get({port, path}, function(res) {
    let response = '';
    res.setEncoding('utf8');
    res
      .on('data', (data) => response += data.toString())
      .on('end', () => {
        let err = null;
        let json = undefined;
        try {
          json = JSON.parse(response);
        } catch (e) {
          err = e;
          err.response = response;
        }
        callback(err, json);
      });
  });
}

function makeBufferingDataCallback(dataCallback) {
  let buffer = Buffer.alloc(0);
  return (data) => {
    const newData = Buffer.concat([buffer, data]);
    const str = newData.toString('utf8');
    const lines = str.split('\n');
    if (str.endsWith('\n'))
      buffer = Buffer.alloc(0);
    else
      buffer = Buffer.from(lines.pop(), 'utf8');
    for (var line of lines)
      dataCallback(line);
  };
}

function timeout(message, multiplicator) {
  return setTimeout(() => common.fail(message), TIMEOUT * (multiplicator || 1));
}

const TestSession = function(socket, harness) {
  this.mainScriptPath = harness.mainScriptPath;
  this.mainScriptId = null;

  this.harness_ = harness;
  this.socket_ = socket;
  this.expectClose_ = false;
  this.scripts_ = {};
  this.messagefilter_ = null;
  this.responseCheckers_ = {};
  this.lastId_ = 0;
  this.messages_ = {};
  this.expectedId_ = 1;
  this.lastMessageResponseCallback_ = null;

  let buffer = Buffer.alloc(0);
  socket.on('data', (data) => {
    buffer = Buffer.concat([buffer, data]);
    let consumed;
    do {
      consumed = parseWSFrame(buffer, this.processMessage_.bind(this));
      if (consumed)
        buffer = buffer.slice(consumed);
    } while (consumed);
  }).on('close', () => assert(this.expectClose_, 'Socket closed prematurely'));
};

TestSession.prototype.scriptUrlForId = function(id) {
  return this.scripts_[id];
};

TestSession.prototype.processMessage_ = function(message) {
  const method = message['method'];
  if (method === 'Debugger.scriptParsed') {
    const script = message['params'];
    const scriptId = script['scriptId'];
    const url = script['url'];
    this.scripts_[scriptId] = url;
    if (url === mainScript)
      this.mainScriptId = scriptId;
  }
  this.messagefilter_ && this.messagefilter_(message);
  const id = message['id'];
  if (id) {
    assert.strictEqual(id, this.expectedId_);
    this.expectedId_++;
    if (this.responseCheckers_[id]) {
      assert(message['result'], JSON.stringify(message) + ' (response to ' +
             JSON.stringify(this.messages_[id]) + ')');
      this.responseCheckers_[id](message['result']);
      delete this.responseCheckers_[id];
    }
    assert(!message['error'], JSON.stringify(message) + ' (replying to ' +
           JSON.stringify(this.messages_[id]) + ')');
    delete this.messages_[id];
    if (id === this.lastId_) {
      this.lastMessageResponseCallback_ && this.lastMessageResponseCallback_();
      this.lastMessageResponseCallback_ = null;
    }
  }
};

TestSession.prototype.sendAll_ = function(commands, callback) {
  if (!commands.length) {
    callback();
  } else {
    this.lastId_++;
    let command = commands[0];
    if (command instanceof Array) {
      this.responseCheckers_[this.lastId_] = command[1];
      command = command[0];
    }
    if (command instanceof Function)
      command = command();
    this.messages_[this.lastId_] = command;
    send(this.socket_, command, this.lastId_,
         () => this.sendAll_(commands.slice(1), callback));
  }
};

TestSession.prototype.sendInspectorCommands = function(commands) {
  if (!(commands instanceof Array))
    commands = [commands];
  return this.enqueue((callback) => {
    let timeoutId = null;
    this.lastMessageResponseCallback_ = () => {
      timeoutId && clearTimeout(timeoutId);
      callback();
    };
    this.sendAll_(commands, () => {
      timeoutId = setTimeout(() => {
        let s = '';
        for (const id in this.messages_) {
          s += id + ', ';
        }
        common.fail('Messages without response: ' +
                    s.substring(0, s.length - 2));
      }, TIMEOUT);
    });
  });
};

TestSession.prototype.createCallbackWithTimeout_ = function(message) {
  var promise = new Promise((resolve) => {
    this.enqueue((callback) => {
      const timeoutId = timeout(message);
      resolve(() => {
        clearTimeout(timeoutId);
        callback();
      });
    });
  });
  return () => promise.then((callback) => callback());
};

TestSession.prototype.expectMessages = function(expects) {
  if (!(expects instanceof Array)) expects = [ expects ];

  const callback = this.createCallbackWithTimeout_(
      'Matching response was not received:\n' + expects[0]);
  this.messagefilter_ = (message) => {
    if (expects[0](message))
      expects.shift();
    if (!expects.length) {
      this.messagefilter_ = null;
      callback();
    }
  };
  return this;
};

TestSession.prototype.expectStderrOutput = function(regexp) {
  this.harness_.addStderrFilter(
      regexp,
      this.createCallbackWithTimeout_('Timed out waiting for ' + regexp));
  return this;
};

TestSession.prototype.runNext_ = function() {
  if (this.task_) {
    setImmediate(() => {
      this.task_(() => {
        this.task_ = this.task_.next_;
        this.runNext_();
      });
    });
  }
};

TestSession.prototype.enqueue = function(task) {
  if (!this.task_) {
    this.task_ = task;
    this.runNext_();
  } else {
    let t = this.task_;
    while (t.next_)
      t = t.next_;
    t.next_ = task;
  }
  return this;
};

TestSession.prototype.disconnect = function(childDone) {
  return this.enqueue((callback) => {
    this.expectClose_ = true;
    this.harness_.childInstanceDone =
        this.harness_.childInstanceDone || childDone;
    this.socket_.destroy();
    console.log('[test]', 'Connection terminated');
    callback();
  });
};

TestSession.prototype.testHttpResponse = function(path, check) {
  return this.enqueue((callback) =>
      checkHttpResponse(this.harness_.port, path, (err, response) => {
        check.call(this, err, response);
        callback();
      }));
};


const Harness = function(port, childProcess) {
  this.port = port;
  this.mainScriptPath = mainScript;
  this.stderrFilters_ = [];
  this.process_ = childProcess;
  this.childInstanceDone = false;
  this.returnCode_ = null;
  this.running_ = true;

  childProcess.stdout.on('data', makeBufferingDataCallback(
      (line) => console.log('[out]', line)));


  childProcess.stderr.on('data', makeBufferingDataCallback((message) => {
    const pending = [];
    console.log('[err]', message);
    for (const filter of this.stderrFilters_)
      if (!filter(message)) pending.push(filter);
    this.stderrFilters_ = pending;
  }));
  childProcess.on('exit', (code, signal) => {
    assert(this.childInstanceDone, 'Child instance died prematurely');
    this.returnCode_ = code;
    this.running_ = false;
  });
};

Harness.prototype.addStderrFilter = function(regexp, callback) {
  this.stderrFilters_.push((message) => {
    if (message.match(regexp)) {
      callback();
      return true;
    }
  });
};

Harness.prototype.run_ = function() {
  setImmediate(() => {
    this.task_(() => {
      this.task_ = this.task_.next_;
      if (this.task_)
        this.run_();
    });
  });
};

Harness.prototype.enqueue_ = function(task) {
  if (!this.task_) {
    this.task_ = task;
    this.run_();
  } else {
    let chain = this.task_;
    while (chain.next_)
      chain = chain.next_;
    chain.next_ = task;
  }
  return this;
};

Harness.prototype.testHttpResponse = function(path, check) {
  return this.enqueue_((doneCallback) => {
    checkHttpResponse(this.port, path, (err, response) => {
      check.call(this, err, response);
      doneCallback();
    });
  });
};

Harness.prototype.wsHandshake = function(devtoolsUrl, tests, readyCallback) {
  http.get({
    port: this.port,
    path: url.parse(devtoolsUrl).path,
    headers: {
      'Connection': 'Upgrade',
      'Upgrade': 'websocket',
      'Sec-WebSocket-Version': 13,
      'Sec-WebSocket-Key': 'key=='
    }
  }).on('upgrade', (message, socket) => {
    const session = new TestSession(socket, this);
    if (!(tests instanceof Array))
      tests = [tests];
    function enqueue(tests) {
      session.enqueue((sessionCb) => {
        if (tests.length) {
          tests[0](session);
          session.enqueue((cb2) => {
            enqueue(tests.slice(1));
            cb2();
          });
        } else {
          readyCallback();
        }
        sessionCb();
      });
    }
    enqueue(tests);
  }).on('response', () => common.fail('Upgrade was not received'));
};

Harness.prototype.runFrontendSession = function(tests) {
  return this.enqueue_((callback) => {
    checkHttpResponse(this.port, '/json/list', (err, response) => {
      assert.ifError(err);
      this.wsHandshake(response[0]['webSocketDebuggerUrl'], tests, callback);
    });
  });
};

Harness.prototype.expectShutDown = function(errorCode) {
  this.enqueue_((callback) => {
    if (this.running_) {
      const timeoutId = timeout('Have not terminated');
      this.process_.on('exit', (code) => {
        clearTimeout(timeoutId);
        assert.strictEqual(errorCode, code);
        callback();
      });
    } else {
      assert.strictEqual(errorCode, this.returnCode_);
      callback();
    }
  });
};

exports.startNodeForInspectorTest = function(callback) {
  const child = spawn(process.execPath,
      [ '--inspect', '--debug-brk', mainScript ]);

  const timeoutId = timeout('Child process did not start properly', 4);

  let found = false;

  const dataCallback = makeBufferingDataCallback((text) => {
    clearTimeout(timeoutId);
    console.log('[err]', text);
    if (found) return;
    const match = text.match(/Debugger listening on port (\d+)/);
    found = true;
    child.stderr.removeListener('data', dataCallback);
    assert.ok(match, text);
    callback(new Harness(match[1], child));
  });

  child.stderr.on('data', dataCallback);

  const handler = tearDown.bind(null, child);

  process.on('exit', handler);
  process.on('uncaughtException', handler);
  process.on('SIGINT', handler);
};

exports.mainScriptSource = function() {
  return fs.readFileSync(mainScript, 'utf8');
};