#!/usr/bin/perl
# ----------------------------------------------------------------------------
# This software is Copyright (C) 2007, All Rights Reserved
# ----------------------------------------------------------------------------
# This program is protected by local and international copyright laws. Any
# use of this program is subject to the the terms of the license agreement
# included as part of this distribution archive. Any other uses are strictly
# prohibited without the written permission of the Vendor and all
# other rights are reserved.
# -----------------------------------------------------------------------------
# Warning : Modifying this program violates your license agreement!
# -----------------------------------------------------------------------------

package IT::DB::WhereParser;

use strict;

use IT::Globals qw($FORM);
use Time::Local;

# module globals
use vars qw($ORIGINAL_STRING $REMAINING_STRING $TABLE_NAME $FOUND_NULL $HIDE_UNKNOWN_FIELD_ERRORS);
use vars qw($CURRENT_SEC $CURRENT_MIN $CURRENT_HOUR $CURRENT_DAY $CURRENT_MON $CURRENT_YEAR);
($CURRENT_SEC, $CURRENT_MIN, $CURRENT_HOUR, $CURRENT_DAY, $CURRENT_MON, $CURRENT_YEAR) = gmtime($^T);




# -----------------------------------------------------------------------------
# Function    : createWhereTree
# Description : Parse a string into an operation tree, honouring parenthesis
#               and assumed precedence (e.g. NOT binds tighter than AND,
#               which binds tighter than OR).  This process is accomplished
#               using "Recursive Descent Parsing", and the "grammer" for our
#               parser is described below.
# Note        : This routine also validates criteria, making sure operations
#               are allowed and database-layer rules are respected.
# Grammar     : Expression       : LooseExpression [ Expression ] ( continue to recurse only if last unary != <null>)
#               LooseExpression  : TightExpression [ "or" LooseExpression ]
#               TightExpression  : UnaryExpression [ "and" TightExpression ]
#               UnaryExpression  : "not" UnaryExpression | "(" Expression ")" | Criteria | <null>
#               Criteria         : FieldName /=|<=|>=|<|>|!=|LIKE|NOT LIKE/ Value
#                                  | ["NOT "] "BETWEEN (" Value "," Value ")"
#                                  | ["NOT "] "IN (" ListOfValues ")"
#               ListOfValues     : Value [ "," ListOfValues ]
#               FieldName        : Value but can't be AND|OR
#               Value            : quoted string | bareword | Function
#               Function         : /DATE|TODAY/ + "(" ListOfValues ")"
# Usage       : my $whereString = 'field > 6 and not (field like "%this%" or field in (1,2,3))';
#               my $whereTree   = &IT::DB::WhereParser::createWhereTree($tableName, $whereString);
#
# NOTES:      All string comparisons are case-INsensitive
#             LIKE and NOT LIKE allows wildcard chars % and _ (= and other operators DO NOT)
#             If you want to search for the literal char % _ or \ you need to escape it with \ in LIKE queries - use &IT::DB::escapeWildcardChars();
#             To sanitize user input backslash the following: \ ' and " with &IT::DB::getEscapedValue();
# Future:     Have Where parser return un-escaped values so we can escape them appropriately for each datasource engine
# -----------------------------------------------------------------------------

sub createWhereTree {
  my $tableName           = shift;
  my $whereString         = shift;
  my $ignoreUnknownFields = shift;

  # error checking
  &IT::Error::dieOnValidationErrors( ["\@_",                   \@_,                  "noArgsLeft"],
                          ["\$tableName",           $tableName,           "notBlank notRef"],
                          ["\$whereString",         $whereString,         "allowBlank notRef"],
                          ["\$ignoreUnknownFields", $ignoreUnknownFields, "allowUndef"] );

  # error checking
  if ($ignoreUnknownFields ne '' && $ignoreUnknownFields ne 'HIDE_UNKNOWN_FIELD_ERRORS') {
    die "\$ignoreUnknownFields must be either blank or 'HIDE_UNKNOWN_FIELD_ERRORS', not '$ignoreUnknownFields'!\n";
  }

  # set default values
  unless (defined $whereString) { $whereString = ''; }

  ### Reset Globals
  $TABLE_NAME              = $tableName;
  $FOUND_NULL              = 0;
  $REMAINING_STRING        = $whereString;       # copy this so we can deconstruct it
  $ORIGINAL_STRING         = $whereString;       # original for _parseError()
  $HIDE_UNKNOWN_FIELD_ERRORS = $ignoreUnknownFields;

  # parse whereString
  my $whereTree = &_parseExpression();
  &_cleanTree($whereTree);                # Remove 'NULL' criteria, collapse boolean nodes with 'NULL' children
  if ($REMAINING_STRING =~ /\S/) {        # If there's still stuff waiting to be parsed, syntax error
    &_parseError("expecting end-of-condition or a recognized token");
  }

  return $whereTree;

}

# -----------------------------------------------------------------------------
# Function    : _parseExpression
# Description : Called by &createWhereTree(), &_parseUnaryExpression() and itself
# Usage       : my $expr = &_parseExpression();
# -----------------------------------------------------------------------------

sub _parseExpression {

  my $expr = &_parseLooseExpression();
  if ($REMAINING_STRING !~ /^\s*$/ && $FOUND_NULL == 0) {
    my $expr2 = &_parseExpression();

    return [ 'AND', $expr, $expr2 ];

  }

  return $expr;

}

# -----------------------------------------------------------------------------
# Function    : _parseLooseExpression
# Description : Called by &_parseExpression() and itself
# Usage       : my $expr = &_parseLooseExpression();
# -----------------------------------------------------------------------------

sub _parseLooseExpression {

  my $term1 = &_parseTightExpression();
  if ($REMAINING_STRING =~ s/^\s*OR\b//i) {
    my $term2 = &_parseLooseExpression();

    return [ 'OR', $term1, $term2 ];

  }

  return $term1;

}

# -----------------------------------------------------------------------------
# Function    : _parseTightExpression
# Description : Called by &_parseLooseExpression & itself
# Usage       : my $term = &_parseTightExpression
# -----------------------------------------------------------------------------

sub _parseTightExpression {

  my $term1 = &_parseUnaryExpression();
  if ($REMAINING_STRING =~ s/^\s*AND\b//i) {
    my $term2 = &_parseTightExpression();

    return [ 'AND', $term1, $term2 ];

  }

  return $term1;

}

# -----------------------------------------------------------------------------
# Function    : _parseUnaryExpression
# Description : Called by &_parseTightExpression() and itself
# Usage       : my $term = &_parseUnaryExpression();
# -----------------------------------------------------------------------------

sub _parseUnaryExpression {

  if ($REMAINING_STRING =~ s/^\s*\(//i) {
    my $expression = &_parseExpression();
    unless ($REMAINING_STRING =~ s/^\s*\)//i) {
      &_parseError("no closing parenthesis (\")\")");
    }
    $FOUND_NULL = 0;

    return $expression;

  }
  elsif ($REMAINING_STRING =~ s/^\s*NOT\b//i) {
    $FOUND_NULL = 0;

    return [ 'NOT', &_parseUnaryExpression() ];

  }
  else {
    $FOUND_NULL = 0;

    return &_parseCriteria();

  }
}

# -----------------------------------------------------------------------------
# Function    : _parseCriteria
# Description : Called by &_parseUnaryExpression
# Usage       : my $requirements = &_parseCriteria();
# -----------------------------------------------------------------------------

sub _parseCriteria {

  my $prefixedFieldname      = &_parseFieldname();
  if ($prefixedFieldname eq '') { $FOUND_NULL = 1; return ['NULL']; }

  # get tablename, fieldname and isFieldNumeric
  my($tableName, $fieldName) = eval { &IT::DB::Common::getOriginalTableAndFieldname($TABLE_NAME, $prefixedFieldname); };
  my $isUnknownField         = ($@ || !$fieldName);
  if ($@ && !$HIDE_UNKNOWN_FIELD_ERRORS) { die $@; }

  if (!$HIDE_UNKNOWN_FIELD_ERRORS && !&IT::DB::Schema::existsField($tableName, $fieldName)) { &_parseError("unknown field '$prefixedFieldname'");
  }
  my $isFieldNumeric = eval { &IT::DB::Common::isFieldNumeric($tableName, $fieldName); };
  if ($@ && !$HIDE_UNKNOWN_FIELD_ERRORS) { die $@; }


  my $operator = uc &_parseOperator();
  my @criteria;

  ### Operator rules (field type rules)
  if ($operator eq 'LIKE'         or $operator eq 'NOT LIKE' or
      $operator eq 'CONTAINS ANY' or $operator eq 'CONTAINS ALL') {

    # FUTURE RESTRICTION #    if ($field_data->{'type'} eq 'memo') { &_parseError("cannot use wildcard operator '$operator' on memo field '$field_name'"); }
    if ($isFieldNumeric) {
      &_parseError("cannot use wildcard operator '$operator' on numeric field '$prefixedFieldname'");
    }

  }
  elsif ($operator eq '<'       or $operator eq '>'   or
         $operator eq '<='      or $operator eq '>='  or
         $operator eq 'BETWEEN' or $operator eq 'NOT BETWEEN') {

    if (!$isFieldNumeric) {
      &_parseError("cannot use numeric comparison operator '$operator' on non-numeric field '$prefixedFieldname'");
    }
  }
  elsif ($operator eq '='   or $operator eq '!=' or
         $operator eq 'IN'  or $operator eq 'NOT IN' or
         $operator eq '=F') {
    ; # no restrictions
  }
  else {
    &_parseError("unknown operator '$operator'");
  }

  ### Get values and return test
  if ($operator eq 'BETWEEN' or $operator eq 'NOT BETWEEN') {
    $REMAINING_STRING =~ s/^\s*\(//i || &_parseError("expecting '(' after BETWEEN operator");
    my ($min, $max)   =  &_parseFunctionArguments('BETWEEN', 0, 2);

    if (defined $min && $min ne int $min) { &_parseError("cannot use non-numeric value '$min' with between operator '$operator'"); }
    if (defined $max && $max ne int $max) { &_parseError("cannot use non-numeric value '$max' with between operator '$operator'"); }

    my $test;
    if    (defined $min and defined $max) { $test = [ $operator, [ $min, $max ] ]; }
    elsif (defined $min)                  { $test = [ (($operator =~ /NOT/) ? '<' : '>='), $min ]; }
    elsif (defined $max)                  { $test = [ (($operator =~ /NOT/) ? '>' : '<='), $max ]; }
    else                                  { return ['NULL']; }

    return [ 'TEST', $prefixedFieldname, @$test];

  }
  elsif ($operator eq 'IN'     or $operator eq 'NOT IN' or
         $operator eq 'ONE OF' or $operator eq 'NOT ONE OF') {
    my @values;
    if ($REMAINING_STRING =~ s/^\s*\(//i) {
      while (1) {
        if ($REMAINING_STRING =~ s/^\s*\)//) { last; }
        if ($REMAINING_STRING =~ s/^\s*,//)  { next; }
        my $value = &_parseValue();
        # FUTURE RESTRICTION #  unless ($value eq int $value) { &_parseError("cannot use non-numeric value '$value' with subset operator '$operator'"); }
        push(@values, $value);
        $REMAINING_STRING =~ s/^\s*,//;
      }
    }
    else {
      @values = split(/,/, &_parseValue());
      # FUTURE RESTRICTION #      my $value;
      # FUTURE RESTRICTION #      foreach $value (@values) {
      # FUTURE RESTRICTION #        unless ($value eq int $value) { &_parseError("cannot use non-numeric value '$value' with subset operator '$operator'"); }
      # FUTURE RESTRICTION #      }
    }
    unless (@values) {
      return ['NULL'];
    }

    return [ 'TEST', $prefixedFieldname, $operator, \@values ];

  }
  elsif ($operator eq 'CONTAINS ANY' or $operator eq 'CONTAINS ALL') {

    ### Get keywords of boolean search
    my $keywords                             = &_parseValue();
    my ($mustWords, $cannotWords, $mayWords) = &_getKeywords($keywords);

    ### "contains all" means that keywords are required by default
    if ($operator eq 'CONTAINS ALL') {
      push @$mustWords, @$mayWords;
      @$mayWords = ();
    }

    ### construct a list of conditions for each type of keyword
    my ($mustTree, $cannotTree, $mayTree);
    my $word;
    foreach $word (@$mustWords) {
      my $condition = [ 'TEST', $prefixedFieldname, 'LIKE', "%$word%" ];
      $mustTree     = $mustTree   ? [ 'AND', $mustTree,   $condition ] : $condition;
    }

    foreach $word (@$cannotWords) {
      my $condition = [ 'TEST', $prefixedFieldname, 'NOT LIKE', "%$word%" ];
      $cannotTree   = $cannotTree ? [ 'AND', $cannotTree, $condition ] : $condition;
    }

    foreach $word (@$mayWords) {
      my $condition = [ 'TEST', $prefixedFieldname, 'LIKE', "%$word%" ];
      $mayTree      = $mayTree    ? [ 'OR',  $mayTree,    $condition ] : $condition;
    }

    ### Join together all requirements
    my $tree = $mustTree;
    $tree    = ($tree && $cannotTree) ? [ 'AND', $tree, $cannotTree ] : $tree || $cannotTree;
    $tree    = ($tree && $mayTree)    ? [ 'AND', $tree, $mayTree ]    : $tree || $mayTree;
    unless ($tree) { return ['NULL']; }

    return $tree;

  }
  else {
    my $parseValue = &_parseValue();

    return ['NULL'] if not defined($parseValue);
    return [ 'TEST', $prefixedFieldname, $operator, $parseValue ];

  }
}

# -----------------------------------------------------------------------------
# Function    : _parseFieldname
# Description : Called by &_parseCriteria()
# Usage       : my $fieldname = &_parseFieldname();
# -----------------------------------------------------------------------------

sub _parseFieldname {

  my $fieldname;

  ### If next token is AND or OR, this is a <NULL> criteria
  if    ($REMAINING_STRING !~ /^\s*([\w\.]+)/i or
         (uc($1) eq 'AND' or uc($1) eq 'OR'))        { $fieldname = ''; }
  elsif ($REMAINING_STRING =~ s/^\s*db\.([\w\.]+)//) { $fieldname = $1; }
  else                                               { $fieldname = &_parseValue(); }
  return $fieldname;

}

# -----------------------------------------------------------------------------
# Function    : _parseOperator
# Description : Called by &_parseCriteria()
# Usage       : my $operator = &_parseOperator;
# -----------------------------------------------------------------------------

sub _parseOperator {

  if ($REMAINING_STRING =~ s/^\s*(=F|=|<=|>=|<|>|!=|LIKE|NOT LIKE|BETWEEN|NOT BETWEEN|IN|NOT IN|ONE OF|NOT ONE OF|CONTAINS ANY|CONTAINS ALL)//i) {
    my $operator = uc $1;
    if ($operator eq 'ONE OF')      { $operator = 'IN';     }    # synonym
    if ($operator eq 'NOT ONE OF')  { $operator = 'NOT IN'; }    # synonym

    return $operator;

  }
  else {
    &_parseError("expecting a recognized operator");
  }
}

# -----------------------------------------------------------------------------
# Function    : _parseValue
# Description : Called by &_parseFieldname(), &_parseCriteria() and &_parseFunctionArguments
# Usage       : my $value = &_parseValue();
# -----------------------------------------------------------------------------

sub _parseValue {

  ### Deal with functions (e.g. Date(yyyy,mm,dd))
  if ($REMAINING_STRING =~ s/^\s*([A-Za-z0-9_+-]+)\(//) {
    my $function = uc $1;

    # DATE(yyyy,mm,dd,HH,MM,SS) - change human-readable time into epoch
    if ($function eq 'DATE') {
      my @args      = &_parseFunctionArguments($function, 3, 6);
      my $epochTime = &_function_DATE(@args);
      return $epochTime;
    }

    # TODAY(-7) - change relative time into epoch (start of day, one week ago)
    elsif ($function eq 'TODAY') {
      my @args       = &_parseFunctionArguments($function, 1, 1);
      my $epochTime  = &_function_TODAY(@args);
      return $epochTime;
    }

    else {
      &_parseError("unknown function '$function', perhaps you need a space between this word and the opening parenthesis?");
    }
  }

  ### form input
  elsif ($REMAINING_STRING =~ s/^\s*form\.(\w+)//) {

    if (!exists $FORM->{$1}) { return undef;       }       # nullify criteria
    else                     { return $FORM->{$1}; }
  }

  ### quoted strings
  elsif ($REMAINING_STRING =~ s/^\s*(['"])//) {
    my $quote = $1;
    ### Hint: ?? makes optional non-greedy - same as {0,1}?
    $REMAINING_STRING  =~ s/^((?:.*?[^\\])??(?:(?:\\{2})*)?)$quote// || &_parseError("unterminated quoted string ($quote)");
    my $value          =  $1;

    return $value;
  }

  ### bareword
  elsif ($REMAINING_STRING =~ s/^\s*([A-Za-z0-9\_\+\-\.]+)//) {
    return $1;
  }
  else {
    &_parseError("expecting a value (quoted string, bareword, or function)");
  }
}

# -----------------------------------------------------------------------------
# Function    : _parseFunctionArguments
# Description : Called by &_parseCriteria and &_parseValue()
# Usage       : my @args = &_parseFunctionArguments($function, $minArgs, $maxArgs);
# -----------------------------------------------------------------------------

sub _parseFunctionArguments {

  my ($functionName, $minArgs, $maxArgs) = @_;    ### does this need validateArgs?
  my @arguments = (undef);                        # proactively set first arg to undef

  while (1) {
    if ($REMAINING_STRING =~ s/^\s*\)//)  {
      last;
    }
    elsif ($REMAINING_STRING =~ s/^\s*,//) {
      push @arguments, undef;                           # proactively set next arg to undef
      if ($maxArgs && @arguments > $maxArgs) { &_parseError("too many arguments for '$functionName'"); }
    }
    else {
      $arguments[-1] = &_parseValue();
      unless ($REMAINING_STRING =~ /^\s*(\)|,)/) { &_parseError("expecting , or ) after argument"); }
    }
  }

  if ($minArgs && @arguments < $minArgs) {
    &_parseError("too few arguments for '$functionName'");
  }

  foreach my $argIndex (0..($minArgs-1)) {
    if (not defined $arguments[$argIndex]) { return (); }    # required arg missing: nullify test!
  }

  return @arguments;

}

# -----------------------------------------------------------------------------
# Function    : _parseError
# Description :
# Usage       : &_parseError($errorString);
# -----------------------------------------------------------------------------

sub _parseError {

  my $error           = shift;
  my $errorPosition   = length($ORIGINAL_STRING) - length($REMAINING_STRING);
  my $textBeforeError = substr($ORIGINAL_STRING, 0, $errorPosition);

  $REMAINING_STRING =~ s/\s+/ /g;
  $textBeforeError  =~ s/\s+/ /g;

  if (length $REMAINING_STRING > 20) { $REMAINING_STRING = substr($REMAINING_STRING, 0, 20) . '...'; }
  if (length $textBeforeError > 20)  { $textBeforeError  = '...' . substr($textBeforeError, -20);    }

  $error .= " in where condition: \"$textBeforeError ***ERROR*** $REMAINING_STRING\"";

  die $error;
}

# -----------------------------------------------------------------------------
# Function    : _getKeywords
# Description : Parses a search-engine-style string for must ("+"), cannot ("-"),
#               and may phrases, honouring quoted strings.
# Usage       : my ($must_words, $cannot_words, $may_words) = &_getKeywords($keywords);
# -----------------------------------------------------------------------------

sub _getKeywords {
  my $REMAINING_STRING = shift;

  # unescape escaped characters
  $REMAINING_STRING =~ s|\\(["',;])|$1|g;

  ### remove leading and trailing delimiters
  my $regex =  "[\\s,;]+";
  $REMAINING_STRING   =~ s/^$regex//gs;
  $REMAINING_STRING   =~ s/$regex$//gs;

  ### find "quoted phrases"
  my @terms;
  while ($REMAINING_STRING =~ s/([+-]?)(['"])(.*?)\2//) {
    push @terms, "$1$3";                         # remove quotes but keep the +/-
  }

  ### other keywords
  push @terms, split(/$regex/, $REMAINING_STRING);    # find other keywords

  ### remove +/- and add to appropriate list
  my $mustWords   = [];
  my $cannotWords = [];
  my $keywords    = [];

  foreach my $term (@terms) {
    if    ($term =~ s/^\+(.*)//) { push(@$mustWords,$1);   }
    elsif ($term =~ s/^-(.*)//)  { push(@$cannotWords,$1); }
    else                         { push(@$keywords,$term); }
  }

  if (wantarray) { return ($mustWords, $cannotWords, $keywords);      }
  else           { return [(@$mustWords, @$cannotWords, @$keywords)]; }

}

# -----------------------------------------------------------------------------
# Function    : _CleanTree
# Description : Reap "NULL" nodes from a $where_tree, collapsing useless conjunctors
# Usage       : &_cleanTree($where_tree);
# -----------------------------------------------------------------------------

sub _cleanTree {
  my $node = shift;
  if (ref $node ne 'ARRAY') { die "not an array ref!"; }

  my $nodeType = $node->[0];
  if ($nodeType eq 'AND' or $nodeType eq 'OR') {

    ### Use "head recursion", so NULLs will always be one level beneath us
    &_cleanTree($node->[1]);
    &_cleanTree($node->[2]);

    ### If we're reaping both children, collapse self, and we'll be reaped by our parent
    if (!@{$node->[1]} && !@{$node->[2]}) { @$node = (); return; }

    ### If we're reaping only one child, replace self with child
    if (!@{$node->[1]}) { @$node = (@{$node->[2]}); return; }
    if (!@{$node->[2]}) { @$node = (@{$node->[1]}); return; }

  }
  elsif ($nodeType eq 'NOT') {

    ### Use "head recursion", so NULLs will always be one level beneath us
    &_cleanTree($node->[1]);

    ### If we're reaping our only child, collapse self, and we'll be reaped by our parent
    if (!@{$node->[1]}) { @$node = (); return; }

  }
  elsif ($nodeType eq 'NULL') {
    @$node = ();
  }

  return;

}

# ----------------------------------------------------------------------------
# Function    : _function_TODAY
#
# Description : Specify a date relative to today and returns its epoch value.
#               The time is ALWAYS returned as midnight on the day in question.
#               today() is today, today(-1) is yesterday and today(1) is tomorrow.
#
# Usage       : my $epochTime = &_function_TODAY(-7);  # 7 days ago (at midnight)
#               my $epochTime = &_function_TODAY();    # today      (at midnight)
#               my $epochTime = &_function_TODAY(1);   # tommorow   (at midnight)
# ----------------------------------------------------------------------------

sub _function_TODAY {
  my $dayOffset = shift;

  if    (@_)                        { &_parseError("too many arguments for 'TODAY'!"); }
  elsif ($dayOffset =~ m/[^0-9\-]/) { &_parseError("dayOffset value '$dayOffset'! Must be a number (0-9 and - allowed) for 'TODAY'!"); }

  # get day start time (as of midnight)
  my $secondsSinceMidnight = ($CURRENT_HOUR*60*60) + ($CURRENT_MIN*60) + $CURRENT_SEC;
  my $dayStartTime         = $^T - $secondsSinceMidnight;

  # get adjusted time
  my $secondsInOneDay = 60*60*24;
  my $secondsOffset   = $dayOffset * $secondsInOneDay;
  my $adjustedTime    = $dayStartTime + $secondsOffset;

  return $adjustedTime;
}

# ----------------------------------------------------------------------------
# Function    : _function_DATE
# Description : Converts a date into epoch value.  Hour, min, and second are
#               assumed to be zero if not specified, meaning if you don't
#               specify them you'll get midnight on the date specified.
# Usage       : my $epochTime = &_function_DATE($year,$mon,$day);                  # date (at midnight)
#               my $epochTime = &_function_DATE($year,$mon,$day,$hour,$min,$sec);  # specified date
# ----------------------------------------------------------------------------

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

  # error checking
  if    (@_ > 6)                   { &_parseError("too many arguments for 'DATE'!"); }
  elsif (!$year || !$mon || !$day) { &_parseError("YEAR, MONTH, and DAY must be specified for 'DATE'!"); }
  elsif ($year < 1970)             { &_parseError("YEAR must be 1970 or greater (not '$year') for 'DATE'"); }
  elsif ($mon < 1  || $mon > 12)   { &_parseError("MONTH must be between 1 and 12 (not '$mon') for 'DATE'"); }
  elsif ($day < 1  || $day > 31)   { &_parseError("DAY must be between 1 and 31 (not '$day') for 'DATE'"); }
  elsif ($hour < 0 || $hour > 23)  { &_parseError("HOUR must be between 0 and 23 (not '$hour') for 'DATE'"); }
  elsif ($min < 0  || $min > 59)   { &_parseError("MINUTE must be between 0 and 59 (not '$min') for 'DATE'"); }
  elsif ($sec < 0  || $sec > 59)   { &_parseError("SECOND must be between 0 and 59 (not '$sec') for 'DATE'"); }

  my $epochTime = eval { timegm($sec, $min, $hour, $day, $mon-1, $year-1900); };
  if ($@) { &_parseError("invalid date (timegm error: $@)"); }

  return $epochTime;
}

# ----------------------------------------------------------------------------
# end of file
1;

