#!/usr/bin/perl
use strict;
use warnings;
use LWP::UserAgent qw();
use JSON qw();
use DateTime qw();
use DateTime::Format::Strptime qw();
use DateTime::Format::ISO8601 qw();
use Getopt::Long;
use File::Temp qw();
use File::Copy qw();
use Fcntl qw(LOCK_EX LOCK_NB);

#constants;
use constant CHMOD_UMASK => '0777';

use constant BALANCEINTERVALS_MODE => 'balanceintervals';
use constant TOPUPLOG_MODE => 'topuplog';

use constant THIS_WEEK_PERIOD => 'this_week';
use constant TODAY_PERIOD => 'today';
use constant THIS_MONTH_PERIOD => 'this_month';
use constant LAST_DAY_PERIOD => 'last_day';
use constant LAST_WEEK_PERIOD => 'last_week';
use constant LAST_MONTH_PERIOD => 'last_month';

use constant MODE_STRINGS => (BALANCEINTERVALS_MODE,TOPUPLOG_MODE);
use constant PERIOD_STRINGS => (THIS_WEEK_PERIOD,TODAY_PERIOD,THIS_MONTH_PERIOD,LAST_DAY_PERIOD,LAST_WEEK_PERIOD,LAST_MONTH_PERIOD);

#default option values and parameters:
my $host = '127.0.0.1'; #db01a
my $port = 1443;
my $user = 'administrator';
my $pass = 'administrator';
my $output_dir = '/tmp';

my $output_filename;
my $verbose = 0;
my $period = ''; #'topuplog' mode only

my $use_tempfile = 1;
my $print_colnames = 1;
my $linebreak = "\n";
my $col_separator = ";";
my $output_file_suffix = ".txt";

my %row_value_escapes = ( quotemeta($linebreak) => ' ',
                          quotemeta($col_separator) => ' ');

fatal("$0 already running") unless flock DATA, LOCK_EX | LOCK_NB; # not tested on windows yet

#runtime globals:
my ($mode,$ua,$uri) = init();

#run the program:
exit(main());

#subs;
sub init {

    umask oct(CHMOD_UMASK);

    GetOptions ("host=s" => \$host,
                "port=i" => \$port,
                "file=s" => \$output_filename,
                "dir=s"  => \$output_dir,
                "user=s" => \$user,
                "pass=s" => \$pass,
                "period=s" => \$period,
                'verbose+' => \$verbose) or fatal("Error in command line arguments");

    my $mode = shift @ARGV;

    fatal('No mode argument specified, one of [' . join(', ',(MODE_STRINGS)). "] required") unless $mode;

    $output_dir .= '/' if $output_dir && '/' ne substr($output_dir,-1);
    makedir($output_dir) if $output_dir && ! -e $output_dir;
    $output_filename =  "api_dump_" . $mode . "_results_" . datetime_to_string(current_local()) . $output_file_suffix unless $output_filename;
    $output_filename = $output_dir . $output_filename;

    my $uri = 'https://'.$host.':'.$port;
    my ($netloc) = ($uri =~ m!^https?://(.*)/?.*$!);

    my $ua = LWP::UserAgent->new;
    $ua->ssl_opts(
        verify_hostname => 0,
        SSL_verify_mode => 0,
    );
    $ua->credentials($netloc, "api_admin_http", $user, $pass);

    return ($mode,$ua,$uri);

}

sub main {

    if (BALANCEINTERVALS_MODE eq $mode) {

        my @cols = qw/
            subscriber_id
            subscriber_status
            primary_number
            contract_id
            contract_status
            interval_start
            interval_stop
            cash_balance
            notopup_discard_expiry
            package_id
        /;

        my $rowcount = 0;
        my ($fh,$filename) = prepare_file($mode,$output_filename,\@cols);

        process_collection($uri.'/api/subscribers',50,'ngcp:subscribers',sub {
            my ($subscriber,$total_count,$customer_map,$package_map,$intervals_map) = @_;
            my $primary_number = get_primary_number($subscriber);
            log_info("processing subscriber ID $subscriber->{id}: " . $primary_number);
            my ($customer,$interval,$package) = ({},{},{});

            $customer = get_item($subscriber->{_links},'ngcp:customers',$customer_map,$subscriber->{customer_id});
            $package = get_item($subscriber->{_links},'ngcp:profilepackages',$package_map,$customer->{profile_package_id});

            if (exists $intervals_map->{$customer->{id}}) {
                $interval = $intervals_map->{$customer->{id}};
            } else {
                process_collection($uri.'/api/balanceintervals/'.$customer->{id}.'?order_by=end&order_by_direction=desc',
                    1,'ngcp:balanceintervals',sub {
                        my $balance_interval = shift;
                        log_info("processing balance interval: contract ID $subscriber->{customer_id}, " . $balance_interval->{start} . ' - ' . $balance_interval->{stop} );
                        $interval = $balance_interval if defined $balance_interval;
                        return 0;
                    });
                $intervals_map->{$customer->{id}} = $interval;
            }

            my %row = (
                'subscriber_id' => $subscriber->{id},
                'subscriber_status' => $subscriber->{status},
                'primary_number' => $primary_number,
                'contract_id' => $customer->{id},
                'contract_status' => $customer->{status},
                #'has_actual_balance_interval' => 1,
                'interval_start' => $interval->{start},
                'interval_stop' => $interval->{stop},
                'cash_balance' => $interval->{cash_balance},
                'notopup_discard_expiry' => $interval->{notopup_discard_expiry},
                'package_id' => $customer->{profile_package_id},
            );

            $rowcount++;

            log_row($rowcount,$total_count,\%row,\@cols);
            print $fh join($col_separator,map { escape_row_value($_); } @row{@cols}) . $linebreak;

            return 1;
        },3);

        close_file($fh,$filename,$output_filename,$rowcount);

    } elsif (TOPUPLOG_MODE eq $mode) {

        my @cols = qw/
            username
            timestamp
            request_token
            contract_id
            outcome
            message
            type
            voucher_id
            voucher_code
            amount
            cash_balance_before
            cash_balance_after
            lock_level_before
            lock_level_after
            package_before
            package_after
            profile_before
            profile_after
        /;

        my $rowcount = 0;
        my ($fh,$filename) = prepare_file($mode,$output_filename,\@cols);

        my ($from,$to) = get_period_dts();
        my $query_string = (defined $from && defined $to ? '?timestamp_from=' . $from . '&timestamp_to=' . $to : '');

        process_collection($uri.'/api/topuplogs'.$query_string,100,'ngcp:topuplogs',sub {
            my ($topuplog,$total_count,$subscriber_map,$voucher_map,$package_map,$profile_map) = @_;
            log_info("processing topup log entry ID $topuplog->{id}");

            my ($subscriber,$voucher,$package_before,$package_after,$profile_before,$profile_after) = ({},{},{},{},{},{});

            #$subscriber = get_item($topuplog->{_links},'ngcp:subscribers',$subscriber_map,$topuplog->{subscriber_id});
            $voucher = get_item($topuplog->{_links},'ngcp:vouchers',$voucher_map,$topuplog->{voucher_id});
            #$package_before = get_item($topuplog->{_links},'ngcp:profilepackages',$package_map,$topuplog->{package_before_id});
            #$package_after = get_item($topuplog->{_links},'ngcp:profilepackages',$package_map,$topuplog->{package_after_id});
            #$profile_before = get_item($topuplog->{_links},'ngcp:billingprofiles',$profile_map,$topuplog->{profile_before_id});
            #$profile_after = get_item($topuplog->{_links},'ngcp:billingprofiles',$profile_map,$topuplog->{profile_after_id});

            my %row = (
                'username' => $topuplog->{username},
                'timestamp' => $topuplog->{timestamp},
                'request_token' => $topuplog->{request_token},
                #'subscriber_id' => $topuplog->{subscriber_id},
                #'primary_number' => get_primary_number($subscriber),
                'contract_id' => $topuplog->{contract_id},
                'outcome' => $topuplog->{outcome},
                'message' => $topuplog->{message},
                'type' => $topuplog->{type},
                'voucher_id' => $topuplog->{voucher_id},
                'voucher_code' => $voucher->{code},
                'amount' => $topuplog->{amount},
                'cash_balance_after' => $topuplog->{cash_balance_after},
                'cash_balance_before' => $topuplog->{cash_balance_before},
                'lock_level_after' => $topuplog->{lock_level_after},
                'lock_level_before' => $topuplog->{lock_level_before},
                'package_after' => undef, #$package_after->{name},
                'package_before' => undef, #$package_before->{name},
                'profile_after' => undef, #$profile_after->{name},
                'profile_before' => undef, #$profile_before->{name},
            );

            $rowcount++;

            log_row($rowcount,$total_count,\%row,\@cols);
            print $fh join($col_separator,map { escape_row_value($_); } @row{@cols}) . $linebreak;

            return 1;
        },4);

        close_file($fh,$filename,$output_filename,$rowcount);

    } else {
        fatal("Mode argument '$mode' not implemented");
    }

    return 0;
}

sub process_collection {
    my ($url,$page_size,$item_rel,$process_item,$num_of_helper_maps) = @_;
    my $nexturi = URI->new($url);
    $nexturi->query(($nexturi->query() ? $nexturi->query() . '&' : '') . 'page=1&rows='.$page_size);
    do {
        my $collection = get_request($nexturi);
        if($collection->{_links}->{next}->{href}) {
            $nexturi = $uri.$collection->{_links}->{next}->{href};
        } else {
            $nexturi = undef;
        }
        my @helper_maps = ();
        for (my $i = 0; $i < $num_of_helper_maps; $i++) {
            push(@helper_maps,{});
        }
        foreach my $item (@{ $collection->{_embedded}->{$item_rel} }) {
            return unless &$process_item($item,$collection->{total_count},@helper_maps);
        }

    } while($nexturi);
}

sub get_item {

    my ($_links,$item_rel,$map,$id) = @_;

    if (defined $id) {
        if (exists $map->{$id}) {
            return $map->{$id};
        } else {
            #log_info("get profile package ID $customer->{profile_package_id}");
            my $links = $_links->{$item_rel};
            if ('ARRAY' eq ref $links) {
                my %link_map = ();
                foreach my $link (@$links) {
                    next if exists $link_map{$link->{href}};
                    my $item = get_request($uri.$link->{href});
                    $map->{$item->{id}} = $item;
                    $link_map{$link->{href}} = 1;
                }
                return $map->{$id};
            } elsif ('HASH' eq ref $links) {
                my $item = get_request($uri.$links->{href});
                $map->{$id} = $item;
                return $item;
            }
        }
    }

    return {};
}

sub get_request {
    my $url = shift;
    my $req = HTTP::Request->new('GET',$url);
    log_debug("GET $url");
    my $res = $ua->request($req);
    my $result;
    eval {
        $result = JSON::from_json($res->decoded_content);
    };
    if ($@) {
        fatal("Error requesting api: " . $res->code . ' ' . $res->message);
    }
    if ($res->code != 404) {
        fatal("Error requesting api: " . $res->code . ' ' . $result->{message}) if $res->code != 200;
    } else {
        $result = {};
    }
    return $result;
}

sub get_primary_number {
    my $subscriber = shift;
    return ($subscriber->{primary_number} ? $subscriber->{primary_number}->{cc} . ' ' . $subscriber->{primary_number}->{ac} . ' ' . $subscriber->{primary_number}->{sn} : $subscriber->{username} . ($subscriber->{domain} ? '@' . $subscriber->{domain} : ''));
}

sub get_period_dts {
    my ($now,$from,$to) = (current_local(),undef,undef);
    my $label;
    if (THIS_WEEK_PERIOD eq $period) {
        $from = $now->truncate(to => 'week');
        $to = $from->clone->add('weeks' => 1)->subtract(seconds => 1);
        $label = 'this week';
    } elsif (TODAY_PERIOD eq $period) {
        $from = $now->truncate(to => 'day');
        $to = $from->clone->add('days' => 1)->subtract(seconds => 1);
        $label = 'today';
    } elsif (THIS_MONTH_PERIOD eq $period) {
        $from = $now->truncate(to => 'month');
        $to = $from->clone->add('months' => 1)->subtract(seconds => 1);
        $label = 'this month';
    } elsif (LAST_DAY_PERIOD eq $period) {
        $from = $now->subtract('days' => 1)->truncate(to => 'day');
        $to = $from->clone->add('days' => 1)->subtract(seconds => 1);
        $label = 'last day';
    } elsif (LAST_WEEK_PERIOD eq $period) {
        $from = $now->truncate(to => 'week')->subtract(seconds => 1)->truncate(to => 'week');
        $to = $from->clone->add('weeks' => 1)->subtract(seconds => 1);
        $label = 'last week';
    } elsif (LAST_MONTH_PERIOD eq $period) {
        $from = $now->truncate(to => 'month')->subtract(seconds => 1)->truncate(to => 'month');
        $to = $from->clone->add('months' => 1)->subtract(seconds => 1);
        $label = 'last month';
    } else {
        fatal("Unknown period '$period' specified, valid periods are [" . join(', ',(PERIOD_STRINGS)). "]") if $period;
        return ($from,$to);
    }
    log_info($label .': ' . datetime_to_string($from) . ' to ' . datetime_to_string($to));
    return ($from,$to);
}

sub datetime_from_string {
    my $s = shift;
    $s =~ s/^(\d{4}\-\d{2}\-\d{2})\s+(\d.+)$/$1T$2/;
    my $ts = DateTime::Format::ISO8601->parse_datetime($s);
    $ts->set_time_zone( DateTime::TimeZone->new(name => 'local') );
    return $ts;
}

sub datetime_to_string {
    my $dt = shift;
    my $dtf = DateTime::Format::Strptime->new(
        pattern => '%F %T',
    );
    return $dtf->format_datetime($dt);
}

sub current_local {
    return DateTime->now(
        time_zone => DateTime::TimeZone->new(name => 'local')
    );
}

sub prepare_file {
    my ($mode,$output_filename,$cols) = @_;

    my ($fh, $filename) = (undef, undef);
    if ($use_tempfile) {
        log_info("dumping $mode into temporary file ...");
        ($fh, $filename) = File::Temp::tempfile('api_dump_XXXX', UNLINK => 1, TMPDIR => 1, SUFFIX => $output_file_suffix);
        fatal("Could not create temporary file $!") unless $fh;
    } else {
        log_info("dumping $mode into file $output_filename ...");
        open($fh, '>', $output_filename) or fatal("Could not open file '$output_filename' $!");
        $filename = $output_filename;
    }

    if ($print_colnames) {
        print $fh join($col_separator,@$cols) . $linebreak;
    }
    return ($fh,$filename);
}

sub close_file {
    my ($fh,$filename,$output_filename,$rowcount) = @_;
    close $fh;
    if ($use_tempfile) {
        fatal("temp file $filename lost") unless -e $filename;
        log_info("$rowcount rows written to temp file '$filename', moving to output file '$output_filename'");
        # atomic rename if /tmp and output directory reside on same filesystem,
        # to ensure polling via ftp sees completed files only:
        File::Copy::move($filename,$output_filename) or fatal("Error when moving temp file $filename to output file $output_filename $!");
    } else {
        log_info("$rowcount rows written to file '$output_filename'");
    }
    chmod(oct(CHMOD_UMASK),$output_filename);

}

sub makedir {
	my ($dirpath) = @_;
	mkdir $dirpath;
	chmod oct(CHMOD_UMASK),$dirpath;
    log_info("directory '$dirpath' created");
}

sub escape_row_value {
    my $value = shift;
    foreach my $escape (keys %row_value_escapes) {
        $value =~ s/$escape/$row_value_escapes{$escape}/g;
    }
    return $value;
}

sub log_row {
    my ($rowcount,$total_count,$row,$cols) = @_;
    my $label = "writing row $rowcount of $total_count ";
    my $rep = 56;
    log_debug($label . '=' x ($rep - length($label)) . "\n" . join("\n",map { '  ' . $_ . ' = ' . $row->{$_}; } @$cols) . "\n" . '=' x $rep);
}

sub log_info {
    my $msg = shift;
    print $msg . "\n" if $verbose > 0;
}

sub log_debug {
    my $msg = shift;
    print $msg . "\n" if $verbose > 1;
}

sub fatal {
    my $msg = shift;
    die($msg . "\n");
}

__DATA__
This exists to allow the locking code at the beginning of the file to work.
DO NOT REMOVE THESE LINES!
