'use strict';

var Buffer = require('safe-buffer').Buffer,
    path        = require('path'),
    querystring = require('querystring'),
    url         = require('url'),
    WebSocket   = require('faye-websocket'),
    EventSource = WebSocket.EventSource;

var constants       = require('../util/constants'),
    assign          = require('../util/assign'),
    idFromMessages  = require('../util/id_from_messages'),
    toJSON          = require('../util/to_json'),
    validateOptions = require('../util/validate_options'),
    Class           = require('../util/class'),
    Logging         = require('../mixins/logging'),
    Publisher       = require('../mixins/publisher'),
    Client          = require('../protocol/client'),
    Server          = require('../protocol/server'),
    contenttypes    = require('./content_types'),
    StaticServer    = require('./static_server');

var NodeAdapter = Class({ className: 'NodeAdapter',
  DEFAULT_ENDPOINT: '/bayeux',
  SCRIPT_PATH:      'faye-browser-min.js',

  VALID_JSONP_CALLBACK: /^[a-z_\$][a-z0-9_\$]*(\.[a-z_\$][a-z0-9_\$]*)*$/i,

  initialize: function(options) {
    this._options = options || {};
    validateOptions(this._options, ['engine', 'mount', 'ping', 'timeout', 'extensions', 'websocketExtensions']);

    this._extensions = [];
    this._endpoint   = this._options.mount || this.DEFAULT_ENDPOINT;
    this._endpointRe = new RegExp('^' + this._endpoint.replace(/\/$/, '') + '(/[^/]*)*(\\.[^\\.]+)?$');
    this._server     = Server.create(this._options);

    this._static = new StaticServer(path.join(__dirname, '..', '..', 'client'), /\.(?:js|map)$/);
    this._static.map(path.basename(this._endpoint) + '.js', this.SCRIPT_PATH);
    this._static.map('client.js', this.SCRIPT_PATH);

    var extensions = this._options.extensions,
        websocketExtensions = this._options.websocketExtensions,
        i, n;

    if (extensions) {
      extensions = [].concat(extensions);
      for (i = 0, n = extensions.length; i < n; i++)
        this.addExtension(extensions[i]);
    }

    if (websocketExtensions) {
      websocketExtensions = [].concat(websocketExtensions);
      for (i = 0, n = websocketExtensions.length; i < n; i++)
        this.addWebsocketExtension(websocketExtensions[i]);
    }
  },

  listen: function() {
    throw new Error('The listen() method is deprecated - use the attach() method to bind Faye to an http.Server');
  },

  addExtension: function(extension) {
    return this._server.addExtension(extension);
  },

  removeExtension: function(extension) {
    return this._server.removeExtension(extension);
  },

  addWebsocketExtension: function(extension) {
    this._extensions.push(extension);
  },

  close: function() {
    return this._server.close();
  },

  getClient: function() {
    return this._client = this._client || new Client(this._server);
  },

  attach: function(httpServer) {
    this._overrideListeners(httpServer, 'request', 'handle');
    this._overrideListeners(httpServer, 'upgrade', 'handleUpgrade');
  },

  _overrideListeners: function(httpServer, event, method) {
    var listeners = httpServer.listeners(event),
        self      = this;

    httpServer.removeAllListeners(event);

    httpServer.on(event, function(request) {
      if (self.check(request)) return self[method].apply(self, arguments);

      for (var i = 0, n = listeners.length; i < n; i++)
        listeners[i].apply(this, arguments);
    });
  },

  check: function(request) {
    var path = url.parse(request.url, true).pathname;
    return !!this._endpointRe.test(path);
  },

  handle: function(request, response) {
    var requestUrl    = url.parse(request.url, true),
        requestMethod = request.method,
        self          = this;

    request.originalUrl = request.url;

    request.on('error', function(error) { self._returnError(response, error) });
    response.on('error', function(error) { self._returnError(null, error) });

    if (this._static.test(requestUrl.pathname))
      return this._static.call(request, response);

    // http://groups.google.com/group/faye-users/browse_thread/thread/4a01bb7d25d3636a
    if (requestMethod === 'OPTIONS' || request.headers['access-control-request-method'] === 'POST')
      return this._handleOptions(request, response);

    if (EventSource.isEventSource(request))
      return this.handleEventSource(request, response);

    if (requestMethod === 'GET')
      return this._callWithParams(request, response, requestUrl.query);

    if (requestMethod === 'POST')
      return this._concatStream(request, function(data) {
        var type   = (request.headers['content-type'] || '').split(';')[0],
            params = (type === 'application/json')
                   ? { message: data }
                   : querystring.parse(data);

        request.body = data;
        this._callWithParams(request, response, params);
      }, this);

    this._returnError(response, { message: 'Unrecognized request type' });
  },

  _callWithParams: function(request, response, params) {
    if (!params.message)
      return this._returnError(response, { message: 'Received request with no message: ' + this._formatRequest(request) });

    try {
      this.debug('Received message via HTTP ' + request.method + ': ?', params.message);

      var message = this._parseJSON(params.message),
          jsonp   = params.jsonp || constants.JSONP_CALLBACK,
          isGet   = (request.method === 'GET'),
          type    = isGet ? contenttypes.TYPE_SCRIPT : contenttypes.TYPE_JSON,
          headers = assign({}, type),
          origin  = request.headers.origin;

      if (!this.VALID_JSONP_CALLBACK.test(jsonp))
        return this._returnError(response, { message: 'Invalid JSON-P callback: ' + jsonp });

      headers['Cache-Control'] = 'no-cache, no-store';
      headers['X-Content-Type-Options'] = 'nosniff';

      if (origin) {
        headers['Access-Control-Allow-Credentials'] = 'true';
        headers['Access-Control-Allow-Origin'] = origin;
      }

      this._server.process(message, request, function(replies) {
        var body = toJSON(replies);

        if (isGet) {
          body = '/**/' + jsonp + '(' + this._jsonpEscape(body) + ');';
          headers['Content-Disposition'] = 'attachment; filename=f.txt';
        }

        headers['Content-Length'] = Buffer.from(body, 'utf8').length.toString();

        this.debug('HTTP response: ?', body);
        response.writeHead(200, headers);
        response.end(body);
      }, this);
    } catch (error) {
      this._returnError(response, error);
    }
  },

  _jsonpEscape: function(json) {
    return json.replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
  },

  handleUpgrade: function(request, socket, head) {
    var options  = { extensions: this._extensions, ping: this._options.ping },
        ws       = new WebSocket(request, socket, head, [], options),
        clientId = null,
        self     = this;

    request.originalUrl = request.url;

    ws.onmessage = function(event) {
      try {
        self.debug('Received message via WebSocket[' + ws.version + ']: ?', event.data);

        var message = self._parseJSON(event.data),
            cid     = idFromMessages(message);

        if (clientId && cid && cid !== clientId) self._server.closeSocket(clientId, false);
        self._server.openSocket(cid, ws, request);
        if (cid) clientId = cid;

        self._server.process(message, request, function(replies) {
          if (ws) ws.send(toJSON(replies));
        });
      } catch (error) {
        console.log(error.stack);
        self.error(error.message + '\nBacktrace:\n' + error.stack);
      }
    };

    ws.onclose = function(event) {
      self._server.closeSocket(clientId);
      ws = null;
    };
  },

  handleEventSource: function(request, response) {
    var es       = new EventSource(request, response, { ping: this._options.ping }),
        clientId = es.url.split('/').pop(),
        self     = this;

    this.debug('Opened EventSource connection for ?', clientId);
    this._server.openSocket(clientId, es, request);

    es.onclose = function(event) {
      self._server.closeSocket(clientId);
      es = null;
    };
  },

  _handleOptions: function(request, response) {
    var origin = request.headers.origin || request.headers.referer;
    var headers = {
      'Access-Control-Allow-Credentials': 'true',
      'Access-Control-Allow-Headers':     'Accept, Authorization, Content-Type, Pragma, X-Requested-With',
      'Access-Control-Allow-Methods':     'POST, GET',
      'Access-Control-Allow-Origin':      origin || '*',
      'Access-Control-Max-Age':           '86400'
    };

    response.writeHead(200, headers);
    response.end('');
  },

  _concatStream: function(stream, callback, context) {
    var chunks = [],
        length = 0;

    stream.on('data', function(chunk) {
      chunks.push(chunk);
      length += chunk.length;
    });

    stream.on('end', function() {
      var buffer = Buffer.alloc(length),
          offset = 0;

      for (var i = 0, n = chunks.length; i < n; i++) {
        chunks[i].copy(buffer, offset);
        offset += chunks[i].length;
      }
      callback.call(context, buffer.toString('utf8'));
    });
  },

  _parseJSON: function(json) {
    var data = JSON.parse(json);
    if (typeof data === 'object') return data;
    throw new SyntaxError('JSON messages must contain an object or array');
  },

  _formatRequest: function(request) {
    var method = request.method.toUpperCase(),
        string = 'curl -X ' + method;

    string += " 'http://" + request.headers.host + request.url + "'";
    if (method === 'POST') {
      string += " -H 'Content-Type: " + request.headers['content-type'] + "'";
      string += " -d '" + request.body + "'";
    }
    return string;
  },

  _returnError: function(response, error) {
    var message = error.message;
    if (error.stack) message += '\nBacktrace:\n' + error.stack;
    this.error(message);

    if (!response) return;

    response.writeHead(400, contenttypes.TYPE_TEXT);
    response.end('Bad request');
  }
});

for (var method in Publisher) (function(method) {
  NodeAdapter.prototype[method] = function() {
    return this._server._engine[method].apply(this._server._engine, arguments);
  };
})(method);

assign(NodeAdapter.prototype, Logging);

module.exports = NodeAdapter;
