<?php
/**
 *
 * Command line utility.
 *
 */
require_once 'CssCrush.php';

define('STATUS_OK', 0);
define('STATUS_ERROR', 1);

$version = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
$requiredVersion = 7.1;

if ($version < $requiredVersion) {

    stderr(["PHP version $requiredVersion or higher is required to use this tool.",
        "You are currently running PHP $version"]);

    exit(STATUS_ERROR);
}

try {
    $args = parse_args();
}
catch (Exception $ex) {

    stderr(message($ex->getMessage(), ['type'=>'error']));

    exit($ex->getCode());
}


##################################################################
##  Information options.

if ($args->version) {

    stdout((string) CssCrush\Version::detect());

    exit(STATUS_OK);
}
elseif ($args->help) {

    stdout(manpage());

    exit(STATUS_OK);
}


##################################################################
##  Resolve input.

$input = null;

if ($args->input_file) {

    $input = file_get_contents($args->input_file);
}
elseif ($stdin = get_stdin_contents()) {

    $input = $stdin;
}
else {
    stdout(manpage());

    exit(STATUS_OK);
}


if ($args->watch && ! $args->input_file) {

    stderr(message('Watch mode requires an input file.', ['type'=>'error']));

    exit(STATUS_ERROR);
}


##################################################################
##  Resolve process options.

$configFile = 'crushfile.php';
if (file_exists($configFile)) {
    $options = CssCrush\Util::readConfigFile($configFile);
}
else {
    $options = [];
}

if ($args->pretty) {
    $options['minify'] = false;
}

foreach (['boilerplate', 'formatter', 'newlines',
    'stat_dump', 'source_map', 'import_path'] as $option) {
    if ($args->$option) {
        $options[$option] = $args->$option;
    }
}

if ($args->enable_plugins) {
    $options['plugins'] = parse_list($args->enable_plugins);
}

if ($args->vendor_target) {
    $options['vendor_target'] = parse_list($args->vendor_target);
}

if ($args->vars) {
    parse_str($args->vars, $in_vars);
    $options['vars'] = $in_vars;
}

if ($args->output_file) {
    $options['output_dir'] = dirname($args->output_file);
    $options['output_file'] = basename($args->output_file);
}

$options += [
    'doc_root' => getcwd(),
    'context' => $args->context,
];


##################################################################
##  Output.

error_reporting(0);

if ($args->watch) {

    csscrush_set('config', ['io' => 'CssCrush\IO\Watch']);

    stdout('CONTROL-C to quit.');

    $outstandingErrors = false;

    while (true) {

        if (! file_exists($args->input_file)) {
            stderr(message(['Input file was not found'], ['type'=>'error']));
            exit(STATUS_ERROR);
        }

        csscrush_file($args->input_file, $options);
        $stats = csscrush_stat();

        $changed = $stats['compile_time'] && ! $stats['errors'];
        $errors = $stats['errors'];
        $warnings = $stats['warnings'];
        $showErrors = $errors && (! $outstandingErrors || ($outstandingErrors != $errors));

        if ($errors) {
            if ($showErrors) {
                $outstandingErrors = $errors;
                stderr(message($errors, ['type'=>'error']));
            }
        }
        elseif ($changed) {
            $outstandingErrors = false;
            stderr(message(fmt_fileinfo($stats, 'output'), ['type'=>'write']));
        }

        if (($showErrors || $changed) && $warnings) {
            stderr(message($warnings, ['type'=>'warning']));
        }

        if ($changed && $args->stats) {
            stderr(message($stats, ['type'=>'stats']));
        }

        sleep(1);
    }
}
else {

    $stdOutput = null;

    if ($args->input_file && isset($options['output_dir'])) {
        $options['cache'] = false;
        csscrush_file($args->input_file, $options);
    }
    else {
        $stdOutput = csscrush_string($input, $options);
    }

    $stats = csscrush_stat();
    $errors = $stats['errors'];
    $warnings = $stats['warnings'];

    if ($errors) {
        stderr(message($errors, ['type'=>'error']));

        exit(STATUS_ERROR);
    }
    elseif ($args->input_file && ! empty($stats['output_filename'])) {
        stderr(message(fmt_fileinfo($stats, 'output'), ['type'=>'write']));
    }

    if ($warnings) {
        stderr(message($warnings, ['type'=>'warning']));
    }

    if ($args->stats) {
        stderr(message($stats, ['type'=>'stats']));
    }

    if ($stdOutput) {
        stdout($stdOutput);
    }

    exit(STATUS_OK);
}


##################################################################
##  Helpers.

function stderr($lines, $closing_newline = true) {

    $out = implode(PHP_EOL, (array) $lines) . ($closing_newline ? PHP_EOL : '');
    fwrite(defined('TESTMODE') && TESTMODE ? STDOUT : STDERR, $out);
}

function stdout($lines, $closing_newline = true) {

    $out = implode(PHP_EOL, (array) $lines) . ($closing_newline ? PHP_EOL : '');
    fwrite(STDOUT, $out);
}

function get_stdin_contents() {

    stream_set_blocking(STDIN, 0);
    $contents = stream_get_contents(STDIN);
    stream_set_blocking(STDIN, 1);

    return $contents;
}

function parse_list(array $option) {

    $out = [];
    foreach ($option as $arg) {
        if (is_string($arg)) {
            foreach (preg_split('~\s*,\s*~', $arg) as $item) {
                $out[] = $item;
            }
        }
        else {
            $out[] = $arg;
        }
    }
    return $out;
}

function message($messages, $options = []) {

    $defaults = [
        'color' => 'b',
        'label' => null,
        'indent' => false,
        'format_label' => false,
    ];
    $preset = ! empty($options['type']) ? $options['type'] : null;
    switch ($preset) {
        case 'error':
            $defaults['color'] = 'r';
            $defaults['label'] = 'ERROR';
            break;
        case 'warning':
            $defaults['color'] = 'y';
            $defaults['label'] = 'WARNING';
            break;
        case 'write':
            $defaults['color'] = 'g';
            $defaults['label'] = 'WRITE';
            break;
        case 'stats':
            // Making stats concise and readable.
            $messages['input_file'] = $messages['input_path'];
            $messages['compile_time'] = round($messages['compile_time'], 5) . ' seconds';
            foreach (['input_filename', 'input_path', 'output_filename',
                'output_path', 'vars', 'errors', 'warnings'] as $key) {
                unset($messages[$key]);
            }
            ksort($messages);
            $defaults['indent'] = true;
            $defaults['format_label'] = true;
            break;
    }
    extract($options + $defaults);

    $out = [];
    foreach ((array) $messages as $_label => $value) {
        $_label = $label ?: $_label;
        if ($format_label) {
            $_label = ucfirst(str_replace('_', ' ', $_label));
        }
        $prefix = $indent ? '└── ' : '';
        $colorUp = strtoupper($color);
        if (is_scalar($value)) {
            $out[] = colorize("<$color>$prefix<$colorUp>$_label:<$color> $value</>");
        }
    }
    return implode(PHP_EOL, $out);
}

function fmt_fileinfo($stats, $type) {
    $time = round($stats['compile_time'], 3);
    return $stats[$type . '_path'] . " ({$time}s)";
}

function pick(array &$arr) {

    $args = func_get_args();
    array_shift($args);

    foreach ($args as $key) {
        if (isset($arr[$key])) {
            // Optional values return false but we want true is argument is present.
            return is_bool($arr[$key]) ? true : $arr[$key];
        }
    }
    return null;
}

function colorize($str) {

    static $color_support;
    static $tags = [
        '<b>' => "\033[0;30m",
        '<r>' => "\033[0;31m",
        '<g>' => "\033[0;32m",
        '<y>' => "\033[0;33m",
        '<b>' => "\033[0;34m",
        '<v>' => "\033[0;35m",
        '<c>' => "\033[0;36m",
        '<w>' => "\033[0;37m",

        '<B>' => "\033[1;30m",
        '<R>' => "\033[1;31m",
        '<G>' => "\033[1;32m",
        '<Y>' => "\033[1;33m",
        '<B>' => "\033[1;34m",
        '<V>' => "\033[1;35m",
        '<C>' => "\033[1;36m",
        '<W>' => "\033[1;37m",

        '</>' => "\033[m",
    ];

    if (! isset($color_support)) {
        $color_support = defined('TESTMODE') && TESTMODE ? false : true;
        if (DIRECTORY_SEPARATOR == '\\') {
            $color_support = false !== getenv('ANSICON') || 'ON' === getenv('ConEmuANSI');
        }
    }

    $find = array_keys($tags);
    $replace = $color_support ? array_values($tags) : '';

    return str_replace($find, $replace, $str);
}

function get_trailing_io_args($required_value_opts) {

    $trailing_input_file = null;
    $trailing_output_file = null;

    // Get raw script args, shift off calling scriptname and reduce to last three.
    $trailing_args = $GLOBALS['argv'];
    array_shift($trailing_args);
    $trailing_args = array_slice($trailing_args, -3);

    // Create patterns for detecting options.
    $required_values = implode('|', $required_value_opts);
    $value_opt_patt = "~^-{1,2}($required_values)$~";
    $other_opt_patt = "~^-{1,2}([a-z0-9\-]+)?(=|$)~ix";

    // Step through the args.
    $filtered = [];
    for ($i = 0; $i < count($trailing_args); $i++) {

        $current = $trailing_args[$i];

        // If tests as a required value option, reset and skip next.
        if (preg_match($value_opt_patt, $current)) {
            $filtered = [];
            $i++;
        }
        // If it looks like any other kind of flag, or optional value option, reset.
        elseif (preg_match($other_opt_patt, $current)) {
            $filtered = [];
        }
        else {
            $filtered[] = $current;
        }
    }

    // We're only interested in the last two values.
    $filtered = array_slice($filtered, -2);

    switch (count($filtered)) {
        case 1:
            $trailing_input_file = $filtered[0];
            break;
        case 2:
            $trailing_input_file = $filtered[0];
            $trailing_output_file = $filtered[1];
            break;
    }

    return [$trailing_input_file, $trailing_output_file];
}

function parse_args() {

    $required_value_opts = [
        'i|input|f|file', // Input file. Defaults to STDIN.
        'o|output', // Output file. Defaults to STDOUT.
        'E|enable|plugins',
        'D|disable',
        'vars|variables',
        'formatter',
        'vendor-target',
        'context',
        'import-path',
        'newlines',
    ];

    $optional_value_opts = [
        'b|boilerplate',
        'stat-dump',
    ];

    $flag_opts = [
        'p|pretty',
        'w|watch',
        'help',
        'version',
        'source-map',
        'stats',
        'test',
    ];

    // Create option strings for getopt().
    $short_opts = [];
    $long_opts = [];
    $join_opts = function ($opts_list, $modifier) use (&$short_opts, &$long_opts) {
        foreach ($opts_list as $opt) {
            foreach (explode('|', $opt) as $arg) {
                if (strlen($arg) === 1) {
                    $short_opts[] = "$arg$modifier";
                }
                else {
                    $long_opts[] = "$arg$modifier";
                }
            }
        }
    };
    $join_opts($required_value_opts, ':');
    $join_opts($optional_value_opts, '::');
    $join_opts($flag_opts, '');

    $opts = getopt(implode($short_opts), $long_opts);

    $args = new stdClass();

    // Information options.
    $args->help = isset($opts['h']) ?: isset($opts['help']);
    $args->version = isset($opts['version']);

    // File arguments.
    $args->input_file = pick($opts, 'i', 'input', 'f', 'file');
    $args->output_file = pick($opts, 'o', 'output');
    $args->context = pick($opts, 'context');

    // Flags.
    $args->pretty = isset($opts['p']) ?: isset($opts['pretty']);
    $args->watch = isset($opts['w']) ?: isset($opts['watch']);
    $args->source_map = isset($opts['source-map']);
    $args->stats = pick($opts, 'stats');
    define('TESTMODE', isset($opts['test']));

    // Arguments that optionally accept a single value.
    $args->boilerplate = pick($opts, 'b', 'boilerplate');
    $args->stat_dump = pick($opts, 'stat-dump');

    // Arguments that require a single value.
    $args->formatter = pick($opts, 'formatter');
    $args->vars = pick($opts, 'vars', 'variables');
    $args->newlines = pick($opts, 'newlines');

    // Arguments that require a value but accept multiple values.
    $args->enable_plugins = pick($opts, 'E', 'enable', 'plugins');
    $args->vendor_target = pick($opts, 'vendor-target');
    $args->import_path = pick($opts, 'import-path');

    // Run multiple value arguments through array cast.
    foreach (['enable_plugins', 'vendor_target'] as $arg) {
        if ($args->$arg) {
            $args->$arg = (array) $args->$arg;
        }
    }

    // Detect trailing IO files from raw script arguments.
    list($trailing_input_file, $trailing_output_file) = get_trailing_io_args($required_value_opts);

    // If detected apply, not overriding explicit IO file options.
    if (! $args->input_file && $trailing_input_file) {
        $args->input_file = $trailing_input_file;
    }
    if (! $args->output_file && $trailing_output_file) {
        $args->output_file = $trailing_output_file;
    }

    if ($args->input_file) {
        $inputFile = $args->input_file;
        if (! ($args->input_file = realpath($args->input_file))) {
            throw new Exception("Input file '$inputFile' does not exist.", STATUS_ERROR);
        }
    }

    if ($args->output_file) {
        $outDir = dirname($args->output_file);
        if (! realpath($outDir) && ! @mkdir($outDir, 0755, true)) {
            throw new Exception('Output directory does not exist and could not be created.', STATUS_ERROR);
        }
        $args->output_file = realpath($outDir) . '/' . basename($args->output_file);
    }

    if ($args->context) {
        if (! ($args->context = realpath($args->context))) {
            throw new Exception('Context path does not exist.', STATUS_ERROR);
        }
    }
    else {
        $args->context = $args->input_file ? dirname($args->input_file) : getcwd();
    }
    if (is_string($args->boilerplate)) {
        if (! ($args->boilerplate = realpath($args->boilerplate))) {
            throw new Exception('Boilerplate file does not exist.', STATUS_ERROR);
        }
    }

    return $args;
}

function manpage() {

    $manpage = <<<TPL

<B>USAGE:</>
    <B>csscrush <G>[OPTIONS] <g>[input-file] [output-file]

<B>OPTIONS:</>
    <G>-i<g>, --input</>
        Input file. If omitted takes input from STDIN.

    <G>-o<g>, --output</>
        Output file. If omitted prints to STDOUT.

    <G>-p<g>, --pretty</>
        Formatted, un-minified output.

    <G>-w<g>, --watch</>
        Watch input file for changes.
        Writes to file specified with -o option or to the input file
        directory with a '.crush.css' file extension.

    <G>-E<g>, --plugins</>
        List of plugins (comma separated) to enable.

    <g>--boilerplate</>
        Whether or not to output a boilerplate. Optionally accepts filepath
        to a custom boilerplate template.

    <g>--context</>
        Filepath context for resolving relative import URLs.
        Only meaningful when taking raw input from STDIN.

    <g>--import-path</>
        Comma separated list of additional paths to search when resolving
        relative import URLs.

    <g>--formatter</>
        Possible values:
            'block' (default)
                Rules are block formatted.
            'single-line'
                Rules are printed in single lines.
            'padded'
                Rules are printed in single lines with right padded selectors.

    <g>--help</>
        Display this help message.

    <g>--newlines</>
        Force newline style on output css. Defaults to the current platform
        newline. Possible values: 'windows' (or 'win'), 'unix', 'use-platform'.

    <g>--source-map</>
        Create a source map file (compliant with the Source Map v3 proposal).

    <g>--stats</>
        Display post-compile stats.

    <g>--vars</>
        Map of variable names in an http query string format.

    <g>--vendor-target</>
        Possible values:
            'all'
                For all vendor prefixes (default).
            'none'
                For no vendor prefixing.
            'moz', 'webkit', 'ms' etc.
                Limit to a specific vendor prefix (or comma separated list).

    <g>--version</>
        Display version number.

<B>EXAMPLES:</>
    # Restrict vendor prefixing.
    csscrush --pretty --vendor-target webkit -i styles.css

    # Piped input.
    cat styles.css | csscrush --vars 'foo=black&bar=white' > alt-styles.css

    # Linting.
    csscrush --pretty -E property-sorter -i styles.css -o linted.css

    # Watch mode.
    csscrush --watch -i styles.css -o compiled/styles.css

    # Using custom boilerplate template.
    csscrush --boilerplate=css/boilerplate.txt css/styles.css

TPL;

    return colorize($manpage);
}
