/**
 *  class Server
 *
 *  Easy to use server/middleware ideal for serving assets your assets:
 *
 *  - great for development, as it recompiles canged assets on-fly
 *  - great for production, as it caches results, and it can become as effecient
 *    as `staticCache` middleware (or even better) of `connect` module.
 *
 *
 *  ##### Examples
 *
 *      // development mode
 *      var srv = new Server(env);
 *
 *      // production mode
 *      var srv = new Server(env.index);
 *
 *      // production mode (restrictive)
 *      var files = ['app.js', 'app.css', 'logo.jpg'];
 *      manifest.compile(files, function (err, manifestData) {
 *        var srv = new Server(env.index, manifestData);
 *      });
 *
 *  You can use this server in your connect app (or as `request` listener of
 *  `http` server) like this:
 *
 *      app.use(function (req, res) {
 *        srv.handle(req, res);
 *      });
 *
 *      // there's a shorthand syntax as well:
 *
 *      app.use(mincer.createServer(env));
 **/

'use strict';


// stdlib
var zlib   = require('zlib');
var http   = require('http');
var url    = require('url');
var format = require('util').format;


// 3rd-party
var mimoza       = require('mimoza');
var compressible = require('compressible');


// internal
var logger      = require('./logger');
var prop        = require('./common').prop;
var start_timer = require('./common').timer;


////////////////////////////////////////////////////////////////////////////////


/**
 *  new Server(environment[, manifest])
 *  - environment (Environment|Index)
 *  - manifest (Object): Data returned by [[Manifest#compile]]
 *
 *  If you provide `manifest`, then server will not even try to find files on
 *  FS unless they are specified in the `manifest`.
 **/
var Server = module.exports = function Server(environment, manifest) {
  prop(this, 'environment', environment);
  prop(this, 'manifest',    manifest);
};


// Retruns fingerprint from the pathname
var FINGERPRINT_RE = /-([0-9a-f]{32,40})\.[^.]+(?:\.map)?$/i;
function get_fingerprint(pathname) {
  var m = FINGERPRINT_RE.exec(pathname);
  return m ? m[1] : null;
}


// Helper to write the code and end response
function end(res, code) {
  res.writeHead(code);

  if (code >= 400) {
    // write human-friendly error message
    res.end('[' + code + '] ' + http.STATUS_CODES[code]);
    return;
  }

  // just end with no body for 304 responses and such
  res.end();
}


// Returns Etag value for `asset`
function etag(asset) {
  return '"' + asset.digest + '"';
}


// Returns true whenever If-None-Match header matches etag of `asset`
function is_etag_match(req, asset) {
  return etag(asset) === req.headers['if-none-match'];
}


// Tells whenever browser accepts gzip at all
function is_gzip_accepted(req) {
  var accept = req.headers['accept-encoding'] || '';
  return accept === '*' || accept.indexOf('gzip') >= 0;
}


// Returns log event structure.
//
function log_event(req, code, message, elapsed) {
  return {
    code:           code,
    message:        message,
    elapsed:        elapsed,
    request:        req,
    url:            req.originalUrl || req.url,
    method:         req.method,
    headers:        req.headers,
    httpVersion:    req.httpVersion,
    remoteAddress:  req.connection.remoteAddress
  };
}


function serve_asset(self, asset, req, res, timer) {
  var buffer, length;

  // OK
  self.log('info', log_event(req, 200, 'OK', timer.stop()));

  buffer = asset.__server_buffer__;
  length = asset.__server_buffer__.length;

  //
  // Alter some headers for gzipped assets
  //

  //
  // Only gzip is supported:
  //
  // - too many issues with deflate
  // - browsers that support deflate well, also support gzip
  //
  // Details:
  //
  // - http://www.vervestudios.co/projects/compression-tests/results
  // - http://zoompf.com/blog/2012/02/lose-the-wait-http-compression
  //

  if (asset.__server_buffer_gzipped__ && is_gzip_accepted(req)) {
    buffer = asset.__server_buffer_gzipped__;
    length = asset.__server_buffer_gzipped__.length;
    res.setHeader('Content-Encoding', 'gzip');
  }

  //
  // Set content type and length headers
  // Force charset for text assets, to avoid problems with JS loaders
  //
  res.setHeader('Content-Type', asset.contentType + (mimoza.isText(asset.contentType) ? '; charset=UTF-8' : ''));
  res.setHeader('Content-Length', length);

  res.statusCode = 200;

  if (req.method === 'HEAD') {
    res.end();
    return;
  }

  res.end(buffer);
}


function serve_source_map(self, asset, req, res, timer) {
  var length, buffer;

  if (!asset.__server_sourcemap_buffer__) {
    self.log('info', log_event(req, 404, 'Not Found', timer.stop()));

    res.statusCode = 404;
  } else {
    self.log('info', log_event(req, 200, 'OK', timer.stop()));

    res.statusCode = 200;

    buffer = asset.__server_sourcemap_buffer__;
    length = asset.__server_sourcemap_buffer__.length;

    if (asset.__gzipped_server_sourcemap__ && is_gzip_accepted(req)) {
      buffer = asset.__gzipped_server_sourcemap__;
      length = asset.__gzipped_server_sourcemap__.length;
      res.setHeader('Content-Encoding', 'gzip');
    }

    res.setHeader('Content-Type',  'application/json; charset=UTF-8');
    res.setHeader('Content-Length', length);
  }

  if (req.method === 'HEAD') {
    res.end();
    return;
  }

  res.end(buffer);
}


/** internal
 *  Server#log(level, event) -> Void
 *  - level (String): Event level
 *  - event (Object): Event data
 *
 *  This is an internal method that formats and writes messages using
 *  [[Mincer.logger]] and it fits almost 99% of cases. But if you want to
 *  integrate this [[Server]] into your existing application and have logs
 *  formatted in your way you can override this method.
 *
 *
 *  ##### Event
 *
 *  Event is an object with following fields:
 *
 *  - **code** (Number): Status code
 *  - **message** (String): Message
 *  - **elapsed** (Number): Time elapsed in milliseconds
 *  - **url** (String): Request url. See `http.request.url`.
 *  - **method** (String): Request method. See `http.request.method`.
 *  - **headers** (Object): Request headers. See `http.request.headers`.
 *  - **httpVersion** (String): Request httpVersion. See `http.request.httpVersion`.
 **/
Server.prototype.log = function log(level, event) {
  logger[level](format('Served asset %s - %d %s (%dms)',
                       event.url, event.code, event.message, event.elapsed));
};


/**
 *  Server#compile(pathname, bundle, callback(err, asset)) -> Void
 *  - pathname (String)
 *  - bundle (Boolean)
 *  - callback (Function)
 *
 *  Finds and compiles given asset.
 **/
Server.prototype.compile = function compile(pathname, bundle, callback) {
  var asset;

  try {
    asset = (this.manifest && !this.manifest.assets[pathname]) ? null
          : this.environment.findAsset(pathname, { bundle: !!bundle });
  } catch (err) {
    callback(err);
    return;
  }

  if (!asset) {
    callback(/* err = undefined, asset = undefined */);
    return;
  }

  // return immediately if asset was previously processed
  if (asset.__server_buffer__) {
    callback(null, asset);
    return;
  }

  // For bundled assets create patched content with embedded url.
  if (asset.sourceMap && asset.mappingUrlComment) {
    prop(asset, '__server_buffer__', new Buffer(asset.source + asset.mappingUrlComment()));
  } else {
    prop(asset, '__server_buffer__', asset.buffer);
  }

  if (asset.sourceMap) {
    prop(asset, '__server_sourcemap_buffer__', new Buffer(')]}\'\n' + asset.sourceMap));
    // Strange ")]}'\n" line added to sourcemap is for XSSI protection.
    // See spec for details.
  }

  if (!compressible(asset.contentType)) {
    callback(null, asset);
    return;
  }

  // Gzip and cache buffer
  zlib.gzip(asset.__server_buffer__, function (err, buffer) {
    if (err) {
      callback(err);
      return;
    }

    // set __server_buffer_gzipped__ buffer only if we have compression profit
    if (buffer.length < asset.__server_buffer__.length) {
      prop(asset, '__server_buffer_gzipped__', buffer);
    }

    if (!asset.__server_sourcemap_buffer__) {
      callback(null, asset);
      return;
    }

    zlib.gzip(asset.__server_sourcemap_buffer__, function (err, buffer) {
      if (err) {
        callback(err);
        return;
      }

      // set __server_buffer_gzipped__ buffer only if we have compression profit
      if (buffer.length < asset.__server_sourcemap_buffer__.length) {
        prop(asset, '__gzipped_server_sourcemap__', buffer);
      }

      callback(null, asset);
    });
  });
};


/**
 *  Server#handle(req, res) -> Void
 *  - req (http.ServerRequest)
 *  - res (hhtp.ServerResponse)
 *
 *  Hander function suitable for usage as server `request` event listener or as
 *  middleware for TJ's `connect` module.
 *
 *
 *  ##### Exampple
 *
 *  var assetsSet
 **/
Server.prototype.handle = function handle(req, res) {
  var self        = this,
      timer       = start_timer(),
      pathname    = url.parse(req.url).pathname,
      bundle      = !/body=[1t]/.test(url.parse(req.url).query),
      sourceMap   = /\.map$/i.test(pathname),
      fingerprint = get_fingerprint(pathname);

  try {
    pathname = decodeURIComponent(pathname.replace(/^\//, ''));
  } catch (err) {
    self.log('error', log_event(req, 400, 'Failed decode URL', timer.stop()));
    end(res, 400);
    return;
  }

  // forbid requests with `..` or NUL chars
  if (pathname.indexOf('..') >= 0 || pathname.indexOf('\u0000') >= 0) {
    self.log('error', log_event(req, 403, 'URL contains unsafe chars', timer.stop()));
    end(res, 403);
    return;
  }

  // ignore non-GET requests
  if (req.method !== 'GET' && req.method !== 'HEAD') {
    self.log('error', log_event(req, 403, 'HTTP method not allowed', timer.stop()));
    end(res, 403);
    return;
  }

  // remove fingerprint (digest) from URL
  if (fingerprint) {
    pathname = pathname.replace('-' + fingerprint, '');
  }

  // remove .map extension from pathname
  if (sourceMap) {
    pathname = pathname.replace(/\.map$/i, '');
  }

  // try to find and compile asset
  this.compile(pathname, bundle, function (err, asset) {
    if (err) {
      err = err.message || err.toString();
      self.log('error', log_event(req, 500, 'Error compiling asset: ' + err, timer.stop()));
      end(res, 500);
      return;
    }

    // asset not found
    if (!asset) {
      self.log('error', log_event(req, 404, 'Not found', timer.stop()));
      end(res, 404);
      return;
    }

    //
    // Asset found. Sending headers for 200/304 responses:
    // http://tools.ietf.org/html/draft-ietf-httpbis-p4-conditional-18#section-4.1
    //

    //
    // Ranges are not supported yet
    //

    res.removeHeader('Accept-Ranges');

    //
    // Mark for proxies, that we can return different content (plain & gzipped),
    // depending on specified (comma-separated) headers
    //

    res.setHeader('Vary', 'Accept-Encoding');

    //
    // Set caching headers
    //

    if (fingerprint) {
      // If the request url contains a fingerprint, set a long
      // expires on the response
      res.setHeader('Cache-Control', 'public, max-age=31536000');
    } else {
      // Otherwise set `must-revalidate` since the asset could be modified.
      res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
    }

    res.setHeader('Date',           (new Date()).toUTCString());
    res.setHeader('Last-Modified',  asset.mtime.toUTCString());
    res.setHeader('ETag',           etag(asset));
    res.setHeader('Server',         'Nokla 1630');

    //
    // Check if asset's etag matches `if-none-match` header
    //

    if (is_etag_match(req, asset)) {
      self.log('info', log_event(req, 304, 'Not Modified', timer.stop()));
      end(res, 304);
      return;
    }

    if (sourceMap) {
      serve_source_map(self, asset, req, res, timer);
    } else {
      serve_asset(self, asset, req, res, timer);
    }
  });
};


/**
 *  Server.createServer(environment[, manifest]) -> Function
 *  - environment (Environment)
 *  - manifest (Object)
 *
 *  Returns a server function suitable to be used as `request` event handler of
 *  `http` Node library module or as `connect` middleware.
 *
 *
 *  ##### Example
 *
 *      // Using TJ's Connect module
 *      var app = connect();
 *      app.use('/assets/', Server.createServer(env));
 *
 *
 *  ##### See Also
 *
 *  - [[Server.new]]
 **/
Server.createServer = function (environment, manifest) {
  var srv = new Server(environment, manifest);
  return function (req, res) {
    return srv.handle(req, res);
  };
};
