#!/usr/bin/env perl

use Modern::Perl;

use CSS::Prepare;
use File::stat;
use FileHandle;
use Getopt::Long    qw( :config bundling );
use IO::Handle;
use POSIX           qw( mkfifo );
use Pod::Usage;
use Term::ANSIColor;
use Time::HiRes     qw( gettimeofday tv_interval );
use Storable        qw( retrieve nstore );

use constant OPTIONS => qw(
              use-all-shasum|a
              hierarchy-base|b=s
                 cache-store|c=s
                  output-dir|d=s
             extended-syntax|e
               disable-hacks
                        help|h
                    location|l=s
               assets-output|m=s
                 assets-base|n=s
                    optimise|o
                        pipe=s
                      pretty|p
                       quiet|q
                      server
        suboptimal-threshold|s=i
                     timeout|t=i
               warnings-only|w
               exit-on-error|x
    );



my %options      = get_options_or_exit();
my %prepare_args = get_prepare_arguments( %options );
my $preparer     = CSS::Prepare->new( %prepare_args );
my( $output, $total_saving, $total_errors, %cache );

if ( $options{'cache-store'} ) {
    if ( -f $options{'cache-store'} ) {
        my $store = retrieve $options{'cache-store'};
        %cache = %$store;
    }
}

if ( $options{'server'} ) {
    run_server( @ARGV );
}
elsif ( $options{'pipe'} ) {
    run_pipe( @ARGV );
}
else {
    my @files;
    foreach my $arg ( @ARGV ) {
        if ( -d $arg ) {
            push @files, get_files_in_directory( $arg );
        }
        else {
            push @files, $arg;
        }
    }
    
    foreach my $stylesheet ( @files ) {
        my ( $processed, $saving, $error_count, $not_cached )
            = process_stylesheet( $stylesheet );

        $total_saving += $saving;
        $total_errors += $error_count;
        $output       .= $processed;
    }

    exit $total_errors
        if defined $options{'warnings-only'};
    die "Exiting - has ${total_errors} error(s)\n"
        if $options{'exit-on-error'} && $total_errors;

    status( "Total: ${total_saving} saved bytes"
            . ( $total_errors ? "; ${total_errors} errors." : '' ) );
    if ( $options{'output-dir'} ) {
        my $filename = output_stylesheet_to_directory( $output );
        status( "Saved to $filename" );
    }
    else {
        print $output;
    }
}

if ( $options{'cache-store'} ) {
    nstore \%cache, $options{'cache-store'};
}

exit;



sub run_server {
    my @args = @_;
    
    eval {
        require Plack::Runner;
        require Plack::Request;
    };
    
    my $runner = Plack::Runner->new();
    $runner->parse_options( @args );
    
    my $preparer = sub {
        my $env = shift;
        my $req = Plack::Request->new( $env );
        
        my( $output, $total_saving, $total_errors );
        
        return [ 404, [], [] ]
            unless $req->path_info eq '/';
        
        foreach my $stylesheet ( @args ) {
            my ( $processed, $saving, $error_count, $not_cached )
                = process_stylesheet( $stylesheet );
            
            $total_saving += $saving;
            $total_errors += $error_count;
            $output       .= $processed;
        }
        
        return [
                200,
                [ 'Content-Type' => 'text/css' ],
                [ $output ]
            ];
    };
    
    $runner->run( $preparer );
}

sub run_pipe {
    my @args      = @_;
    
    my $pipe_name = $options{'pipe'};
    my $pipe_mode = 0700;
    
    # clean up after ourselves
    $SIG{'INT'} = sub {
        unlink $pipe_name;
        exit 0;
    };
    
    while (1) {
        # (re-)create the named pipe
        unless ( -p $pipe_name ) {
            unlink $pipe_name;
            mkfifo( $pipe_name, $pipe_mode )
                or die "Cannot make pipe ${pipe_name}: $!";
            status( "Re-opened pipe ${pipe_name}" );
        }
        
        my $pipe;
        my $output;
        
        # this blocks until there is a reader on the other end,
        # so any CSS processing is done just-in-time
        open( $pipe, '>', $pipe_name )
            or die "Cannot write to ${pipe_name}: $!";
        
        my @files;
        foreach my $arg ( @args ) {
            if ( -d $arg ) {
                push @files, get_files_in_directory( $arg );
            }
            else {
                push @files, $arg;
            }
        }
        
        our $updates = 0;
        foreach my $stylesheet ( @files ) {
            my ( $processed, $saving, $error_count, $not_cached )
                = process_stylesheet( $stylesheet );
            
            $total_saving += $saving;
            $total_errors += $error_count;
            $output       .= $processed;
            $updates      += $not_cached;
        }
        
        print {$pipe} $output;
        close $pipe;
        
        status('') if $updates;
        
        # avoid dup signals
        select undef, undef, undef, 0.1;
        
        # touch the pipe to confound anything caching based upon the mtime
        utime undef, undef, $pipe;
    }
}

sub process_stylesheet {
    my $stylesheet = shift;
    
    $cache{ $stylesheet } = { timestamp => 0, }
        unless defined $cache{ $stylesheet };
    my $cache = $cache{ $stylesheet };
    
    my $stat        = stat $stylesheet;
    my $timestamp   = $stat->mtime;
    my $recalculate = $timestamp != $cache->{'timestamp'}
                      || $cache->{'error_count'};
    
    if ( $recalculate ) {
        my $start = [ gettimeofday() ];
        status( "Processing stylesheet '$stylesheet'" );
        
        my @structure = $preparer->parse_stylesheet( $stylesheet );
        
        $cache->{'saving'}      = 0;    # TODO
        $cache->{'error_count'} = 0;
        $cache->{'output'}      = '';
        $cache->{'timestamp'}   = $timestamp;
        
        foreach my $block ( @structure ) {
            foreach my $error ( @{$block->{'errors'}} ) {
                my $selector = defined $block->{'selectors'}
                             ? join ', ', @{$block->{'selectors'}}
                             : '';
                my( $level, $text ) = each %$error;
                
                status(
                        "  [${level}] '${selector}' - ${text}",
                        0,
                        'bold red'
                    );
                $cache->{'error_count'}++;
            }
        }
        
        if ( !defined $options{'warnings-only'} ) {
            @structure = $preparer->optimise( @structure )
                if defined $options{'optimise'};
            
            $cache->{'output'}
                = $preparer->output_as_string( @structure );
            
            my $interval = tv_interval( $start );
            status( "\r  Time taken ${interval} seconds" );
        }
    }
    
    return (
        $cache->{'output'},
        $cache->{'saving'},
        $cache->{'error_count'},
        $recalculate,
    );
}
sub output_stylesheet_to_directory {
    my $output = shift;
    
    my $sha1      = sha1_base64( $output );
       $sha1  =~ s{/}{_}g;
    
    if ( !defined $options{'use-all-shasum'} ) {
        # ten characters is not as unique as the full SHA1 digest, but is 
        # just about unique enough for our purposes
        $sha1 = substr( $sha1, 0, 5 );
    }
    
    my $filename = "$options{'output-dir'}/${sha1}.css";
    my $handle   = FileHandle->new( $filename, 'w' )
        or die "Cannot write $output: $!";
    
    print {$handle} $output;
    
    return $filename;
}
sub get_files_in_directory {
    my $directory = shift;
    
    opendir my $handle, $directory
        or return;
    
    my @files;
    my @directories;
    while ( my $entry = readdir $handle ) {
        next if $entry =~ m{^\.};
        
        my $target = "$directory/$entry";
        
        push( @files, $target )
            if -f $target && $target =~ m{\.css$};
        push( @directories, $target ) if -d $target;
    }
    closedir $handle;
    
    foreach my $dir ( @directories ) {
        my @subfiles;
        
        foreach my $file ( get_files_in_directory( $dir ) ) {
            push @subfiles, $file;
        }
        
        @files = ( @subfiles, @files );
    }
    
    return sort @files;
}


sub get_options_or_exit {
    my %getopts;
    
    my $known = GetOptions( \%getopts, OPTIONS );
    my $usage = ! $known || $getopts{'help'};
    
    if ( $getopts{'output-dir'} ) {
        eval {
                require Digest::SHA1;
                Digest::SHA1->import( 'sha1_base64' );
            };
        die( "Cannot generate output file--cssprepare requires the perl\n"
             . "module 'Digest::SHA1' to be installed." )
                if $@;
    }
    
    pod2usage() if $usage;
    
    return %getopts;
}
sub get_prepare_arguments {
    my %getopts = @_;
    
    my %args;
    $args{'extended'} = 1
        if defined $getopts{'extended-syntax'};
    $args{'pretty'} = 1
        if defined $getopts{'pretty'};
    $args{'location'} = $getopts{'location'}
        if defined $getopts{'location'};
    $args{'hacks'} = 0
        if defined $getopts{'disable-hacks'};
    $args{'base_directory'} = $getopts{'hierarchy-base'}
        if defined $getopts{'hierarchy-base'};
    $args{'http_timeout'} = $getopts{'timeout'}
        if defined $getopts{'timeout'};
    $args{'suboptimal_threshold'} = $getopts{'suboptimal-threshold'}
        if defined $getopts{'suboptimal-threshold'};
    $args{'assets_output'} = $getopts{'assets-output'}
        if defined $getopts{'assets-output'};
    $args{'assets_base'} = $getopts{'assets-base'}
        if defined $getopts{'assets-base'};
    $args{'status'} = \&status;
    
    return %args;
}

sub status {
    my $text   = shift;
    my $temp   = shift;
    my $colour = shift // '';
    
    if ( !defined $options{'quiet'} ) {
        STDERR->autoflush(1);
        my $output = ( $temp ? "\r" : '' )
                     . $text
                     . ( $temp ? '' : "\n" );
        print STDERR colored( $output, $colour );
    }
}

__END__

=head1 NAME

B<cssprepare> - pre-process CSS style sheet(s)

=head1 SYNOPSIS

B<cssprepare> [B<-afhoqwx>] [B<-b> F<dir>] [B<-d> F<dir>] [B<-l> F<path>]
[B<-s> I<secs>] [B<-t> I<secs>]
           F<style sheet> [F<...>]

B<cssprepare> [B<--long-options ...>] F<style sheet> [F<...>]

=head1 DESCRIPTION

B<cssprepare> concatenates and minifies multiple cascading style sheets into
one, optionally adding new features to the CSS syntax and optimising the
result to save as much space as possible.

=head1 OPTIONS

=over

=item -a, --use-all-shasum

When automatically creating the output style sheet (C<-d>, C<--output-dir>),
use the entire SHA1 checksum as the filename rather than truncating it to five
characters.

=item -b F<dir>, --hierarchy-base=F<dir>

Use F<dir> as the hierarchy base. See L<Using hierarchical CSS> for more details.

=item -d F<dir>, --output-dir=F<dir>

Automatically create a file in F<dir> with the CSS output. This filename will
be based upon the first five characters of the SHA1 checksum of the content.
This means repeated runs of B<cssprepare> on the same files will only generate
one output file, which is useful when using B<cssprepare> as part of a
deployment script. See L<Deploying CSS> for more details.

=item -e, --extended-syntax

Turn on the extra features that cssprepare uses when parsing CSS. See
L<Extending the CSS syntax> for details.

=item -h, --disable-hacks

Turn off support for the "star" and "underscore" CSS hacks, and the "zoom" and
"filter" properties (the most common of the work-arounds needed to deal with
earlier version of Internet Explorer). See L<Supported CSS hacks> for more
details.

=item -l F<dir>, --location=F<dir>

Set the hierarchy location to F<dir>. See L<Using hierarchical CSS> for more
details.

=item -o, --optimise

Attempt to optimise the structure of the CSS before outputting it. B<Warning:>
this can break your CSS. See L<Optimising CSS> for a longer explanation as to
why.

=item --pipe=F<file>

Create the output file F<file> as a named pipe; then enter an infinite loop.
This allows you to use B<cssprepare> as a development environment, changing
source files and seeing that change immediately reflected next time you read
from the named pipe F<file>.

=item --port=I<number>

In conjunction with the C<--server> option, specify on which port the server
should listen. Default is to listen on 5000.

=item -q, --quiet

Silence the status updates sent to STDERR during processing.

=item --server

Runs a local web server (using L<Plack>) to deliver the output of the combined
style sheets, rather than saving it to a file. This allows you to develop
your styles within the context of a web page, and see changes reflected
immediately. Set the style sheet link to point to localhost, like so:

    <link rel="stylesheet" href="http://localhost:5000/">

You can change the port from 5000 with C<--port>.

=item -s I<seconds>, --suboptimal-threshold=I<seconds>

Set the length of time that can pass before cssprepare switches 
optimisation to a faster (but less efficient) method. B<Note:> this applies to
each style sheet, not to the length of time cssprepare will run.

=item -t I<seconds>, --timeout=I<seconds>

Set the length of time that can pass before any HTTP requests will fail
when the remote server does not respond.

=item -w, --warnings-only

Only output warnings and errors found in the processed style sheet(s), and set
the return value of cssprepare to the number of errors. This is useful for
CSS validation.

=item -x, --exit-on-error

Exit before producing any output if there were any errors. This is useful for
prematurely exiting from automatic build scripts, rather than generating
incorrect output.

=back

=head1 REQUIREMENTS

The only fixed requirement CSS::Prepare has is that the version of the perl
interpreter must be at least 5.10.

If you wish to use C<@import url(...);> in your style sheets you will need
one of L<HTTP::Lite> or L<LWP::UserAgent> installed.

Some parts of the extended CSS syntax are implemented as optional plugins. 
For these to work you will need L<Module::Pluggable> installed.

=head1 SEE ALSO

=over

=item *

L<CSS::Prepare::Manual>.

=item *

CSS::Prepare online: L<http://cssprepare.com/>

=item * 

Yahoo! Yslow rules on content delivery networks:
L<http://developer.yahoo.com/performance/rules.html#cdn>.

=back

=head1 AUTHOR

Mark Norman Francis, L<norm@cackhanded.net>.

=head1 COPYRIGHT AND LICENSE

Copyright 2010 Mark Norman Francis.

This library is free software; you can redistribute it and/or modify it
under the same terms as Perl itself.