
/*!
* socket.io-node
* Copyright(c) 2011 LearnBoost <dev@learnboost.com>
* MIT Licensed
*/

/**
 * Module dependencies.
 */

var client = require('socket.io-client')
  , cp = require('child_process')
  , fs = require('fs')
  , util = require('./util');

/**
 * File type details.
 *
 * @api private
 */

var mime = {
    js: {
        type: 'application/javascript'
      , encoding: 'utf8'
      , gzip: true
    }
  , swf: {
        type: 'application/x-shockwave-flash'
      , encoding: 'binary'
      , gzip: false
    }
};

/**
 * Regexp for matching custom transport patterns. Users can configure their own
 * socket.io bundle based on the url structure. Different transport names are
 * concatinated using the `+` char. /socket.io/socket.io+websocket.js should
 * create a bundle that only contains support for the websocket.
 *
 * @api private
 */

var bundle = /\+((?:\+)?[\w\-]+)*(?:\.v\d+\.\d+\.\d+)?(?:\.js)$/
  , versioning = /\.v\d+\.\d+\.\d+(?:\.js)$/;

/**
 * Export the constructor
 */

exports = module.exports = Static;

/**
 * Static constructor
 *
 * @api public
 */

function Static (manager) {
  this.manager = manager;
  this.cache = {};
  this.paths = {};

  this.init();
}

/**
 * Initialize the Static by adding default file paths.
 *
 * @api public
 */

Static.prototype.init = function () {
  /**
   * Generates a unique id based the supplied transports array
   *
   * @param {Array} transports The array with transport types
   * @api private
   */
  function id (transports) {
    var id = transports.join('').split('').map(function (char) {
      return ('' + char.charCodeAt(0)).split('').pop();
    }).reduce(function (char, id) {
      return char +id;
    });

    return client.version + ':' + id;
  }

  /**
   * Generates a socket.io-client file based on the supplied transports.
   *
   * @param {Array} transports The array with transport types
   * @param {Function} callback Callback for the static.write
   * @api private
   */

  function build (transports, callback) {
    client.builder(transports, {
          minify: self.manager.enabled('browser client minification')
      }, function (err, content) {
        callback(err, content ? new Buffer(content) : null, id(transports));
      }
    );
  }

  var self = this;

  // add our default static files
  this.add('/static/flashsocket/WebSocketMain.swf', {
      file: client.dist + '/WebSocketMain.swf'
  });

  this.add('/static/flashsocket/WebSocketMainInsecure.swf', {
      file: client.dist + '/WebSocketMainInsecure.swf'
  });

  // generates dedicated build based on the available transports
  this.add('/socket.io.js', function (path, callback) {
    build(self.manager.get('transports'), callback);
  });

  this.add('/socket.io.v', { mime: mime.js }, function (path, callback) {
    build(self.manager.get('transports'), callback);
  });

  // allow custom builds based on url paths
  this.add('/socket.io+', { mime: mime.js }, function (path, callback) {
    var available = self.manager.get('transports')
      , matches = path.match(bundle)
      , transports = [];

    if (!matches) return callback('No valid transports');

    // make sure they valid transports
    matches[0].split('.')[0].split('+').slice(1).forEach(function (transport) {
      if (!!~available.indexOf(transport)) {
        transports.push(transport);
      }
    });

    if (!transports.length) return callback('No valid transports');
    build(transports, callback);
  });

  // clear cache when transports change
  this.manager.on('set:transports', function (key, value) {
    delete self.cache['/socket.io.js'];
    Object.keys(self.cache).forEach(function (key) {
      if (bundle.test(key)) {
        delete self.cache[key];
      }
    });
  });
};

/**
 * Gzip compress buffers.
 *
 * @param {Buffer} data The buffer that needs gzip compression
 * @param {Function} callback
 * @api public
 */

Static.prototype.gzip = function (data, callback) {
  var gzip = cp.spawn('gzip', ['-9', '-c', '-f', '-n'])
    , encoding = Buffer.isBuffer(data) ? 'binary' : 'utf8'
    , buffer = []
    , err;

  gzip.stdout.on('data', function (data) {
    buffer.push(data);
  });

  gzip.stderr.on('data', function (data) {
    err = data +'';
    buffer.length = 0;
  });

  gzip.on('exit', function () {
    if (err) return callback(err);

    var size = 0
      , index = 0
      , i = buffer.length
      , content;

    while (i--) {
      size += buffer[i].length;
    }

    content = new Buffer(size);
    i = buffer.length;

    buffer.forEach(function (buffer) {
      var length = buffer.length;

      buffer.copy(content, index, 0, length);
      index += length;
    });

    buffer.length = 0;
    callback(null, content);
  });

  gzip.stdin.end(data, encoding);
};

/**
 * Is the path a static file?
 *
 * @param {String} path The path that needs to be checked
 * @api public
 */

Static.prototype.has = function (path) {
  // fast case
  if (this.paths[path]) return this.paths[path];

  var keys = Object.keys(this.paths)
    , i = keys.length;
 
  while (i--) {
    if (-~path.indexOf(keys[i])) return this.paths[keys[i]];
  }

  return false;
};

/**
 * Add new paths new paths that can be served using the static provider.
 *
 * @param {String} path The path to respond to
 * @param {Options} options Options for writing out the response
 * @param {Function} [callback] Optional callback if no options.file is
 * supplied this would be called instead.
 * @api public
 */

Static.prototype.add = function (path, options, callback) {
  var extension = /(?:\.(\w{1,4}))$/.exec(path);

  if (!callback && typeof options == 'function') {
    callback = options;
    options = {};
  }

  options.mime = options.mime || (extension ? mime[extension[1]] : false);

  if (callback) options.callback = callback;
  if (!(options.file || options.callback) || !options.mime) return false;

  this.paths[path] = options;

  return true;
};

/**
 * Writes a static response.
 *
 * @param {String} path The path for the static content
 * @param {HTTPRequest} req The request object
 * @param {HTTPResponse} res The response object
 * @api public
 */

Static.prototype.write = function (path, req, res) {
  /**
   * Write a response without throwing errors because can throw error if the
   * response is no longer writable etc.
   *
   * @api private
   */

  function write (status, headers, content, encoding) {
    try {
      res.writeHead(status, headers || undefined);

      // only write content if it's not a HEAD request and we actually have
      // some content to write (304's doesn't have content).
      res.end(
          req.method !== 'HEAD' && content ? content : ''
        , encoding || undefined
      );
    } catch (e) {}
  }

  /**
   * Answers requests depending on the request properties and the reply object.
   *
   * @param {Object} reply The details and content to reply the response with
   * @api private
   */

  function answer (reply) {
    var cached = req.headers['if-none-match'] === reply.etag;
    if (cached && self.manager.enabled('browser client etag')) {
      return write(304);
    }

    var accept = req.headers['accept-encoding'] || ''
      , gzip = !!~accept.toLowerCase().indexOf('gzip')
      , mime = reply.mime
      , versioned = reply.versioned
      , headers = {
          'Content-Type': mime.type
      };

    // check if we can add a etag
    if (self.manager.enabled('browser client etag') && reply.etag && !versioned) {
      headers['Etag'] = reply.etag;
    }

    // see if we need to set Expire headers because the path is versioned
    if (versioned) {
      var expires = self.manager.get('browser client expires');
      headers['Cache-Control'] = 'private, x-gzip-ok="", max-age=' + expires;
      headers['Date'] = new Date().toUTCString();
      headers['Expires'] = new Date(Date.now() + (expires * 1000)).toUTCString();
    }

    if (gzip && reply.gzip) {
      headers['Content-Length'] = reply.gzip.length;
      headers['Content-Encoding'] = 'gzip';
      headers['Vary'] = 'Accept-Encoding';
      write(200, headers, reply.gzip.content, mime.encoding);
    } else {
      headers['Content-Length'] = reply.length;
      write(200, headers, reply.content, mime.encoding);
    }

    self.manager.log.debug('served static content ' + path);
  }

  var self = this
    , details;

  // most common case first
  if (this.manager.enabled('browser client cache') && this.cache[path]) {
    return answer(this.cache[path]);
  } else if (this.manager.get('browser client handler')) {
    return this.manager.get('browser client handler').call(this, req, res);
  } else if ((details = this.has(path))) {
    /**
     * A small helper function that will let us deal with fs and dynamic files
     *
     * @param {Object} err Optional error
     * @param {Buffer} content The data
     * @api private
     */

    function ready (err, content, etag) {
      if (err) {
        self.manager.log.warn('Unable to serve file. ' + (err.message || err));
        return write(500, null, 'Error serving static ' + path);
      }

      // store the result in the cache
      var reply = self.cache[path] = {
            content: content
          , length: content.length
          , mime: details.mime
          , etag: etag || client.version
          , versioned: versioning.test(path)
        };

      // check if gzip is enabled
      if (details.mime.gzip && self.manager.enabled('browser client gzip')) {
        self.gzip(content, function (err, content) {
          if (!err) {
            reply.gzip = {
                content: content
              , length: content.length
            }
          }

          answer(reply);
        });
      } else {
        answer(reply);
      }
    }

    if (details.file) {
      fs.readFile(details.file, ready);
    } else if(details.callback) {
      details.callback.call(this, path, ready);
    } else {
      write(404, null, 'File handle not found');
    }
  } else {
    write(404, null, 'File not found');
  }
};
