"use strict";

/*!
 * Less - middleware (adapted from the stylus middleware)
 *
 * Copyright(c) 2014 Randy Merrill <Zoramite+github@gmail.com>
 * MIT Licensed
 */

var extend = require('node.extend');
var fs = require('fs');
var less = require('less');
var mkdirp = require('mkdirp');
var path = require('path');
var url = require('url');
var utilities = require('./utilities');

// Import mapping with mtimes
var lessFiles = {};
var cacheFileInitialized = false;
// Allow tests to force flushing of cacheFile
var _saveCacheToFile = function() {};

// Check imports for changes.
var checkImports = function(path, next) {
  var nodes = lessFiles[path].imports;

  if (!nodes || !nodes.length) {
    return next();
  }

  var pending = nodes.length;
  var changed = [];

  nodes.forEach(function(imported){
    fs.stat(imported.path, function(err, stat) {
      // error or newer mtime
      if (err || !imported.mtime || stat.mtime > imported.mtime) {
        changed.push(imported.path);
      }

      --pending || next(changed);
    });
  });
};

var initCacheFile = function(cacheFile, log) {
  cacheFileInitialized = true;
  var cacheFileSaved = false;
  _saveCacheToFile = function() {
    if (cacheFileSaved) { // We expect to only save to the cache file once, just before exiting
      log('cache file already appears to be saved, not saving again to', cacheFile);
      return;
    } else {
      cacheFileSaved = true;
      try {
        fs.writeFileSync(cacheFile, JSON.stringify(lessFiles));
        log('successfully cached imports to file', cacheFile);
      } catch (err) {
        log('error caching imports to file ' + cacheFile, err);
      }
    }
  };
  process.on('exit', _saveCacheToFile);
  process.once('SIGUSR2', function() { // Handle nodemon restarts
    _saveCacheToFile();
    process.kill(process.pid, 'SIGUSR2');
  });
  process.once('SIGINT', function() {
    _saveCacheToFile();
    process.kill(process.pid, 'SIGINT'); // Let other SIGINT handlers run, if there are any
  });

  fs.readFile(cacheFile, 'utf8', function(err, data) {
    if (!err) {
      try {
        lessFiles = extend(JSON.parse(data), lessFiles);
      } catch (err) {
        log('error parsing cached imports in file ' + cacheFile, err);
      }
    } else {
      log('error loading cached imports file ' + cacheFile, err);
    }
  });
}

/**
 * Return Connect middleware with the given `options`.
 */
module.exports = less.middleware = function(source, options){
  // Source dir is required.
  if (!source) {
    throw new Error('less.middleware() requires `source` directory');
  }

  // Override the defaults for the middleware.
  options = extend(true, {
    cacheFile: null,
    debug: false,
    dest: source,
    force: false,
    once: false,
    pathRoot: null,
    postprocess: {
      css: function(css, req) { return css; },
      sourcemap: function(sourcemap, req) { return sourcemap; }
    },
    preprocess: {
      less: function(src, req) { return src; },
      path: function(pathname, req) { return pathname; },
      importPaths: function(paths, req) { return paths; }
    },
    render: {
      compress: 'auto',
      yuicompress: false,
      paths: []
    },
    storeCss: function(pathname, css, req, next) {
      mkdirp(path.dirname(pathname), 511 /* 0777 */, function(err){
        if (err) return next(err);

        fs.writeFile(pathname, css, next);
      });
    },
    storeSourcemap: function(pathname, sourcemap, req) {
      mkdirp(path.dirname(pathname), 511 /* 0777 */, function(err){
        if (err) {
          utilities.lessError(err);
          return;
        }

        fs.writeFile(pathname, sourcemap, function(err) {
          if (err) throw err;
        });
      });
    }
  }, options || {});

  // The log function is determined by the debug option.
  var log = (options.debug ? utilities.logDebug : utilities.log);

  if (options.cacheFile && !cacheFileInitialized) {
    initCacheFile(options.cacheFile, log);
  }

  // Expose for testing.
  less.middleware._saveCacheToFile = _saveCacheToFile;

  // Actual middleware.
  return function(req, res, next) {
    if ('GET' != req.method.toUpperCase() && 'HEAD' != req.method.toUpperCase()) { return next(); }

    var pathname = url.parse(req.url).pathname;

    // Only handle the matching files in this middleware.
    if (utilities.isValidPath(pathname)) {
      var isSourceMap = utilities.isSourceMap(pathname);

      // Translate source maps to a normal .css request which will update the associated source-map.
      if( isSourceMap ){
        pathname = pathname.replace( /\.map$/, '' );
      }
      var lessPath = path.join(source, utilities.maybeCompressedSource(pathname));
      var cssPath = path.join(options.dest, pathname);

      if (options.pathRoot) {
        pathname = pathname.replace(options.dest, '');
        cssPath = path.join(options.pathRoot, options.dest, pathname);
        lessPath = path.join(options.pathRoot, source, utilities.maybeCompressedSource(pathname));
      }

      var sourcemapPath = cssPath + '.map';

      // Allow for preprocessing the source filename.
      lessPath = options.preprocess.path(lessPath, req);

      log('pathname', pathname);
      log('source', lessPath);
      log('destination', cssPath);

      // Ignore ENOENT to fall through as 404.
      var error = function(err) {
        return next('ENOENT' == err.code ? null : err);
      };

      var compile = function() {
        fs.readFile(lessPath, 'utf8', function(err, lessSrc){
          if (err) {
            return error(err);
          }

          delete lessFiles[lessPath];

          try {
            var renderOptions = extend(true, {}, options.render, {
              filename: lessPath,
              paths: options.preprocess.importPaths(options.render.paths, req)
            });
            lessSrc = options.preprocess.less(lessSrc, req);

            less.render(lessSrc, renderOptions, function(err, output){
              if (err) {
                utilities.lessError(err);
                return next(err);
              }

              // Determine the imports used and check modified times.
              var imports = [];
              output.imports.forEach(function(imported) {
                var currentImport = {
                    path: imported,
                    mtime: null
                };

                imports.push(currentImport);

                // Update the mtime of the import async.
                fs.stat(imported, function(err, lessStats){
                  if (err) {
                    return error(err);
                  }

                  currentImport.mtime = lessStats.mtime;
                });
              });

              // Store the less paths for simple cache invalidation.
              lessFiles[lessPath] = {
                mtime: Date.now(),
                imports: imports
              };

              if(output.map) {
                // Postprocessing on the sourcemap.
                var map = options.postprocess.sourcemap(output.map, req);

                // Custom sourcemap storage.
                options.storeSourcemap(sourcemapPath, map, req);
              }

              // Postprocessing on the css.
              var css = options.postprocess.css(output.css, req);

              // Custom css storage.
              options.storeCss(cssPath, css, req, next);
            });
          } catch (err) {
            utilities.lessError(err);
            return next(err);
          }
        });
      };

      // Force recompile of all files.
      if (options.force) {
        return compile();
      }

      // Only compile once, disregarding the file changes.
      if (options.once && lessFiles[lessPath]) {
        return next();
      }

      // Compile on (uncached) server restart and new files.
      if (!lessFiles[lessPath]) {
        return compile();
      }

      // Compare mtimes to determine if changed.
      fs.stat(lessPath, function(err, lessStats){
        if (err) {
          return error(err);
        }

        fs.stat(cssPath, function(err, cssStats){
          // CSS has not been compiled, compile it!
          if (err) {
            if ('ENOENT' == err.code) {
              log('not found', cssPath);

              // No CSS file found in dest
              return compile();
            }

            return next(err);
          }

          if (lessStats.mtime > cssStats.mtime) {
            // Source has changed, compile it
            log('modified', cssPath);

            return compile();
          } else if (lessStats.mtime > lessFiles[lessPath].mtime) {
            // This can happen if lessFiles[lessPath] was copied from
            // cacheFile above, but the cache file was out of date (which
            // can happen e.g. if node is killed and we were unable to write out
            // lessFiles on exit). Since imports might have changed, we need to
            // recompile.
            log('cache file out of date for', lessPath);

            return compile();
          } else {
            // Check if any of the less imports were changed
            checkImports(lessPath, function(changed){
              if(typeof changed != "undefined" && changed.length) {
                log('modified import', changed);

                return compile();
              }

              return next();
            });
          }
        });
      });
    } else {
      return next();
    }
  };
};
