#!/usr/bin/perl

# This file is part of mpd-hits.
# Copyright (C) 2010, 2011, 2013, 2015 Dmitry Samoyloff.
#
# mpd-hits is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# mpd-hits is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along
# with mpd-hits. If not, see <http://www.gnu.org/licenses/>.

use strict;
use warnings 'all';

use File::Basename 'basename';
use Getopt::Mixed 1.006; # Customization vars first appeared in this version
use Time::Local;
use DateTime::Format::Strptime;
use List::Util 'max';
use Term::ANSIColor qw(:constants);
use Text::Table;

# Forward declarations.
sub back_in_time;

my $progname=basename($0);
my $version='0.3.0'; # sed'ed before release

my $db_regex='([0-9]{4})\.([0-9]{2})'; # Regex for DB file names
my $field_sep=':::'; # Field separator in DB files
my $timeperiod_sep='\.\.'; # Field separator for TIMEPERIOD
my $timespec_delta_min=10; # Time around single TIMESPEC with time specified
my @fields_supported=('day', 'time', 'artist', 'title', # Supported DB fields
                      'album', 'date', 'genre', 'length');
my $strp=new DateTime::Format::Strptime(
    pattern => '%F',
    time_zone => 'local',
    on_error => 'undef',
    );

# Default values, changeable by command line arguments.
my @fields=@fields_supported;
my $sortby='day'; # Sort output by this field
my $time_period='24'; # Show playbacks in last 24 hours by default
my $chart=''; # Don't show the chart by default
my $db_dir="/var/lib/mpd-hits"; # Directory of statistics DB

# Parse command line arguments.
use vars qw($opt_fields $opt_sort $opt_time $opt_chart
            $opt_no_color $opt_db_directory $opt_help $opt_version);
Getopt::Mixed::init('fields=s f>fields sort=s s>sort time=s t>time
                     chart=s c>chart no-color n>no-color
                     db-directory=s d>db-directory
                     help h>help version V>version');
$Getopt::Mixed::badOption=sub { usage(1) };
Getopt::Mixed::getOptions();
usage(1) if scalar @ARGV; # Unknown stuff remained in command line
usage(0) if defined $opt_help; # Show usage info and quit
version() if defined $opt_version; # Show version and quit
@fields=split /,/, $opt_fields if defined $opt_fields;
foreach my $fld (@fields) {
    exitprog(1, "Bad fields specified.")
        unless grep /^$fld$/, @fields_supported;
}
$sortby=$opt_sort if defined $opt_sort;
exitprog(1, qq/Bad --sort value "$sortby". See --help for more info./)
    unless grep /^$sortby$/, @fields;
$time_period=$opt_time if defined $opt_time;
$chart=$opt_chart if defined $opt_chart;
$ENV{ANSI_COLORS_DISABLED}='1' if defined $opt_no_color;
$db_dir=$opt_db_directory if defined $opt_db_directory;

# Check permissions of the db directory.
exitprog(1, "Can't access db directory $db_dir") if not -r $db_dir;

# Get list of DB files.
my @db_files=glob "$db_dir/????.??";
exitprog(1, "DB files not found in $db_dir.") if not scalar @db_files;
@db_files=sort grep /\/$db_regex$/, @db_files;

# Calculate starting and finishing timestamps.
my ($from_epoch, $to_epoch);
if ($time_period eq '24' or
    $time_period eq 'day') {
    ($from_epoch, $to_epoch)=back_in_time(\DateTime::Duration->new(hours=>24));
} elsif ($time_period eq 'week') {
    ($from_epoch, $to_epoch)=back_in_time(\DateTime::Duration->new(weeks=>1));
} elsif ($time_period eq 'month') {
    ($from_epoch, $to_epoch)=back_in_time(\DateTime::Duration->new(months=>1));
} elsif ($time_period eq 'year') {
    ($from_epoch, $to_epoch)=back_in_time(\DateTime::Duration->new(years=>1));
} elsif ($time_period eq 'today') {
    $strp->pattern('%F');
    my $today=$strp->format_datetime(dt_now());
    $from_epoch=timespec2epoch($today, 'from');

    $to_epoch=dt_now()->epoch;
} elsif ($time_period eq 'yesterday') {
    $strp->pattern('%F');

    # Get current time and subtract one day.
    my $dt_to=dt_now();
    $dt_to->subtract_duration(DateTime::Duration->new(days=>1));
    my $to=$strp->format_datetime($dt_to);

    # Get current time and subtract one day.
    my $dt_from=dt_now();
    $dt_from->subtract_duration(DateTime::Duration->new(days=>1));
    my $from=$strp->format_datetime($dt_from);

    # Get "from" and "to" around it
    $from_epoch=timespec2epoch($from, 'from');
    $to_epoch=timespec2epoch($to, 'to');
} elsif ($time_period eq 'this-week') {
    # Get current time.
    $to_epoch=dt_now()->epoch;

    # Get current time and set week day to Monday (or Sunday?).
    my $dt_from=dt_now();
    $dt_from->truncate(to => 'week');
    $strp->pattern('%F');
    my $from=$strp->format_datetime($dt_from);
    $from_epoch=timespec2epoch($from, 'from');
} elsif ($time_period eq 'this-month') {
    # Get current time.
    $to_epoch=dt_now()->epoch;

    # Get current time and set month day to 1st.
    my $dt_from=dt_now();
    $dt_from->truncate(to => 'month');
    $strp->pattern('%F');
    my $from=$strp->format_datetime($dt_from);
    $from_epoch=timespec2epoch($from, 'from');
} elsif ($time_period eq 'this-year') {
    # Get current time.
    $to_epoch=dt_now()->epoch;

    # Get current time and set day to January 1st.
    my $dt_from=dt_now();
    $dt_from->truncate(to => 'year');
    $strp->pattern('%F');
    my $from=$strp->format_datetime($dt_from);
    $from_epoch=timespec2epoch($from, 'from');
} elsif ($time_period eq 'all') {
    $to_epoch=dt_now()->epoch;
    $from_epoch=oldest_epoch();
} else {
    my $seps=0;
    $seps++ while $time_period=~/$timeperiod_sep/g;
    exitprog(1, "Bad TIMEPERIOD.") if $seps>1;

    if ($seps==0) { # Single TIMESPEC
        ($from_epoch, $to_epoch)=timespec2epoch($time_period, 'around');
    } else { # From TIMESPEC to TIMESPEC
        my ($from_str, $to_str)=split /$timeperiod_sep/, $time_period;

        if ($from_str) {
            $from_epoch=timespec2epoch($from_str, 'from');
        } else {
            $from_epoch=oldest_epoch();
        }
        if ($to_str) {
            $to_epoch=timespec2epoch($to_str, 'to');
        } else {
            $to_epoch=dt_now()->epoch;
        }
    }
}

# Decompose starting and finishing timestamps.
my ($from_sec, $from_min, $from_hour, $from_mday, $from_mon, $from_year)=
    epoch2strs($from_epoch);
my ($to_sec, $to_min, $to_hour, $to_mday, $to_mon, $to_year)=
    epoch2strs($to_epoch);
my $period_str;
$period_str.=sprintf('From '.GREEN.'%04d-%02d-%02d %02d:%02d:%02d'.RESET,
                     $from_year, $from_mon, $from_mday,
                     $from_hour, $from_min, $from_sec);
$period_str.=sprintf(' to '.GREEN.'%04d-%02d-%02d %02d:%02d:%02d'.RESET,
                     $to_year, $to_mon, $to_mday,
                     $to_hour, $to_min, $to_sec);
print "\n$period_str\n\n";

# Select DB files from the specified time range.
my @db_files_selected;
unless ($from_epoch<=$to_epoch) {
    exitprog(1, "The starting date must be less or equal to the ending one.");
}
foreach (@db_files) {
    my ($year, $month)=(/$db_regex/);
    next if $year<$from_year or $year>$to_year;
    next if $year==$from_year and $month<$from_mon;
    next if $year==$to_year and $month>$to_mon;
    push @db_files_selected, $_;
}

# Debugging output.
#print "Selected DB files: @db_files_selected\n\n";

# Retrieve info from the selected DB files.
my @songs_played;
my %albums_played;
my %artists_played;
my %songs_played;
my %genres_played;
my $album_last='';
foreach my $file (@db_files_selected) {
    open DBFILE, $file or exitprog(1, "Can't read $file.");
    while (<DBFILE>) {
        chomp;
        next if /^$/; # Skip empty lines.

        # Get song info.
        my %song;
        ($song{'day'}, $song{'time'}, $song{'artist'}, $song{'title'},
         $song{'album'}, $song{'date'}, $song{'genre'}, $song{'length'})=
             split /$field_sep/;

        # Create timestamp for this playback.
        my ($h, $m, $s)=split /:/, $song{'time'};
        my ($y, $n, $d)=split /\./, $song{'day'};
        my $song_epoch=strs2epoch($s, $m, $h, $d, $n, $y);

        # Process the song if it was played during the specified time period.
        if ($song_epoch>=$from_epoch and $song_epoch<=$to_epoch) {
            push @songs_played, \%song;

            my $album=$song{'album'}.$field_sep.$song{'artist'};
            my $sng=$song{'title'}.$field_sep.$song{'artist'};

            $artists_played{$song{'artist'}}++ if $album ne $album_last;
            $albums_played{$album}++ if $album ne $album_last;
            $songs_played{$sng}++;
            $genres_played{$song{'genre'}}++ if $album ne $album_last;

            $album_last=$album;
        }
    }
    close DBFILE;
}

my $table;

if ($chart) {
    # Build the chart.
    if ($chart eq 'artist') {
        print_table_title("Artists sorted by album plays in the time range");
        my @artists_sorted=sort sort_artists_by_plays keys %artists_played;
        my $n=0;
        $table=Text::Table->new(YELLOW.'Artist'.RESET,
                                YELLOW.'Plays'.RESET);
        foreach my $art (@artists_sorted) {
            $table->add($art, RED.$artists_played{$art}.RESET);
        } 
    } elsif ($chart eq 'album') {
        print_table_title("Albums sorted by plays in the time range");
        my @albums_sorted=sort sort_albums_by_plays keys %albums_played;
        $table=Text::Table->new(YELLOW.'Album'.RESET,
                                YELLOW.'Artist'.RESET,
                                YELLOW.'Plays'.RESET);
        foreach my $alb (@albums_sorted) {
            my ($album, $artist)=split /$field_sep/, $alb;
            $table->add($album, $artist, RED.$albums_played{$alb}.RESET);
        }
    } elsif ($chart eq 'title') {
        print_table_title("Songs sorted by plays in the time range");
        my @songs_sorted=sort sort_songs_by_plays keys %songs_played;
        $table=Text::Table->new(YELLOW.'Song'.RESET,
                                YELLOW.'Artist'.RESET,
                                YELLOW.'Plays'.RESET);
        foreach my $sng (@songs_sorted) {
            my ($song, $artist)=split /$field_sep/, $sng;
            $table->add($song, $artist, RED.$songs_played{$sng}.RESET);
        }
    } elsif ($chart eq 'genre') {
        print_table_title("Genres sorted by album plays in the time range");
        my @genres_sorted=sort sort_genres_by_plays keys %genres_played;
        $table=Text::Table->new(YELLOW.'Genre'.RESET,
                                YELLOW.'Plays'.RESET);
        foreach my $genre (@genres_sorted) {
            $table->add($genre, RED.$genres_played{$genre}.RESET);
        }
    } else {
        exitprog(1, qq|Unknown/unimplemented chart type: "$chart"|)
    }
} else {
    print_table_title("Tracks played in the time range, sorted by $sortby");

    # Build the report.
    @songs_played=sort sort_songs @songs_played;
    my @header;
    foreach my $str (@fields) {
        push @header, YELLOW.ucfirst($str).RESET;
    }
    $table=Text::Table->new(@header);
    foreach my $song (@songs_played) {
        my @line;
        foreach my $fld (@fields) {
            if ($fld eq $sortby) {
                push @line, RED.$song->{$fld}.RESET;
            } else {
                push @line, $song->{$fld};
            }
        }
        $table->add(@line);
    }
}

print $table->table."\n";

exit 0;

# Parameters:
#   1. TIMESPEC, as it specified in command line;
#   2. one of:
#      'from':   if time is not specified in TIMESPEC, it will be set to
#                00:00:00; the function will return epoch time;
#      'to':     if time is not specified in TIMESPEC, it will be set to
#                23:59:59; the function will return epoch time;
#      'around': if time is not specified in TIMESPEC, the function will return
#                array of two epoch times, the first one is TIMESPEC with time
#                set to 00:00:00 and the second one is TIMESPEC with time set
#                to 23:59:59;
#                if time *is* specified in TIMESPEC, the function will return
#                array of two epoch times, the first one is
#                TIMESPEC-$timespec_delta_min, the second one is
#                TIMESPEC+$timespec_delta_min.
#
# Return value:
#   Depends on the second parameter: one or two epoch times.
#
sub timespec2epoch {
    my $timespec=shift @_;
    my $hint=shift @_;

    $strp->pattern('%FT%T');
    my $dt=$strp->parse_datetime($timespec);
    if (defined $dt) { # Both date and time specified
        if ($hint eq 'around') {
            $dt->subtract(DateTime::Duration->new(minutes =>
                                                  $timespec_delta_min));
            my $from=$dt->epoch();
            $dt->add(DateTime::Duration->new(minutes =>
                                             $timespec_delta_min*2));
            my $to=$dt->epoch();
            return ($from, $to);
        } elsif ($hint eq 'from' or $hint eq 'to') {
            return $dt->epoch();
        } else {
            exitprog(1, "Internal error: unknown hint '$hint' for ".
                     "timespec2epoch().\n");
        }
    } else {
        $strp->pattern('%F');
        $dt=$strp->parse_datetime($timespec);
        if (defined $dt) { # Only date specified
            if ($hint eq 'around') {
                $dt->set(hour => 0, minute => 0, second => 0);
                my $from=$dt->epoch();
                $dt->set(hour => 23, minute => 59, second => 59);
                my $to=$dt->epoch();
                return ($from, $to);
            } elsif ($hint eq 'from') {
                $dt->set(hour => 0, minute => 0, second => 0);
                return $dt->epoch();
            } elsif ($hint eq 'to') {
                $dt->set(hour => 23, minute => 59, second => 59);
                return $dt->epoch();
            } else {
                exitprog(1, "Internal error: unknown hint '$hint' for ".
                         "timespec2epoch().\n");
            }
        } else {
            exitprog(1, "Failed to parse TIMEPERIOD.");
        }
    }
}

# Creates DateTime object with current time.
sub dt_now {
    return DateTime->now(time_zone => 'local');
}

# Parameter: ref to DateTime::Duration.
# Returns: array of two epoch times, the first one is
# current time - DateTime::Duration, the second one is current time.
sub back_in_time() {
    my $duration=shift @_;

    my $dt_to=dt_now();
    my $dt_from=dt_now();
    $dt_from->subtract_duration($$duration);

    return ($dt_from->epoch(), $dt_to->epoch());
}

sub epoch2strs {
    my $epoch=shift @_;

    my ($sec, $min, $hour, $mday, $mon, $year)=localtime($epoch);
    $mon++;
    $year+=1900;

    return ($sec, $min, $hour, $mday, $mon, $year);
}

sub strs2epoch {
    my ($sec, $min, $hour, $mday, $mon, $year)=@_;

    $mon--;
    $year-=1900;
    my $epoch=timelocal($sec, $min, $hour, $mday, $mon, $year);

    return $epoch;
}

sub oldest_epoch {
    my $oldest_epoch=0;

    my $file=$db_files[0];
    open DBFILE, $file or exitprog(1, "Can't read $file.");
    while (<DBFILE>) {
        chomp;
        next if /^$/; # Skip empty lines.

        # Get song info.
        my %song;
        ($song{'day'}, $song{'time'}, $song{'artist'}, $song{'title'},
         $song{'album'}, $song{'date'}, $song{'genre'}, $song{'length'})=
             split /$field_sep/;

        # Create timestamp for this playback.
        my ($h, $m, $s)=split /:/, $song{'time'};
        my ($y, $n, $d)=split /\./, $song{'day'};
        $oldest_epoch=strs2epoch($s, $m, $h, $d, $n, $y);

        last;
    }

    return $oldest_epoch;
}

sub sort_artists_by_plays {
    if ($artists_played{$b}!=$artists_played{$a}) {
        $artists_played{$b} <=> $artists_played{$a}
    } else {
        $a cmp $b
    }
}

sub sort_albums_by_plays {
    if ($albums_played{$b}!=$albums_played{$a}) {
        $albums_played{$b} <=> $albums_played{$a}
    } else {
        $a cmp $b
    }
}

sub sort_songs_by_plays {
    if ($songs_played{$b}!=$songs_played{$a}) {
        $songs_played{$b} <=> $songs_played{$a}
    } else {
        $a cmp $b
    }
}

sub sort_genres_by_plays {
    if ($genres_played{$b}!=$genres_played{$a}) {
        $genres_played{$b} <=> $genres_played{$a}
    } else {
        $a cmp $b
    }
}

sub sort_songs {
    if ($sortby eq 'length') {
        $a->{$sortby} <=> $b->{$sortby}
    } else {
        $a->{$sortby} cmp $b->{$sortby}
    }
}

sub print_table_title {
    my $title=shift @_;

    print BRIGHT_WHITE, "  ".$title, RESET, "\n\n";
}

sub exitprog {
    my $code=shift @_;
    my $message=shift @_ if defined $_[0];

    print "$message\n" if $message;
    exit $code;
}

sub usage {
    my $code=shift @_;

    my $p_spaces=' ' x length $progname;
    print "\n";
    print "Usage: $progname -c artist | title | album | genre [-t TIMEPERIOD]  \\\n";
    print "       $p_spaces [-n] [-d DBDIR]                                      \n";
    print "\n";
    print "       $progname [-f FIELD[,FIELD]...] [-s FIELD] [-t TIMEPERIOD] \\\n";
    print "       $p_spaces [-n] [-d DBDIR]                                      \n";
    print "\n";
    print "       $progname -V                                                   \n";
    print "\n";
    print "       $progname -h                                                   \n";
    print "\n";
    print "Options:                                     \n";
    print "  -c, --chart=artist | title | album | genre \n";
    print "                                             \n";
    print "  -f, --fields=FIELD[,FIELD]...            \n";
    print "  -s, --sort=FIELD                           \n";
    print "  -t, --time=TIMEPERIOD                      \n";
    print "                                             \n";
    print "  -h, --help                                 \n";
    print "                                             \n";
    print "  -V, --version                              \n";
    print "                                             \n";
    print "  -n, --no-color                             \n";
    print "  -d, --db-directory=DBDIR                   \n";
    print "                                             \n";
    print "FIELD is [ day | time | artist | title | album | date | genre | length ]\n";
    print "\n";
    print "TIMEPERIOD is yyyy-mm-dd[Thh:mm:ss][..yyyy-mm-dd[Thh:mm:ss]]\n";
    print "    (T in between is a separator), or one of:\n";
    print "    [ 24 | day | week | month | year | today | yesterday |\n";
    print "      this-week | this-month | this-year | all ]\n";
    print "\n";

    exitprog($code);
}

sub version {
    exitprog(0, "$progname $version");
}
