#!/usr/bin/env node

/*!
 * Expresso
 * Copyright(c) TJ Holowaychuk <tj@vision-media.ca>
 * (MIT Licensed)
 */
 
/**
 * Module dependencies.
 */

var assert = require('assert'),
    childProcess = require('child_process'),
    http = require('http'),
    path = require('path'),
    sys = require('sys'),
    cwd = process.cwd(),
    fs = require('fs'),
    defer;

/**
 * Expresso version.
 */

var version = '0.6.2';

/**
 * Failure count.
 */

var failures = 0;


/**
 * Number of tests executed.
 */

var testcount = 0;

/**
 * Whitelist of tests to run.
 */
 
var only = [];

/**
 * Boring output.
 */
 
var boring = false;

/**
 * Growl notifications.
 */

var growl = false;

/**
 * Server port.
 */

var port = 5555;

/**
 * Watch mode.
 */

var watch = false;

/**
 * Execute serially.
 */

var serial = false;

/**
 * Default timeout.
 */

var timeout = 2000;

/**
 * Usage documentation.
 */

var usage = ''
    + '[bold]{Usage}: expresso [options] <file ...>'
    + '\n'
    + '\n[bold]{Options}:'
    + '\n  -w, --watch          Watch for modifications and re-execute tests'
    + '\n  -g, --growl          Enable growl notifications'
    + '\n  -c, --coverage       Generate and report test coverage'
    + '\n  -t, --timeout MS     Timeout in milliseconds, defaults to 2000'
    + '\n  -r, --require PATH   Require the given module path'
    + '\n  -o, --only TESTS     Execute only the comma sperated TESTS (can be set several times)'
    + '\n  -I, --include PATH   Unshift the given path to require.paths'
    + '\n  -p, --port NUM       Port number for test servers, starts at 5555'
    + '\n  -s, --serial         Execute tests serially'
    + '\n  -b, --boring         Suppress ansi-escape colors'
    + '\n  -v, --version        Output version number'
    + '\n  -h, --help           Display help information'
    + '\n';

// Parse arguments

var files = [],
    args = process.argv.slice(2);

while (args.length) {
    var arg = args.shift();
    switch (arg) {
        case '-h':
        case '--help':
            print(usage + '\n');
            process.exit(1);
            break;
        case '-v':
        case '--version':
            sys.puts(version);
            process.exit(1);
            break;
        case '-i':
        case '-I':
        case '--include':
            if (arg = args.shift()) {
                require.paths.unshift(arg);
            } else {
                throw new Error('--include requires a path');
            }
            break;
        case '-o':
        case '--only':
            if (arg = args.shift()) {
                only = only.concat(arg.split(/ *, */));
            } else {
                throw new Error('--only requires comma-separated test names');
            }
            break;
        case '-p':
        case '--port':
            if (arg = args.shift()) {
                port = parseInt(arg, 10);
            } else {
                throw new Error('--port requires a number');
            }
            break;
        case '-r':
        case '--require':
            if (arg = args.shift()) {
                require(arg);
            } else {
                throw new Error('--require requires a path');
            }
            break;
        case '-t':
        case '--timeout':
          if (arg = args.shift()) {
            timeout = parseInt(arg, 10);
          } else {
            throw new Error('--timeout requires an argument');
          }
          break;
        case '-c':
        case '--cov':
        case '--coverage':
            defer = true;
            childProcess.exec('rm -fr lib-cov && node-jscoverage lib lib-cov', function(err){
                if (err) throw err;
                require.paths.unshift('lib-cov');
                run(files);
            })
            break;
        case '-b':
        case '--boring':
        	boring = true;
        	break;
        case '-w':
        case '--watch':
            watch = true;
            break;
        case '-g':
        case '--growl':
            growl = true;
            break;
        case '-s':
        case '--serial':
            serial = true;
            break;
        default:
            if (/\.js$/.test(arg)) {
                files.push(arg);
            }
            break;
    }
}

/**
 * Colorized sys.error().
 *
 * @param {String} str
 */

function print(str){
    sys.error(colorize(str));
}

/**
 * Colorize the given string using ansi-escape sequences.
 * Disabled when --boring is set.
 *
 * @param {String} str
 * @return {String}
 */

function colorize(str){
    var colors = { bold: 1, red: 31, green: 32, yellow: 33 };
    return str.replace(/\[(\w+)\]\{([^]*?)\}/g, function(_, color, str){
        return boring
            ? str
            : '\x1B[' + colors[color] + 'm' + str + '\x1B[0m';
    });
}

// Alias deepEqual as eql for complex equality

assert.eql = assert.deepEqual;

/**
 * Assert that `val` is null.
 *
 * @param {Mixed} val
 * @param {String} msg
 */

assert.isNull = function(val, msg) {
    assert.strictEqual(null, val, msg);
};

/**
 * Assert that `val` is not null.
 *
 * @param {Mixed} val
 * @param {String} msg
 */

assert.isNotNull = function(val, msg) {
    assert.notStrictEqual(null, val, msg);
};

/**
 * Assert that `val` is undefined.
 *
 * @param {Mixed} val
 * @param {String} msg
 */

assert.isUndefined = function(val, msg) {
    assert.strictEqual(undefined, val, msg);
};

/**
 * Assert that `val` is not undefined.
 *
 * @param {Mixed} val
 * @param {String} msg
 */

assert.isDefined = function(val, msg) {
    assert.notStrictEqual(undefined, val, msg);
};

/**
 * Assert that `obj` is `type`.
 *
 * @param {Mixed} obj
 * @param {String} type
 * @api public
 */

assert.type = function(obj, type, msg){
    var real = typeof obj;
    msg = msg || 'typeof ' + sys.inspect(obj) + ' is ' + real + ', expected ' + type;
    assert.ok(type === real, msg);
};

/**
 * Assert that `str` matches `regexp`.
 *
 * @param {String} str
 * @param {RegExp} regexp
 * @param {String} msg
 */

assert.match = function(str, regexp, msg) {
    msg = msg || sys.inspect(str) + ' does not match ' + sys.inspect(regexp);
    assert.ok(regexp.test(str), msg);
};

/**
 * Assert that `val` is within `obj`.
 *
 * Examples:
 *
 *    assert.includes('foobar', 'bar');
 *    assert.includes(['foo', 'bar'], 'foo');
 *
 * @param {String|Array} obj
 * @param {Mixed} val
 * @param {String} msg
 */

assert.includes = function(obj, val, msg) {
    msg = msg || sys.inspect(obj) + ' does not include ' + sys.inspect(val);
    assert.ok(obj.indexOf(val) >= 0, msg);
};

/**
 * Assert length of `val` is `n`.
 *
 * @param {Mixed} val
 * @param {Number} n
 * @param {String} msg
 */

assert.length = function(val, n, msg) {
    msg = msg || sys.inspect(val) + ' has length of ' + val.length + ', expected ' + n;
    assert.equal(n, val.length, msg);
};

/**
 * Assert response from `server` with
 * the given `req` object and `res` assertions object.
 *
 * @param {Server} server
 * @param {Object} req
 * @param {Object|Function} res
 * @param {String} msg
 */

assert.response = function(server, req, res, msg){
    // Callback as third or fourth arg
    var callback = typeof res === 'function'
        ? res
        : typeof msg === 'function'
            ? msg
            : function(){};

    // Default messate to test title
    if (typeof msg === 'function') msg = null;
    msg = msg || assert.testTitle;
    msg += '. ';

    // Pending responses
    server.__pending = server.__pending || 0;
    server.__pending++;

    // Create client
    if (!server.fd) {
        server.listen(server.__port = port++, '127.0.0.1');
        server.client = http.createClient(server.__port);
    }

    // Issue request
    var timer,
        client = server.client,
        method = req.method || 'GET',
        status = res.status || res.statusCode,
        data = req.data || req.body,
        timeout = req.timeout || 0;

    var request = client.request(method, req.url, req.headers);

    // Timeout
    if (timeout) {
        timer = setTimeout(function(){
            --server.__pending || server.close();
            delete req.timeout;
            assert.fail(msg + 'Request timed out after ' + timeout + 'ms.');
        }, timeout);
    }

    if (data) request.write(data);
    request.addListener('response', function(response){
        response.body = '';
        response.setEncoding('utf8');
        response.addListener('data', function(chunk){ response.body += chunk; });
        response.addListener('end', function(){
            --server.__pending || server.close();
            if (timer) clearTimeout(timer);

            // Assert response body
            if (res.body !== undefined) {
                var eql = res.body instanceof RegExp
                  ? res.body.test(response.body)
                  : res.body === response.body;
                assert.ok(
                    eql,
                    msg + 'Invalid response body.\n'
                        + '    Expected: ' + sys.inspect(res.body) + '\n'
                        + '    Got: ' + sys.inspect(response.body)
                );
            }

            // Assert response status
            if (typeof status === 'number') {
                assert.equal(
                    response.statusCode,
                    status,
                    msg + colorize('Invalid response status code.\n'
                        + '    Expected: [green]{' + status + '}\n'
                        + '    Got: [red]{' + response.statusCode + '}')
                );
            }

            // Assert response headers
            if (res.headers) {
                var keys = Object.keys(res.headers);
                for (var i = 0, len = keys.length; i < len; ++i) {
                    var name = keys[i],
                        actual = response.headers[name.toLowerCase()],
                        expected = res.headers[name];
                    assert.equal(
                        actual,
                        expected,
                        msg + colorize('Invalid response header [bold]{' + name + '}.\n'
                            + '    Expected: [green]{' + expected + '}\n'
                            + '    Got: [red]{' + actual + '}')
                    );
                }
            }

            // Callback
            callback(response);
        });
    });
    request.end();
};

/**
 * Pad the given string to the maximum width provided.
 *
 * @param  {String} str
 * @param  {Number} width
 * @return {String}
 */

function lpad(str, width) {
    str = String(str);
    var n = width - str.length;
    if (n < 1) return str;
    while (n--) str = ' ' + str;
    return str;
}

/**
 * Pad the given string to the maximum width provided.
 *
 * @param  {String} str
 * @param  {Number} width
 * @return {String}
 */

function rpad(str, width) {
    str = String(str);
    var n = width - str.length;
    if (n < 1) return str;
    while (n--) str = str + ' ';
    return str;
}

/**
 * Report test coverage.
 *
 * @param  {Object} cov
 */

function reportCoverage(cov) {
    populateCoverage(cov);
    // Stats
    print('\n   [bold]{Test Coverage}\n');
    var sep = '   +------------------------------------------+----------+------+------+--------+',
        lastSep = '                                              +----------+------+------+--------+';
    sys.puts(sep);
    sys.puts('   | filename                                 | coverage | LOC  | SLOC | missed |');
    sys.puts(sep);
    for (var name in cov) {
        var file = cov[name];
        if (Array.isArray(file)) {
            sys.print('   | ' + rpad(name, 40));
            sys.print(' | ' + lpad(file.coverage.toFixed(2), 8));
            sys.print(' | ' + lpad(file.LOC, 4));
            sys.print(' | ' + lpad(file.SLOC, 4));
            sys.print(' | ' + lpad(file.totalMisses, 6));
            sys.print(' |\n');
        }
    }
    sys.puts(sep);
    sys.print('     ' + rpad('', 40));
    sys.print(' | ' + lpad(cov.coverage.toFixed(2), 8));
    sys.print(' | ' + lpad(cov.LOC, 4));
    sys.print(' | ' + lpad(cov.SLOC, 4));
    sys.print(' | ' + lpad(cov.totalMisses, 6));
    sys.print(' |\n');
    sys.puts(lastSep);
    // Source
    for (var name in cov) {
        if (name.match(/\.js$/)) {
            var file = cov[name];
            print('\n   [bold]{' + name + '}:');
            print(file.source);
            sys.print('\n');
        }
    }
}

/**
 * Populate code coverage data.
 *
 * @param  {Object} cov
 */

function populateCoverage(cov) {
    cov.LOC = 
    cov.SLOC =
    cov.totalFiles =
    cov.totalHits =
    cov.totalMisses = 
    cov.coverage = 0;
    for (var name in cov) {
        var file = cov[name];
        if (Array.isArray(file)) {
            // Stats
            ++cov.totalFiles;
            cov.totalHits += file.totalHits = coverage(file, true);
            cov.totalMisses += file.totalMisses = coverage(file, false);
            file.totalLines = file.totalHits + file.totalMisses;
            cov.SLOC += file.SLOC = file.totalLines;
            if (!file.source) file.source = [];
            cov.LOC += file.LOC = file.source.length;
            file.coverage = (file.totalHits / file.totalLines) * 100;
            // Source
            var width = file.source.length.toString().length;
            file.source = file.source.map(function(line, i){
                ++i;
                var hits = file[i] === 0 ? 0 : (file[i] || ' ');
                if (!boring) {
                    if (hits === 0) {
                        hits = '\x1b[31m' + hits + '\x1b[0m';
                        line = '\x1b[41m' + line + '\x1b[0m';
                    } else {
                        hits = '\x1b[32m' + hits + '\x1b[0m';
                    }
                }
                return '\n     ' + lpad(i, width) + ' | ' + hits + ' | ' + line;
            }).join('');
        }
    }
    cov.coverage = (cov.totalHits / cov.SLOC) * 100;
}

/**
 * Total coverage for the given file data.
 *
 * @param  {Array} data
 * @return {Type}
 */

function coverage(data, val) {
    var n = 0;
    for (var i = 0, len = data.length; i < len; ++i) {
        if (data[i] !== undefined && data[i] == val) ++n;
    }
    return n;  
}

/**
 * Run the given test `files`, or try _test/*_.
 *
 * @param  {Array} files
 */

function run(files) {
    if (!files.length) {
        try {
            files = fs.readdirSync('test').map(function(file){
                return 'test/' + file;
            });
        } catch (err) {
            print('\n  failed to load tests in [bold]{./test}\n');
            ++failures;
            process.exit(1);
        }
    }
    if (watch) watchFiles(files);
    runFiles(files);
}

/**
 * Show the cursor when `show` is true, otherwise hide it.
 *
 * @param {Boolean} show
 */

function cursor(show) {
    if (show) {
        sys.print('\x1b[?25h');
    } else {
        sys.print('\x1b[?25l');
    }
}

/**
 * Run the given test `files`.
 *
 * @param {Array} files
 */

function runFiles(files) {
    files.forEach(runFile);
}

/**
 * Run tests for the given `file`.
 *
 * @param {String} file
 */

function runFile(file) {
    if (file.match(/\.js$/)) {
        var title = path.basename(file),
            file = path.join(cwd, file),
            mod = require(file.replace(/\.js$/, ''));
        (function check(){
           var len = Object.keys(mod).length;
           if (len) {
               runSuite(title, mod);
           } else {
               setTimeout(check, 20);
           }
        })();
    }
}

/**
 * Clear the module cache for the given `file`.
 *
 * @param {String} file
 */

function clearCache(file) {
    var keys = Object.keys(module.moduleCache);
    for (var i = 0, len = keys.length; i < len; ++i) {
        var key = keys[i];
        if (key.indexOf(file) === key.length - file.length) {
            delete module.moduleCache[key];
        }
    }
}

/**
 * Watch the given `files` for changes.
 *
 * @param {Array} files
 */

function watchFiles(files) {
    var p = 0,
        c = ['▫   ', '▫▫  ', '▫▫▫ ', ' ▫▫▫',
             '  ▫▫', '   ▫', '   ▫', '  ▫▫',
             '▫▫▫ ', '▫▫  ', '▫   '],
        l = c.length;
    cursor(false);
    setInterval(function(){
        sys.print(colorize('  [green]{' + c[p++ % l] + '} watching\r'));
    }, 100);
    files.forEach(function(file){
        fs.watchFile(file, { interval: 100 }, function(curr, prev){
            if (curr.mtime > prev.mtime) {
                print('  [yellow]{◦} ' + file);
                clearCache(file);
                runFile(file);
            }
        });
    });
}

/**
 * Report `err` for the given `test` and `suite`.
 *
 * @param {String} suite
 * @param {String} test
 * @param {Error} err
 */

function error(suite, test, err) {
    ++failures;
    var name = err.name,
        stack = err.stack.replace(err.name, ''),
        label = test === 'uncaught'
            ? test
            : suite + ' ' + test;
    print('\n   [bold]{' + label + '}: [red]{' + name + '}' + stack + '\n');
    if (watch) notify(label + ' failed');
}

/**
 * Run the given tests.
 *
 * @param  {String} title
 * @param  {Object} tests
 */

var dots = 0;
function runSuite(title, tests) {
    // Keys
    var keys = only.length
        ? only.slice(0)
        : Object.keys(tests);

    // Setup
    var setup = tests.setup || function(fn){ fn(); };
    
    // Iterate tests
    (function next(){
        if (keys.length) {
            var key,
                test = tests[key = keys.shift()];
            // Non-tests
            if (key === 'setup') return next();

            // Run test
            if (test) {
                try {
                    ++testcount;
                    assert.testTitle = key;
                    if (serial) {
                        if (!watch) {
                            sys.print('.');
                            if (++dots % 25 === 0) sys.print('\n');
                        }
                        setup(function(){
                            if (test.length < 2) {
                                test(assert);
                                next();
                            } else {
                                var id = setTimeout(function(){
                                    throw new Error("'" + key + "' timed out");
                                }, timeout);
                                test(assert, function(){
                                    clearTimeout(id);
                                    next();
                                });
                            } 
                        });
                    } else {
                        test(assert, function(fn){
                            process.addListener('beforeExit', function(){
                                try {
                                    fn();
                                } catch (err) {
                                    error(title, key, err);
                                }
                            });
                        });
                    }
                } catch (err) {
                    error(title, key, err);
                }
            }
            if (!serial) next();
        }
    })();
}

/**
 * Report exceptions.
 */

function report() {
    process.emit('beforeExit');
    if (failures) {
        print('\n   [bold]{Failures}: [red]{' + failures + '}\n\n');
        notify('Failures: ' + failures);
    } else {
        if (serial) print('');
        print('\n   [green]{100%} ' + testcount + ' tests\n');
        notify('100% ok');
    }
    if (typeof _$jscoverage === 'object') {
        reportCoverage(_$jscoverage);
    }
}

/**
 * Growl notify the given `msg`.
 *
 * @param {String} msg
 */

function notify(msg) {
    if (growl) {
        childProcess.exec('growlnotify -name Expresso -m "' + msg + '"');
    }
}

// Report uncaught exceptions

process.addListener('uncaughtException', function(err){
    error('uncaught', 'uncaught', err);
});

// Show cursor

['INT', 'TERM', 'QUIT'].forEach(function(sig){
    process.addListener('SIG' + sig, function(){
        cursor(true);
        process.exit(1);
    });
});

// Report test coverage when available
// and emit "beforeExit" event to perform
// final assertions

var orig = process.emit;
process.emit = function(event){
    if (event === 'exit') {
        report();
        process.reallyExit(failures);
    }
    orig.apply(this, arguments);
};

// Run test files

if (!defer) run(files);
