
=begin nd
Class: LIMS::Local::Utils
A collection of functions to perform various common and/or repetitive tasks required by other applications eg validation of dates, conversion of dates to other formats, generation of html symbols, trimming white space, etc.
Exports functons into callers namespace using EXPORT_OK.
=cut

package LIMS::Local::Utils;

use strict;
use warnings;

# stopped exporting - methods called explicitly now
# require Exporter;
# our @ISA = qw(Exporter);

use IO::All;
use YAML::Tiny;
use Time::ParseDate;
use Archive::Extract;
use Digest::SHA1 'sha1_base64'; # use Digest::MD5 'md5_hex';
use DateTime::Format::DateParse;
use CGI::Simple::Standard qw( escapeHTML );
use FindBin qw($RealBin); # works for lims_server.pl & hilis3_convert.pl, but not fastcgi
use DateTime; DateTime->DefaultLocale('en_GB'); # set default locale
use Date::Calc qw(Decode_Date_EU Day_of_Week Add_Delta_Days Delta_Days Today
	This_Year Today_and_Now);

use LIMS::Local::Debug;
use Data::Dumper;

# FindBin can't cope with app being called from different locations relative to home dir:
sub find_home {
    my $path_to_app;

    # NB: $INC[0] set by lims_server.pl 'use lib', or mod_perl config:
    foreach (@INC) { # warn 'INC:'. $_ . "\n";
        if (-e "$_/LIMS.pm") { # "$_/lib/LIMS.pm"
            $path_to_app = $_ and last;
        }
    } # warn 'PATH_TO_APP:'. $path_to_app;

#open my $fh, '>' . '/home/raj/www/apps/HMDS/trunk/path.log' or die $!;
#print $fh 'path_to_app: ' . $path_to_app, "\n";
#print $fh 'RealBin: ' . $RealBin, "\n";

    # if $INC[0] set by lims_server.pl, need to strip trailing 3 dirs:
    if ($path_to_app =~ m!(\.\./lib)$!) { # warn 'HERE'; # warn '$INC[0] set by lims_server.pl';
        $path_to_app =~ s/(\/\w+\/$1)$//; # eg /path/to/app/script/../lib -> /path/to/app
    }
    # if $INC[0] set by mod_perl, need to remove trailing /lib:
	# CHANGED 'elsif' to 'if' IN CASE TRAILING '/lib' STILL INTACT (eg called from cron):
    if ($path_to_app =~ m!(/?lib)$!) { # warn 'HERE'; # warn '$INC[0] set by mod_perl';
        $path_to_app =~ s/($1)$//; # eg /path/to/app/lib -> /path/to/app
    }

	# fix for Win32 - returns empty $path_to_app otherwise
	$path_to_app ||= '.'; # warn 'PATH_TO_APP:'. $path_to_app;

    return $path_to_app;
}

=begin nd
Function: check_date
Evaluates string with Date::Calc::Decode_Date_EU. Catalyst::Controller::FormBuilder::validate() require a true value for a valid field, so returns true ( 1 ) if date is decipherable and not in future, otherwise returns 0
=cut

sub check_date {
	my $date = shift; # DEBUG('DATE:', $date);

    # first check we have 4-digit year:
    return 0 unless $date =~ /\W\d{4}$/; # check for non-word character (dot, hyphen, space, etc) followed by 4 digits

    my $date_is_valid; # becomes true if Decode_Date_EU can decode date (as scalar takes last output from function = day)

    # wrap it in eval so we don't die if date IS invalid:
	eval { $date_is_valid = Decode_Date_EU($date) }; # DEBUG('date_is_valid:', $date_is_valid);

    # if date decoded OK, check it for future, otherwise return 0:
    $date_is_valid || return 0; # DEBUG ('date confirmed valid');

    # returns true if date >= Today():
	my $date_is_future = _date_is_future($date); # warn "date_is_future: $date_is_future";

    # need to return true value if date is valid (no error and date not in future):
	return $date_is_future ? 0 : 1;
}

=begin
Function: check_date
Evaluates string with Time::ParseDate::parsedate(). Converts a valid (UK-style) date to number of seconds since the epoch (1970-01-01). If called in array context, parsedate() returns an error element which contains the error string if date invalid. Catalyst::Controller::FormBuilder::validate() require a true value for a valid field so cannot return the error code. Method returns true (1) if date is valid, otherwise 0. Only reliable for 4-digit years between 1902 and 2038 (not at all for 2-digit years due to assumptions).
  Using Decode_Date_EU method instead
=cut

sub _check_date {
	my $date = shift;


# variable: to_seconds
# number of seconds since the epoch (1970-01-01) if date is valid
# variable: $error
# contains error message if date is invalid, otherwise undef
# VALIDATE - force validation; PREFER_PAST -  assume past when year or day of week is ambiguous (allows dd-mm-yy when yy >= current yy)
    my ( $to_seconds, $error ) =
      Time::ParseDate::parsedate( $date, UK => 1, VALIDATE => 1 ); #, PREFER_PAST => 1 - best to have unambiguous dates

    # returns true if date >= Today():
    my $date_is_future = _date_is_future($date); # DEBUG ("date_is_future: $date_is_future");

    # need to return true value if date is valid (no error and date not in future):
    return ( $error || $date_is_future ) ? 0 : 1;
}

=begin nd
Function: date_to_mysql
Converts an EU formatted date to MySQL format (yyyy-d-m) if date is decipherable by Decode_Date_EU, or undef if not.
Need to wrap in eval and return undef if @!
=cut

sub date_to_mysql { # could use MySQL::DateFormat->toMySQL, but requires '-' or '/' separators
	  my $date = shift || return;

	  return join '-', Decode_Date_EU($date);
}

=begin nd
Function: to_datetime
Converts hashref containing year, month & day values to a DateTime object ( eg 1923-02-01T00:00:00') using DateTime.
Assumes date has been validated with Decode_Date_EU. Gives correct DateTime->ymd for all 4-digit years.
=cut

sub to_datetime {
    my $data = shift; # hashref

    # return safely if no date params passed:
    return unless grep $data->{$_}, qw( day month year );

    # check all required values present:
    map {
        $data->{$_} or die "No $_ value passed to LIMS::Local::Utils::to_datatime() function";
    } qw( day month year );

    return DateTime->new( year => $data->{year}, month => $data->{month}, day => $data->{day} );
}

=begin nd
Function: to_datetime_using_datecalc
Converts an EU formatted date to a DateTime object ( eg 1923-02-01T00:00:00') using DateTime. Assumes date has been
validated with Decode_Date_EU. Gives correct DateTime->ymd for all 4-digit years.
=cut

sub to_datetime_using_datecalc { # uses Decode_Date_EU & DateTime
	my $date = shift || return;

    my ( $yr, $month, $day ) = Decode_Date_EU($date);
    
    # eval to protect DT object creation if Decode_Date_EU fails (eg FV_and() in validation):
    eval { return DateTime->new( year => $yr, month => $month, day => $day ) };
}

=begin nd
Function: to_datetime_using_parsedate
Converts an EU formatted date to a DateTime object (eg 1983-02-01T00:00:00') using Time::ParseDate() and DateTime::from_epoch().
Can handle abbreviated month names (Jan Feb) and numerical months ( 1 - 12 ). But parsedate can only handle years ranging 1902 to 2038.
Two-digit years unreliable due to assumptions - DateTime->ymd switches from 2000's to 1900's after 38 (ie 38 -> 2038, 39 -> 1902!)
Need to specify time_zone either in parsedate() or from_epoch() methods or dates during BST are ajusted -1hr causing date shift, eg:
13.4.1947 becomes 1947-04-12T23:00:00
=cut

sub to_datetime_using_parsedate { # uses Time::ParseDate
	my $date = shift || return;

    # dump all time_zones to file:
    # my @zones = DateTime::TimeZone->all_names; DEBUG @zones;

    # need to specify time_zone here or in from_epoch() or summer dates adjusted -1hr causing date shift:
    my ( $to_seconds, $error ) =
      Time::ParseDate::parsedate( $date, UK => 1, GMT => 1 );

    # this should not happen as date already validated:
    die $error if $error;

    # need to specify time_zone here or in parsedate(), or summer dates adjusted -1hr causing date shift:
    return DateTime->from_epoch( epoch => $to_seconds, );    # time_zone => 'Europe/London'
}

=begin nd

Function: to_datetime_using_strptime
Converts a date to a DateTime object using DateTime::Format::Strptime. Can handle dates
before epoch (1/1/1970), but requires dates to match a specified pattern, eg %d\W%m\W%Y.
Expects pattern supplied.
=cut

sub to_datetime_using_strptime {    # uses DateTime::Format::Strptime
    my $date    = shift || return;
	my $pattern = shift; # requires pattern or will die trying to parse date

    my %config = (
        pattern   => $pattern,
        locale    => 'en_GB',
        time_zone => 'GMT',
        on_error  => 'croak',
    );

    my $dt = new DateTime::Format::Strptime(%config);

    return $dt->parse_datetime($date);
}

sub to_datetime_using_dateparse { # uses DateTime::Format::DateParse
    my $date = shift || return;

    my $dt = DateTime::Format::DateParse->parse_datetime($date);
    return $dt;
}

=begin nd
Function: split_labno
Extracts and returns numerator & 4-digit yr from Lab Number
=cut

sub split_labno {
    my $labno = shift or return;

    my ( $numerator, $yr ) = $labno =~ m!^[A-Z]?(\d+)/?(\d{2})?$!i;

    $yr ||= ( localtime() )[5] - 100;    # assume current year if null

    return ( $numerator, $yr + 2000 );
}

=begin nd
Function: pretty_nhs_no
Splits and returns NHS number in ddd ddd dddd format
=cut

sub pretty_nhs_no {
    local $_ = shift;

    my (@nhsno) = /(\d{3})(\d{3})(\d{4})/;    # DEBUG (@nhsno);

    return sprintf '%s %s %s', @nhsno;
}

=begin nd
Function: sha1_digest
Returns encrypted string:
=cut

sub sha1_digest {
	my $str = shift; # warn $str;

    return Digest::SHA1::sha1_base64($str); # Digest::MD5::md5_hex($str)
}

=begin nd
Function: check_nhsno
Validates NHS number using the Modulus 11 algorithm. The tenth digit of an NHS number is a check digit.
+ multiply each of the first nine digits by a weighting factor 1 -> 10, 2 -> 9, 3 -> 8 .. 9 -> 2
+ add the results of each multiplication together
+ divide the total by 11 and establish the remainder
+ subtract the remainder from 11 to give the expected value of the check digit
+ if remainder is less than 10 AND matches the check digit then the submitted NHS number is VALID

Function returns 1 if valid, and undef if invalid or not 10-digits.

See also: http://www.govtalk.gov.uk/gdsc/html/frames/NHSnumber-2-0-Release.htm
=cut

sub check_nhsno {
    my $nhsno = shift;    # _dump(Dumper $nhsno);

    $nhsno =~
      s/\s+//g;    # remove spaces if nhs_no submitted in nnn nnn nnnn format

    return unless length $nhsno == 10;    # require 10 consecutive numbers only

    my $product;

    # sum of (each of the first nine digits * weighting factor):
    for ( 0 .. 8 ) {                      # 1st 9 digits of NHS no
        $product += ( substr( $nhsno, $_, 1 ) * ( 10 - $_ ) );
    }

# Divide $product by 11 and establish the remainder.
# Subtract the remainder from 11 to give
# the check digit. If the result is 11 then a check digit of 0 is used.
    my $check_digit = $product % 11
      ?                                   # if $product / 11 is non-zero:
      11 - ( $product % 11 )
      :     # $check_digit = 11 minus remainder of $product / 11
      0;    # $check_digit = zero

    # returns true (valid) if:
    return $check_digit ==
      substr( $nhsno, 9, 1 )    # $check_digit matches the 10th digit, and
      && $check_digit != 10;    # invalid if $check_digit == 10
}

sub sig_figs { # from http://www.indo.com/distance/dist.pl sub round_to_3():
	local $_ = shift; # value to round
	
	my $lg = int( log(abs($_)) / log(10.0) );	# log base 10 of num
	
	my $round = 10 ** ($lg - 2); # DEBUG($lg,$round);

	return int($_ / $round + 0.5) * $round;
}

# requires dob & ref_date as dt objects:
sub calculate_age {
	my ($dob, $ref_date) = @_; # warn Dumper [$dob, $ref_date];
	
	return 0 unless $dob && $ref_date # also must be DT objects:
		&& ref $dob eq 'DateTime' && ref $ref_date eq 'DateTime';
	
	# my $age = $ref_date->year - $dob->year - ( $dob->day_of_year > $ref_date->day_of_year );
	my $age = ($ref_date - $dob)->in_units('years'); # warn $age; # DateTime::Duration function
	return $age;
}

# convert stones & pounds to kg:
sub convert_imperial { 
	my $data = shift || return; 

    my $kg =
        ( $data->{imperial_stones} * 6.35029318 )
        + ( $data->{imperial_pounds} * 0.45359237 );
	return sprintf '%.1f', $kg;
}

# compares 2 dates using DateTime->compare, returns '-1' if chronological order,
# 0 (empty) if dates the same, '1' if 1st date > 2nd date
sub check_chronological_order {
	my ($first_date, $last_date) = @_; # warn Dumper [$first_date, $last_date];
	
	return 0 unless $first_date && $last_date # also must be DT objects:
		&& ref $first_date eq 'DateTime' && ref $last_date eq 'DateTime';
    
    my $cmp = DateTime->compare($first_date, $last_date);
}

# shared by ReportUpdate::do_request_report() & incomplete_requests.pl cron
sub diagnosis_confirmation_required {
	my $args = shift; # expect keys = screen, specimen, lab_tests, lab_sections
	
=begin # a "final_diagnosis" confirmation IS required if:
	1) request.status = 'authorised' (already been tested for)
	2) no outstanding tests
	3) has a cytogenetics, molecular or FISH results summary
	4) has not been screened as:
		Molecular miscellaneous
		Chimerism sample
		PNH
		Rheumatoid arthritis
		CML follow-up (post-BMT, imatinib, interferon, STI) on PB sample
		CMPD pres & follow-up on PB sample with JAK2 as sole test
=cut

	my $src = find_home() . "/config/.local/diagnosis_confirm.yml";
	return 0 unless (-e $src);  # eg function not configured
	my $yaml = YAML::Tiny->read($src)->[0]; # warn Dumper $yaml;

	my $specimen = $args->{specimen}; # array(ref) of sample_codes
	my $lab_test = $args->{lab_test}; # AoH (keys = test_name & status)
	my $section  = $args->{section};  # array(ref) of lab_section names
	my $screen   = $args->{screen};   # str
	
	# get list of lab_test names:
	my @lab_tests = map $_->{test_name}, @$lab_test; # warn Dumper \@lab_tests;

	{ # exempted screens with any sample type:
		# NB - already checked in incomplete_requests.pl
		my $exempt_screens = $yaml->{exempt_all_sample_types}; # arrayref
		return 0 if grep $screen eq $_, @$exempt_screens;		
	} # warn 'here';
	
	{ # exempted screens with specific sample type:
		my $data = $yaml->{exempt_if_sample_type}; # hashref
		while ( my($exempt_screen, $exempt_specimen) = each %$data ) {
			return 0 if $screen eq $exempt_screen
				&& lc(join '', @$specimen) eq lc $exempt_specimen;
		}
	} # warn 'here';
	
	{ # # exempted screens with specific sample type and lab test:
		my $data = $yaml->{exempt_if_sample_type_and_lab_test}; # hashref
		while ( my($exempt_screen, $d) = each %$data ) {
			return 0 if
				$screen eq $exempt_screen &&
				lc(join '', @$specimen) eq $d->{sample} &&
				lc(join '', @lab_tests) eq $d->{test_name};
		}
	} # warn 'here';
	
	{ # require result_summary from molecular, cytogenetics or FISH sections:
		# NB - already checked in incomplete_requests.pl
		my $lab_sections = $yaml->{lab_sections}; # arrayref
		my %map = map { $_ => 1 } @$lab_sections; # create hash from array(ref)
		return 0 unless grep $map{$_}, @$section;
	} # warn 'here';
	
	{ # require all lab_test status = complete:
		# NB - already checked in _diagnosis_confirmation_required()
		my @status = map $_->{status}, @$lab_test; # warn Dumper \@status;
		return 0 if grep $_ ne 'complete', @status;		
	} # warn 'here';
	
	# OK, not an exempt initial_screen/specimen combination, do have necessary
	# result summary & all lab_tests complete so DO need confirmation:
	return 1;	
}

# return postcode in WWd(d) dWW format (or 0 if invalid):
sub format_postcode {
	my $post_code = shift || return 0;

	$post_code =~ s/\s//g; # remove all spaces
    $post_code = uc $post_code; # lower case
    
    # see also http://github.com/JeremyJones/Regexp--Common/tree/master/lib/
    my $regex = qr(^([A-Z]{2}[0-9]{1,2})([0-9]{1}[A-Z]{2})$);
    # <http://www.wellho.net/resources/ex.php4?item=q803/pcode>:
    # my $regex = qr(^(([A-Z]{1,2})\d[0-9A-Z]?) +(\d[A-Z]{2})$);
    
    # put space back 4th from end
	my $formatted_postcode = join ' ', $1, $2 if $post_code =~ $regex;

	return $formatted_postcode || 0; # for validator
}

sub reformat_address {
	my $addr = shift || return 0;
	
	my @lines = split /[\,\n]+/, $addr; # split on new-line, comma or both 
  
	my @new = map { # doesn't deal with inconsistent use of comma between house number & street name
		# capitalise 1st letter of each word in @lines:
		join ' ', grep { s/(.*)/ucfirst( lc($1) )/e } split ' ', $_; 
	} @lines;

	return join ', ', grep $_, @new; # reform with comma-space field delimiters 
}

# extracts zip contents & returns a contents table
# expects upload dir & <file name>.zip
sub unzip_file {
	my $args = shift; # warn Dumper $args;
	
	my $target_dir = $args->{target_dir}; # unzip into upload dir
	my $filename   = $args->{filename}; # ie <file name>.zip	
	
	$filename =~ s/\s/_/g; # substitute spaces

	my $src_file = join '/', $target_dir, $filename;
	
	my $unzip = '/usr/bin/unzip';
    my @zip_contents = `$unzip -l $src_file`; # get list of files in zip for display
	
    # -a to convert DOS txt file line-endings 
    # -b to convert all files as binary
    # -j to ignore directory structure
    # -o to overwrite
    # -d or tries & fails to unzip to cgi-bin
	
	# problem with /usr/bin/unzip extracting some binaries as text
#    `$unzip -ajo $src_file -d $target_dir` || die "Problem: ", $? >> 8, "\n";

	my $archive = Archive::Extract->new(archive => $src_file);
    my $ok = $archive->extract(to => $target_dir) or die $archive->error;
	
	return \@zip_contents;
}

=begin nd
Function: delta_business_days
Returns number of business days between two dates (submitted format??).
See Date::Calc - How can I calculate the difference in days between dates, etc
=cut

sub delta_business_days { # Date::Calc - How can I calculate the difference in days between dates, etc
    my ( $d0, $d1 ) = @_;

    my @d0 = split '-', $d0;    # DEBUG (\@d0);
    my @d1 = split '-', $d1;    # DEBUG (\@d1);

    my ( $dow1, $dow2, $diff, $temp );

    my $minus = 0;
    my $result = Delta_Days( @d0, @d1 );

    if ( $result != 0 ) {
        if ( $result < 0 ) {
            $minus  = 1;
            $result = -$result;
            $dow1   = Day_of_Week(@d1);
            $dow2   = Day_of_Week(@d0);
        }
        else {
            $dow1 = Day_of_Week(@d0);
            $dow2 = Day_of_Week(@d1);
        }
        $diff = $dow2 - $dow1;
        $temp = $result;
        if ( $diff != 0 ) {
            if ( $diff < 0 ) {
                $diff += 7;
            }
            $temp -= $diff;
            $dow1 += $diff;
            if ( $dow1 > 6 ) {
                $result--;
                if ( $dow1 > 7 ) {
                    $result--;
                }
            }
        }
        if ( $temp != 0 ) {
            $temp /= 7;
            $result -= ( $temp << 1 );
        }
    }

    if   ($minus) { return -$result; }
    else          { return $result; }
}

sub delta_last_working_day {
    my $delta = '-1';

    until ( Day_of_Week( Add_Delta_Days( Today(), $delta ) ) < 6 ) {
        $delta--;
    }

    return $delta;
}

sub last_working_day { # receives value calculated in delta_last_working_day(), returns date in EU format
    my $delta = shift;

    return join '-',
      reverse Add_Delta_Days( Today(), $delta )
      ;  # $delta always negative so use Add_Delta_Days
}

sub symbolise {    # transform eg 10/9/l & /ul
    my $string = shift || return; # warn $string;

    # first escapeHTML:
    my $formatted = escapeHTML($string); # warn $estring;

    $formatted =~ s/(\d)\/ul/$1\/&micro;L/ig;    # /ul
    $formatted =~ s/10\/(6|9)\/(l|kg)/10<sup>$1<\/sup>\/$2/ig; # 10/9/l; 10/6/kg; etc
    $formatted =~ s/10\^(\d)/10<sup>$1<\/sup>/g;         # 10^6; 10^9; etc
	
    # return escapeHTML'd string:
    return $formatted;
}

sub trim {    # remove leading & trailing spaces
    local $_ = shift;

    s/^\s+//;
    s/\s+$//;

    return $_;
}

sub this_year {
    return This_Year;
}

sub time_now {
    return DateTime->now(time_zone => 'Europe/London');
}

sub today {
  return DateTime->today;
}

# converts 4-digit year to date expression hashref useable by MySQL & SQLite:
sub year_to_date_expression {
    my $year = shift || return;

    my $first_day_of_year = DateTime->new(year => $year, month =>  1, day =>  1)->ymd;
    my $last_day_of_year  = DateTime->new(year => $year, month => 12, day => 31)->ymd;

    return { '-between' => [ $first_day_of_year, $last_day_of_year ] };
}

=begin nd
Function: _date_is_future
Expression Delta_Days( @test_date, @today ) evaluates to positive if 2 dates in chronological order, zero if both dates the same,
and negative if first date AFTER second. Function returns true if Delta_Days() evalutes to negative. Uses Date:Calc which can handle dates
> 19/1/2038. Time::ParseDate does not work for dates > 19/1/2038 (UNIX epoch date).
=cut

sub _date_is_future {
    my $date = shift;    # from check_date()

    my @decoded_date = Decode_Date_EU($date);    # DEBUG (@decoded_date);

    return 1
      unless @decoded_date
    ;    # or fatals in Delta_Days() if @decoded_date undef due to invalid date

# return value of expression 'Delta_Days() < 0' - ie true if Delta_Days is negative:
    return Delta_Days( @decoded_date, Today() ) < 0;
}

=begin # would work except parse_datetime() assumes eg 21.2.08 = American date - need to supply 21.Feb.08 for EU dates
sub _date_is_future {
    my $date = shift;

    my $date  = DateTime::Format::DateParse->parse_datetime( $date ); DEBUG( $date);
    my $today = DateTime->today(); DEBUG $today;

    my $delta = DateTime->compare($date, $today); DEBUG "delta:$delta";
}
=cut

=begin # returns true if date > today # NB Time::ParseDate DOES NOT WORK FOR DATES > 19.1.2038 !!
sub _date_is_future {
	my $seconds_to_date = shift || return; # $to_seconds from Time::ParseDate::parsedate($date)

	my $seconds_to_now = Time::ParseDate::parsedate("today"); # DEBUG("seconds_to_now:$seconds_to_now");

	# $delta will be positive if $date before or equal to today, and negative if after today:
	my $delta = $seconds_to_now - $seconds_to_date;  DEBUG("delta: $seconds_to_now minus $seconds_to_date");

	# return true if $delta < 0:
	return $delta < 0 ? 1 : 0;
}
=cut

1;
