# Plugin for TWiki Enterprise Collaboration Platform, http://TWiki.org/
#
# Copyright (C) 2001-2006 Peter Thoeny, peter@thoeny.org
#
# This program 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 2
# of the License, or (at your option) any later version. For
# more details read LICENSE in the root of this distribution.
#
# This program 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, published at
# http://www.gnu.org/copyleft/gpl.html
#
# As per the GPL, removal of this notice is prohibited.
#
# =========================
#
# This is part of TWiki's Spreadsheet Plugin.
#
# The code below is kept out of the main plugin module for
# performance reasons, so it doesn't get compiled until it
# is actually used.

package TWiki::Plugins::SpreadSheetPlugin::Calc;

use strict;
use Time::Local;


# =========================
use vars qw(
        $web $topic $debug $dontSpaceRE
        $renderingWeb @tableMatrix $cPos $rPos $escToken
        %varStore @monArr @wdayArr %mon2num
    );

$escToken = "\0";
%varStore = ();
$dontSpaceRE = "";
@monArr = ( "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" );
@wdayArr = ( "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" );
{ my $count = 0;
  %mon2num = map { $_ => $count++ } @monArr;
}


# =========================
sub init
{
    ( $web, $topic, $debug ) = @_;

    # initialize variables, once per page view
    %varStore = ();
    $dontSpaceRE = "";

    # Module initialized
    TWiki::Func::writeDebug( "- TWiki::Plugins::SpreadSheetPlugin::Calc::init( $web.$topic )" ) if $debug;
    return 1;
}

# =========================
sub CALC
{
### my ( $text, $topic, $web ) = @_;   # do not uncomment, use $_[0], $_[1]... instead

    TWiki::Func::writeDebug( "- SpreadSheetPlugin::Calc::CALC( $_[2].$_[1] )" ) if $debug;

    @tableMatrix = ();
    $cPos = -1;
    $rPos = -1;

    my $result = "";
    my $insidePRE = 0;
    my $insideTABLE = 0;
    my $line = "";
    my $before = "";
    my $cell = "";
    my @row = ();

    $_[0] =~ s/\r//go;
    $_[0] =~ s/\\\n//go;  # Join lines ending in "\"
    foreach( split( /\n/, $_[0] ) ) {

        # change state:
        m|<pre>|i       && ( $insidePRE = 1 );
        m|<verbatim>|i  && ( $insidePRE = 1 );
        m|</pre>|i      && ( $insidePRE = 0 );
        m|</verbatim>|i && ( $insidePRE = 0 );

        if( ! ( $insidePRE ) ) {

            if( /^\s*\|.*\|\s*$/ ) {
                # inside | table |
                if( ! $insideTABLE ) {
                    $insideTABLE = 1;
                    @tableMatrix = ();  # reset table matrix
                    $cPos = -1;
                    $rPos = -1;
                }
                $line = $_;
                $line =~ s/^(\s*\|)(.*)\|\s*$/$2/o;
                $before = $1;
                @row  = split( /\|/o, $line, -1 );
                push @tableMatrix, [ @row ];
                $rPos++;
                $line = "$before";
                for( $cPos = 0; $cPos < @row; $cPos++ ) {
                    $cell = $row[$cPos];
                    $cell =~ s/%CALC\{(.*?)\}%/&doCalc($1)/geo;
                    $line .= "$cell|";
                }
                s/.*/$line/o;

            } else {
                # outside | table |
                if( $insideTABLE ) {
                    $insideTABLE = 0;
                }
                s/%CALC\{(.*?)\}%/&doCalc($1)/geo;
            }
        }
        $result .= "$_\n";
    }
    $_[0] = $result;
}

# =========================
sub doCalc
{
    my( $theAttributes ) = @_;
    my $text = &TWiki::Func::extractNameValuePair( $theAttributes );

    # Add nesting level to parenthesis,
    # e.g. "A(B())" gets "A-esc-1(B-esc-2(-esc-2)-esc-1)"
    my $level = 0;
    $text =~ s/([\(\)])/addNestingLevel($1, \$level)/geo;
    $text = doFunc( "MAIN", $text );

    if( ( $rPos >= 0 ) && ( $cPos >= 0 ) ) {
        # update cell in table matrix
        $tableMatrix[$rPos][$cPos] = $text;
    }

    return $text;
}

# =========================
sub addNestingLevel
{
  my( $theParen, $theLevelRef ) = @_;

  my $result = "";
  if( $theParen eq "(" ) {
    $$theLevelRef++;
    $result = "$escToken$$theLevelRef$theParen";
  } else {
    $result = "$escToken$$theLevelRef$theParen";
    $$theLevelRef--;
  }
  return $result;
}

# =========================
sub doFunc
{
    my( $theFunc, $theAttr ) = @_;

    $theAttr = "" unless( defined $theAttr );
    TWiki::Func::writeDebug( "- SpreadSheetPlugin::Calc::doFunc: $theFunc( $theAttr ) start" ) if $debug;

    unless( $theFunc =~ /^(IF|LISTIF|LISTMAP|NOEXEC)$/ ) {
        # Handle functions recursively
        $theAttr =~ s/\$([A-Z]+)$escToken([0-9]+)\((.*?)$escToken\2\)/&doFunc($1,$3)/geo;
        # Clean up unbalanced mess
        $theAttr =~ s/$escToken\-*[0-9]+([\(\)])/$1/go;
    }
    # else: delay the function handler to after parsing the parameters,
    # in which case handling functions and cleaning up needs to be done later

    my $result = "";
    my $i = 0;
    if( $theFunc eq "MAIN" ) {
        $result = $theAttr;

    } elsif( $theFunc eq "EXEC" ) {
        # add nesting level escapes
        my $level = 0;
        $result = $theAttr;
        $result =~ s/([\(\)])/addNestingLevel($1, \$level)/geo;
        # execute functions in attribute recursively and clean up unbalanced parenthesis
        $result =~ s/\$([A-Z]+)$escToken([0-9]+)\((.*?)$escToken\2\)/&doFunc($1,$3)/geo;
        $result =~ s/$escToken\-*[0-9]+([\(\)])/$1/go;

    } elsif( $theFunc eq "NOEXEC" ) {
        $result = $theAttr;

    } elsif( $theFunc eq "T" ) {
        $result = "";
        my @arr = getTableRange( "$theAttr..$theAttr" );
        if( @arr ) {
            $result = $arr[0];
        }

    } elsif( $theFunc eq "TRIM" ) {
        $result = $theAttr || "";
        $result =~ s/^\s*//o;
        $result =~ s/\s*$//o;
        $result =~ s/\s+/ /go;

    } elsif( $theFunc eq "FORMAT" ) {
        # Format FORMAT(TYPE, precision, value) returns formatted value -- JimStraus - 05 Jan 2003
        my( $format, $res, $value )  = split( /,\s*/, $theAttr );
        $format =~ s/^\s*(.*?)\s*$/$1/; #Strip leading and trailing spaces
        $res =~ s/^\s*(.*?)\s*$/$1/;
        $value =~ s/^\s*(.*?)\s*$/$1/;
        if( $format eq "DOLLAR" ) {
            my $neg = 1 if $value < 0;
            $value = abs($value);
            $result = sprintf("%0.${res}f", $value);
            my $temp = reverse $result;
            $temp =~ s/(\d\d\d)(?=\d)(?!\d*\.)/$1,/g;
            $result = "\$" . (scalar reverse $temp);
            $result = "(".$result.")" if $neg;
        } elsif( $format eq "COMMA" ) {
            $result = sprintf("%0.${res}f", $value);
            my $temp = reverse $result;
            $temp =~ s/(\d\d\d)(?=\d)(?!\d*\.)/$1,/g;
            $result = scalar reverse $temp;
        } elsif( $format eq "PERCENT" ) {
            $result = sprintf("%0.${res}f%%", $value * 100);
        } elsif( $format eq "NUMBER" ) {
            $result = sprintf("%0.${res}f", $value);
        } elsif( $format eq "K" ) {
            $result = sprintf("%0.${res}f K", $value / 1024);
        } elsif( $format eq "KB" ) {
            $result = sprintf("%0.${res}f KB", $value / 1024);
        } elsif ($format eq "MB") {
            $result = sprintf("%0.${res}f MB", $value / (1024 * 1024));
        } elsif( $format =~ /^KBMB/ ) {
            $value /= 1024;
            my @lbls = ( "MB", "GB", "TB", "PB", "EB", "ZB" );
            my $lbl = "KB";
            while( $value >= 1024 && @lbls ) {
                $value /= 1024;
                $lbl = shift @lbls;
            }
            $result = sprintf("%0.${res}f", $value) . " $lbl";
        } else {
            # FORMAT not recognized, just return value
            $result = $value;
        }

    } elsif( $theFunc eq "EXACT" ) {
        $result = 0;
        my( $str1, $str2 ) = split( /,\s*/, $theAttr, 2 );
        $str1 = "" unless( $str1 );
        $str2 = "" unless( $str2 );
        $str1 =~ s/^\s*(.*?)\s*$/$1/o; # cut leading and trailing spaces
        $str2 =~ s/^\s*(.*?)\s*$/$1/o;
        $result = 1 if( $str1 eq $str2 );

    } elsif( $theFunc eq "RAND" ) {
        my $max = _getNumber( $theAttr );
        $max = 1 if( $max <= 0 );
        $result = rand( $max );

    } elsif( $theFunc eq "VALUE" ) {
        $result = _getNumber( $theAttr );

    } elsif( $theFunc =~ /^(EVAL|INT)$/ ) {
        $result = safeEvalPerl( $theAttr );
        unless( $result =~ /^ERROR/ ) {
            $result = int( _getNumber( $result ) ) if( $theFunc eq "INT" );
        }

    } elsif( $theFunc eq "ROUND" ) {
        # ROUND(num, digits)
        my( $num, $digits ) = split( /,\s*/, $theAttr, 2 );
        $result = safeEvalPerl( $num );
        unless( $result =~ /^ERROR/ ) {
            $result = _getNumber( $result );
            if( ( $digits ) && ( $digits =~ s/^.*?(\-?[0-9]+).*$/$1/o ) && ( $digits ) ) {
                my $factor = 10**$digits;
                $result *= $factor;
                ( $result >= 0 ) ? ( $result += 0.5 ) : ( $result -= 0.5 );
                $result = int( $result );
                $result /= $factor;
            } else {
                ( $result >= 0 ) ? ( $result += 0.5 ) : ( $result -= 0.5 );
                $result = int( $result );
            }
        }

    } elsif( $theFunc eq "MOD" ) {
        $result = 0;
        my( $num1, $num2 ) = split( /,\s*/, $theAttr, 2 );
        $num1 = _getNumber( $num1 );
        $num2 = _getNumber( $num2 );
        if( $num1 && $num2 ) {
            $result = $num1 % $num2;
        }

    } elsif( $theFunc eq "ODD" ) {
        $result = _getNumber( $theAttr ) % 2;

    } elsif( $theFunc eq "EVEN" ) {
        $result = ( _getNumber( $theAttr ) + 1 ) % 2;

    } elsif( $theFunc eq "AND" ) {
        $result = 0;
        my @arr = getListAsInteger( $theAttr );
        foreach $i( @arr ) {
            unless( $i ) {
                $result = 0;
                last;
            }
            $result = 1;
        }

    } elsif( $theFunc eq "OR" ) {
        $result = 0;
        my @arr = getListAsInteger( $theAttr );
        foreach $i( @arr ) {
            if( $i ) {
                $result = 1;
                last;
            }
        }

    } elsif( $theFunc eq "NOT" ) {
        $result = 1;
        $result = 0 if( _getNumber( $theAttr ) );

    } elsif( $theFunc eq "ABS" ) {
        $result = abs( _getNumber( $theAttr ) );

    } elsif( $theFunc eq "SIGN" ) {
        $i = _getNumber( $theAttr );
        $result =  0;
        $result =  1 if( $i > 0 );
        $result = -1 if( $i < 0 );

    } elsif( $theFunc eq "IF" ) {
        # IF(condition, value if true, value if false)
        my( $condition, $str1, $str2 ) = _properSplit( $theAttr, 3 );
        # with delay, handle functions in condition recursively and clean up unbalanced parenthesis
        $condition =~ s/\$([A-Z]+)$escToken([0-9]+)\((.*?)$escToken\2\)/&doFunc($1,$3)/geo;
        $condition =~ s/$escToken\-*[0-9]+([\(\)])/$1/go;
        $condition =~ s/^\s*(.*?)\s*$/$1/o;
        $result = safeEvalPerl( $condition );
        unless( $result =~ /^ERROR/ ) {
            if( $result ) {
                $result = $str1;
            } else {
                $result = $str2;
            }
            $result = "" unless( defined( $result ) );
            # with delay, handle functions in result recursively and clean up unbalanced parenthesis
            $result =~ s/\$([A-Z]+)$escToken([0-9]+)\((.*?)$escToken\2\)/&doFunc($1,$3)/geo;
            $result =~ s/$escToken\-*[0-9]+([\(\)])/$1/go;

        } # else return error message

    } elsif( $theFunc eq "UPPER" ) {
        $result = uc( $theAttr );

    } elsif( $theFunc eq "LOWER" ) {
        $result = lc( $theAttr );

    } elsif( $theFunc eq "PROPER" ) {
        # FIXME: I18N
        $result = lc( $theAttr );
        $result =~ s/(^|[^a-z])([a-z])/$1 . uc($2)/geo;

    } elsif( $theFunc eq "PROPERSPACE" ) {
        $result = _properSpace( $theAttr );

    } elsif( $theFunc eq "CHAR" ) {
        if( $theAttr =~ /([0-9]+)/ ) {
            $i = $1;
        } else {
            $i = 0;
        }
        $i = 255 if $i > 255;
        $i = 0 if $i < 0;
        $result = chr( $i );

    } elsif( $theFunc eq "REPEAT" ) {
        my( $str, $num ) = split( /,\s*/, $theAttr, 2 );
        $str = "" unless( defined( $str ) );
        $num = _getNumber( $num );
        $result = "$str" x $num;

    } elsif( $theFunc eq "CODE" ) {
        $result = ord( $theAttr );

    } elsif( $theFunc eq "LENGTH" ) {
        $result = length( $theAttr );

    } elsif( $theFunc eq "ROW" ) {
        $i = $theAttr || 0;
        $result = $rPos + $i + 1;

    } elsif( $theFunc eq "COLUMN" ) {
        $i = $theAttr || 0;
        $result = $cPos + $i + 1;

    } elsif( $theFunc eq "LEFT" ) {
        $i = $rPos + 1;
        $result = "R$i:C0..R$i:C$cPos";

    } elsif( $theFunc eq "ABOVE" ) {
        $i = $cPos + 1;
        $result = "R0:C$i..R$rPos:C$i";

    } elsif( $theFunc eq "RIGHT" ) {
        $i = $rPos + 1;
        $result = "R$i:C$cPos..R$i:C32000";

    } elsif( $theFunc eq "DEF" ) {
        # Format DEF(list) returns first defined cell
        # Added by MF 26/3/2002, fixed by PeterThoeny
        my @arr = getList( $theAttr );
        foreach my $cell ( @arr ) {
            if( $cell ) {
                $cell =~ s/^\s*(.*?)\s*$/$1/o;
                if( $cell ) {
                    $result = $cell;
                    last;
                }
            }
        }

    } elsif( $theFunc eq "MAX" ) {
        my @arr = sort { $a <=> $b }
                  grep { /./ }
                  grep { defined $_ }
                  getListAsFloat( $theAttr );
        $result = $arr[$#arr];

    } elsif( $theFunc eq "MIN" ) {
        my @arr = sort { $a <=> $b }
                  grep { /./ }
                  grep { defined $_ }
                  getListAsFloat( $theAttr );
        $result = $arr[0];

    } elsif( $theFunc eq "SUM" ) {
        $result = 0;
        my @arr = getListAsFloat( $theAttr );
        foreach $i ( @arr ) {
            $result += $i  if defined $i;
        }

    } elsif( $theFunc eq "SUMPRODUCT" ) {
        $result = 0;
        my @arr;
        my @lol = split( /,\s*/, $theAttr );
        my $size = 32000;
        for $i (0 .. $#lol ) {
            @arr = getListAsFloat( $lol[$i] );
            $lol[$i] = [ @arr ];                # store reference to array
            $size = @arr if( @arr < $size );    # remember smallest array
        }
        if( ( $size > 0 ) && ( $size < 32000 ) ) {
            my $y; my $prod; my $val;
            $size--;
            for $y (0 .. $size ) {
                $prod = 1;
                for $i (0 .. $#lol ) {
                    $val = $lol[$i][$y];
                    if( defined $val ) {
                        $prod *= $val;
                    } else {
                        $prod = 0;   # don't count empty cells
                    }
                }
                $result += $prod;
            }
        }

    } elsif( $theFunc =~ /^(SUMDAYS|DURATION)$/ ) {
        # DURATION is undocumented, is for SvenDowideit
        # contributed by SvenDowideit - 07 Mar 2003; modified by PTh
        $result = 0;
        my @arr = getListAsDays( $theAttr );
        foreach $i ( @arr ) {
            $result += $i  if defined $i;
        }

    } elsif( $theFunc eq "WORKINGDAYS" ) {
        my( $num1, $num2 ) = split( /,\s*/, $theAttr, 2 );
        $result = _workingDays( _getNumber( $num1 ), _getNumber( $num2 ) );

    } elsif( $theFunc =~ /^(MULT|PRODUCT)$/ ) {   # MULT is deprecated, no not remove
        $result = 0;
        my @arr = getListAsFloat( $theAttr );
        $result = 1;
        foreach $i ( @arr ) {
            $result *= $i  if defined $i;
        }

    } elsif( $theFunc =~ /^(AVERAGE|MEAN)$/ ) {
        $result = 0;
        my $items = 0;
        my @arr = getListAsFloat( $theAttr );
        foreach $i ( @arr ) {
            if( defined $i ) {
                $result += $i;
                $items++;
            }
        }
        if( $items > 0 ) {
            $result = $result / $items;
        }

    } elsif( $theFunc eq "MEDIAN" ) {
        my @arr = sort { $a <=> $b } grep { defined $_ } getListAsFloat( $theAttr );
        $i = @arr;
        if( ( $i % 2 ) > 0 ) {
            $result = $arr[$i/2];
        } elsif( $i ) {
            $i /= 2;
            $result = ( $arr[$i] + $arr[$i-1] ) / 2;
        }

    } elsif( $theFunc eq "PERCENTILE" ) {
        my( $percentile, $set ) = split( /,\s*/, $theAttr, 2 );
        my @arr = sort { $a <=> $b } grep { defined $_ } getListAsFloat( $set );
        $result = 0;

        my $size = scalar( @arr );
        if( $size > 0 ) {
            $i = $percentile / 100 * ( $size + 1 );
            my $iInt = int( $i );
            if( $i <= 1 ) {
                $result = $arr[0];
            } elsif( $i >= $size ) {
                $result = $arr[$size-1];
            } elsif( $i == $iInt ) {
                $result = $arr[$i-1];
            } else {
                # interpolate beween neighbors # Example: $i = 7.25
                my $r1 = $iInt + 1 - $i;       # 0.75 = 7 + 1 - 7.25
                my $r2 = 1 - $r1;              # 0.25 = 1 - 0.75
                my $x1 = $arr[$iInt-1];
                my $x2 = $arr[$iInt];
                $result = ($r1 * $x1) + ($r2 * $x2);
            }
        }

    } elsif( $theFunc eq "COUNTSTR" ) {
        $result = 0;  # count any string
        $i = 0;       # count string equal second attr
        my $list = $theAttr;
        my $str = "";
        if( $theAttr =~ /^(.*),\s*(.*?)$/ ) {  # greedy match for last comma
            $list = $1;
            $str = $2;
        }
        $str =~ s/\s*$//o;
        my @arr = getList( $list );
        foreach my $cell ( @arr ) {
            if( defined $cell ) {
                $cell =~ s/^\s*(.*?)\s*$/$1/o;
                $result++ if( $cell );
                $i++ if( $cell eq $str );
            }
        }
        $result = $i if( $str );

    } elsif( $theFunc eq "COUNTITEMS" ) {
        $result = "";
        my @arr = getList( $theAttr );
        my %items = ();
        my $key = "";
        foreach $key ( @arr ) {
            $key =~ s/^\s*(.*?)\s*$/$1/o if( $key );
            if( $key ) {
                if( exists( $items{ $key } ) ) {
                    $items{ $key }++;
                } else {
                    $items{ $key } = 1;
                }
            }
        }
        foreach $key ( sort keys %items ) {
            $result .= "$key: $items{ $key }<br /> ";
        }
        $result =~ s|<br /> $||o;

    } elsif( $theFunc =~ /^(FIND|SEARCH)$/ ) {
        my( $searchString, $string, $pos ) = split( /,\s*/, $theAttr, 3 );
        $result = 0;
        $pos--;
        $pos = 0 if( $pos < 0 );
        pos( $string ) = $pos if( $pos );
        $searchString = quotemeta( $searchString ) if( $theFunc eq "FIND" );
        # using zero width lookahead '(?=...)' to keep pos at the beginning of match
        if( eval '$string =~ m/(?=$searchString)/g' && $string ) {
            $result = pos( $string ) + 1;
        }

    } elsif( $theFunc eq "REPLACE" ) {
        my( $string, $start, $num, $replace ) = split ( /,\s*/, $theAttr, 4 );
        $result = $string;
        $start-- unless ($start < 1);
        $num = 0 unless( $num );
        $replace = "" unless( defined $replace );
        if( eval 'substr( $string, $start, $num, $replace )' && $string ) {
            $result = $string;
        }

    } elsif( $theFunc eq "SUBSTITUTE" ) {
        my( $string, $from, $to, $inst, $options ) = split( /,\s*/, $theAttr );
        $result = $string;
        $to = "" unless( defined $to );
        $from = quotemeta( $from ) unless( $options && $options =~ /r/i);
        if( $inst ) {
            # replace Nth instance
            my $count = 0;
            if( eval '$string =~ s/($from)/if( ++$count == $inst ) { $to; } else { $1; }/gex;' && $string ) {
                $result = $string;
            }
        } else {
            # global replace
            if( eval '$string =~ s/$from/$to/g' && $string ) {
                $result = $string;
            }
        }

    } elsif( $theFunc eq "TRANSLATE" ) {
        $result = $theAttr;
        # greedy match for comma separated parameters (in case first parameter has embedded commas)
        if( $theAttr =~ /^(.*)\,\s*(.+)\,\s*(.+)$/ ) {
            my $string = $1;
            my $from = $2;
            my $to   = $3;
            $from =~ s/\$comma/,/g;  $from =~ s/\$sp/ /g;  $from = quotemeta( $from );
            $to   =~ s/\$comma/,/g;  $to   =~ s/\$sp/ /g;  $to   = quotemeta( $to );
            $from =~ s/([a-zA-Z0-9])\\\-([a-zA-Z0-9])/$1\-$2/g; # fix quotemeta (allow only ranges)
            $to   =~ s/([a-zA-Z0-9])\\\-([a-zA-Z0-9])/$1\-$2/g;
            $result = $string;
            if( $string && eval "\$string =~ tr/$from/$to/" ) {
                $result = $string;
            }
        }

    } elsif ( $theFunc eq "TIME" ) {
       $result = $theAttr;
       $result =~ s/^\s+//o;
       $result =~ s/\s+$//o;
       if( $result ) {
           $result = _date2serial( $result );
       } else {
           $result = time();
       }

    } elsif ( $theFunc eq "TODAY" ) {
       $result = _date2serial( _serial2date( time(), '$year/$month/$day GMT', 1 ) );

    } elsif( $theFunc =~ /^(FORMATTIME|FORMATGMTIME)$/ ) {
       my( $time, $str ) = split( /,\s*/, $theAttr, 2 );
       if( $time =~ /([0-9]+)/ ) {
           $time = $1;
       } else {
           $time = time();
       }
       my $isGmt = 0;
       $isGmt = 1 if( ( $str =~ m/ gmt/i ) || ( $theFunc eq "FORMATGMTIME" ) );
       $result = _serial2date( $time, $str, $isGmt );

    } elsif( $theFunc eq "TIMEADD" ) {
       my( $time, $value, $scale ) = split( /,\s*/, $theAttr, 3 );
       $time = 0 unless( $time );
       $value = 0 unless( $value );
       $scale = "" unless( $scale );
       $time =~ s/.*?([0-9]+).*/$1/o || 0;
       $value =~ s/.*?(\-?[0-9\.]+).*/$1/o || 0;
       $value *= 60            if( $scale =~ /^min/i );
       $value *= 3600          if( $scale =~ /^hou/i );
       $value *= 3600*24       if( $scale =~ /^day/i );
       $value *= 3600*24*7     if( $scale =~ /^week/i );
       $value *= 3600*24*30.42 if( $scale =~ /^mon/i );  # FIXME: exact calc
       $value *= 3600*24*365   if( $scale =~ /^year/i ); # FIXME: exact calc
       $result = int( $time + $value );

    } elsif( $theFunc eq "TIMEDIFF" ) {
       my( $time1, $time2, $scale ) = split( /,\s*/, $theAttr, 3 );
       $time1 = 0 unless( $time1 );
       $time2 = 0 unless( $time2 );
       $time1 =~ s/.*?([0-9]+).*/$1/o || 0;
       $time2 =~ s/.*?([0-9]+).*/$1/o || 0;
       $result = $time2 - $time1;
       $result /= 60            if( $scale =~ /^min/i );
       $result /= 3600          if( $scale =~ /^hou/i );
       $result /= 3600*24       if( $scale =~ /^day/i );
       $result /= 3600*24*7     if( $scale =~ /^week/i );
       $result /= 3600*24*30.42 if( $scale =~ /^mon/i );  # FIXME: exact calc
       $result /= 3600*24*365   if( $scale =~ /^year/i ); # FIXME: exact calc

    } elsif( $theFunc eq "SET" ) {
       my( $name, $value ) = split( /,\s*/, $theAttr, 2 );
       $name =~ s/[^a-zA-Z0-9\_]//go;
       if( $name && defined( $value ) ) {
           $value =~ s/\s*$//o;
           $varStore{ $name } = $value;
       }

    } elsif( $theFunc eq "SETIFEMPTY" ) {
       my( $name, $value ) = split( /,\s*/, $theAttr, 2 );
       $name =~ s/[^a-zA-Z0-9\_]//go;
       if( $name && defined( $value ) && ! $varStore{ $name } ) {
           $value =~ s/\s*$//o;
           $varStore{ $name } = $value;
       }

    } elsif( $theFunc eq "SETM" ) {
       my( $name, $value ) = split( /,\s*/, $theAttr, 2 );
       $name =~ s/[^a-zA-Z0-9\_]//go;
       if( $name ) {
           my $old = $varStore{ $name };
           $old = "" unless( defined( $old ) );
           $value = safeEvalPerl( "$old $value" );
           $varStore{ $name } = $value;
       }

    } elsif( $theFunc eq "GET" ) {
       my $name = $theAttr;
       $name =~ s/[^a-zA-Z0-9\_]//go;
       $result = $varStore{ $name } if( $name );
       $result = "" unless( defined( $result ) );

    } elsif( $theFunc eq "LIST" ) {
        my @arr = getList( $theAttr );
        $result = _listToDelimitedString( @arr );

    } elsif( $theFunc eq "LISTITEM" ) {
        my( $index, $str ) = _properSplit( $theAttr, 2 );
        $index = _getNumber( $index );
        $str = "" unless( defined( $str ) );
        my @arr = getList( $str );
        my $size = scalar @arr;
        if( $index && $size ) {
            $index-- if( $index > 0 );                 # documented index starts at 1
            $index = $size + $index if( $index < 0 );  # start from back if negative
            $result = $arr[$index] if( ( $index >= 0 ) && ( $index < $size ) );
        }

    } elsif( $theFunc eq "LISTJOIN" ) {
        my( $sep, $str ) = _properSplit( $theAttr, 2 );
        $str = "" unless( defined( $str ) );
        $result = _listToDelimitedString( getList( $str ) );
        $sep = ", " unless( $sep );
        $sep =~ s/\$comma/,/go;
        $sep =~ s/\$sp/ /go;
        $sep =~ s/\$n/\n/go;
        $result =~ s/, /$sep/go;

    } elsif( $theFunc eq "LISTSIZE" ) {
        my @arr = getList( $theAttr );
        $result = scalar @arr;

    } elsif( $theFunc eq "LISTSORT" ) {
        my $isNumeric = 1;
        my @arr = map {
            s/^\s*//o;
            s/\s*$//o;
            $isNumeric = 0 unless( $_ =~ /^[\+\-]?[0-9\.]+$/ );
            $_
        } getList( $theAttr );
        if( $isNumeric ) {
            @arr = sort { $a <=> $b } @arr;
        } else {
            @arr = sort @arr;
        }
        $result = _listToDelimitedString( @arr );

    } elsif( $theFunc eq "LISTREVERSE" ) {
        my @arr = reverse getList( $theAttr );
        $result = _listToDelimitedString( @arr );

    } elsif( $theFunc eq "LISTUNIQUE" ) {
        my %seen = ();
        my @arr = grep { ! $seen{$_} ++ } getList( $theAttr );
        $result = _listToDelimitedString( @arr );

    } elsif( $theFunc eq "LISTMAP" ) {
        # LISTMAP(action, item 1, item 2, ...)
        my( $action, $str ) = _properSplit( $theAttr, 2 );
        $action = "" unless( defined( $action ) );
        $str = "" unless( defined( $str ) );
        # with delay, handle functions in result recursively and clean up unbalanced parenthesis
        $str =~ s/\$([A-Z]+)$escToken([0-9]+)\((.*?)$escToken\2\)/&doFunc($1,$3)/geo;
        $str =~ s/$escToken\-*[0-9]+([\(\)])/$1/go;
        my $item = "";
        $i = 0;
        my @arr =
            map {
               $item = $_;
               $_ = $action;
               $i++;
               s/\$index/$i/go;
               $_ .= $item unless( s/\$item/$item/go );
               s/\$([A-Z]+)$escToken([0-9]+)\((.*?)$escToken\2\)/&doFunc($1,$3)/geo;
               s/$escToken\-*[0-9]+([\(\)])/$1/go;
               $_
            } getList( $str );
        $result = _listToDelimitedString( @arr );

    } elsif( $theFunc eq "LISTIF" ) {
        # LISTIF(cmd, item 1, item 2, ...)
        my( $cmd, $str ) = _properSplit( $theAttr, 2 );
        $cmd = "" unless( defined( $cmd ) );
        $cmd =~ s/^\s*(.*?)\s*$/$1/o;
        $str = "" unless( defined( $str ) );
        # with delay, handle functions in result recursively and clean up unbalanced parenthesis
        $str =~ s/\$([A-Z]+)$escToken([0-9]+)\((.*?)$escToken\2\)/&doFunc($1,$3)/geo;
        $str =~ s/$escToken\-*[0-9]+([\(\)])/$1/go;
        my $item = "";
        my $eval = "";
        $i = 0;
        my @arr =
            grep { ! /^TWIKI_GREP_REMOVE$/ }
            map {
                $item = $_;
                $_ = $cmd;
                $i++;
                s/\$index/$i/go;
                s/\$item/$item/go;
                s/\$([A-Z]+)$escToken([0-9]+)\((.*?)$escToken\2\)/&doFunc($1,$3)/geo;
                s/$escToken\-*[0-9]+([\(\)])/$1/go;
                $eval = safeEvalPerl( $_ );
                if( $eval =~ /^ERROR/ ) {
                    $_ = $eval;
                } elsif( $eval ) {
                    $_ = $item;
                } else {
                    $_ = "TWIKI_GREP_REMOVE";
                }
            } getList( $str );
        $result = _listToDelimitedString( @arr );

    } elsif ( $theFunc eq "NOP" ) {
        # pass everything through, this will allow plugins to defy plugin order
        # for example the %SEARCH{}% variable
        $theAttr =~ s/\$per/%/g;
        $result = $theAttr;

    } elsif ( $theFunc eq "EXISTS" ) {
        $result = TWiki::Func::topicExists( "", $theAttr );
        $result = 0 unless( $result );
    }

    TWiki::Func::writeDebug( "- SpreadSheetPlugin::Calc::doFunc: $theFunc( $theAttr ) returns: $result" ) if $debug;
    return $result;
}

# =========================
sub _listToDelimitedString
{
    my @arr = map { s/^\s*//o; s/\s*$//o; $_ } @_;
    my $text = join( ", ", @arr );
    return $text;
}

# =========================
sub _properSplit
{
    my( $theAttr, $theLevel ) = @_;

    # escape commas inside functions
    $theAttr =~ s/(\$[A-Z]+$escToken([0-9]+)\(.*?$escToken\2\))/_escapeCommas($1)/geo;
    # split at commas and restore commas inside functions
    my @arr = map{ s/<$escToken>/\,/go; $_ } split( /,\s*/, $theAttr, $theLevel );
    return @arr;
}

# =========================
sub _escapeCommas
{
    my( $theText ) = @_;
    $theText =~ s/\,/<$escToken>/go;
    return $theText;
}

# =========================
sub _getNumber
{
    my( $theText ) = @_;
    return 0 unless( $theText );
    $theText =~ s/([0-9])\,(?=[0-9]{3})/$1/go;          # "1,234,567" ==> "1234567"
    unless( $theText =~ s/^.*?(\-?[0-9\.]+).*$/$1/o ) { # "xy-1.23zz" ==> "-1.23"
        $theText = 0;
    }
    $theText =~ s/^(\-?)0+([0-9])/$1$2/o;               # "-0009.12"  ==> "-9.12"
    $theText =~ s/^(\-?)\./${1}0\./o;                   # "-.25"      ==> "-0.25"
    $theText =~ s/^\-0$/0/o;                            # "-0"        ==> "0"
    return $theText;
}

# =========================
sub safeEvalPerl
{
    my( $theText ) = @_;

    # Allow only simple math with operators - + * / % ( )
    $theText =~ s/\%\s*[^\-\+\*\/0-9\.\(\)]+//go; # defuse %hash but keep modulus
    # keep only numbers and operators (shh... don't tell anyone, we support comparison operators)
    $theText =~ s/[^\!\<\=\>\-\+\*\/\%0-9\.\(\)]*//go;
    $theText =~ /(.*)/;
    $theText = $1;  # untainted variable
    return "" unless( $theText );
    local $SIG{__DIE__} = sub { TWiki::Func::writeDebug($_[0]); warn $_[0] };
    my $result = eval $theText;
    if( $@ ) {
        $result = $@;
        $result =~ s/[\n\r]//go;
        $result =~ s/\[[^\]]+.*view.*?\:\s?//o;                   # Cut "[Mon Mar 15 23:31:39 2004] view: "
        $result =~ s/\s?at \(eval.*?\)\sline\s?[0-9]*\.?\s?//go;  # Cut "at (eval 51) line 2."
        $result = "ERROR: $result";

    } else {
        $result = 0 unless( $result );  # logical false is "0"
    }
    return $result;
}

# =========================
sub getListAsInteger
{
    my( $theAttr ) = @_;

    my $val = 0;
    my @list = getList( $theAttr );
    (my $baz = "foo") =~ s/foo//;  # reset search vars. defensive coding
    for my $i (0 .. $#list ) {
        $val = $list[$i];
        # search first integer pattern, skip over HTML tags
        if( $val =~ /^\s*(?:<[^>]*>)*([\-\+]*[0-9]+).*/o ) {
            $list[$i] = $1;  # untainted variable, possibly undef
        } else {
            $list[$i] = undef;
        }
    }
    return @list;
}

# =========================
sub getListAsFloat
{
    my( $theAttr ) = @_;

    my $val = 0;
    my @list = getList( $theAttr );
    (my $baz = "foo") =~ s/foo//;  # reset search vars. defensive coding
    for my $i (0 .. $#list ) {
        $val = $list[$i] || "";
        # search first float pattern, skip over HTML tags
        if( $val =~ /^\s*(?:<[^>]*>)*\$?([\-\+]*[0-9\.]+).*/o ) {
            $list[$i] = $1;  # untainted variable, possibly undef
        } else {
            $list[$i] = undef;
        }
    }
    return @list;
}

# =========================
sub getListAsDays
{
    my( $theAttr ) = @_;

    # contributed by by SvenDowideit - 07 Mar 2003; modified by PTh
    my $val = 0;
    my @arr = getList( $theAttr );
    (my $baz = "foo") =~ s/foo//;  # reset search vars. defensive coding
    for my $i (0 .. $#arr ) {
        $val = $arr[$i] || "";
        # search first float pattern
        if( $val =~ /^\s*([\-\+]*[0-9\.]+)\s*d/oi ) {
            $arr[$i] = $1;      # untainted variable, possibly undef
        } elsif( $val =~ /^\s*([\-\+]*[0-9\.]+)\s*w/oi ) {
            $arr[$i] = 5 * $1;  # untainted variable, possibly undef
        } elsif( $val =~ /^\s*([\-\+]*[0-9\.]+)\s*h/oi ) {
            $arr[$i] = $1 / 8;  # untainted variable, possibly undef
        } elsif( $val =~ /^\s*([\-\+]*[0-9\.]+)/o ) {
            $arr[$i] = $1;      # untainted variable, possibly undef
        } else {
            $arr[$i] = undef;
        }
    }
    return @arr;
}

# =========================
sub getList
{
    my( $theAttr ) = @_;

    my @list = ();
    foreach( split( /,\s*/, $theAttr ) ) {
        if( m/\s*R([0-9]+)\:C([0-9]+)\s*\.\.+\s*R([0-9]+)\:C([0-9]+)/ ) {
            # table range
            push( @list, getTableRange( $_ ) );
        } else {
            # list item
            $list[$#list+1] = $_;
        }
    }
    return @list;
}

# =========================
sub getTableRange
{
    my( $theAttr ) = @_;

    my @arr = ();
    if( $rPos < 0 ) {
        return @arr;
    }

    TWiki::Func::writeDebug( "- SpreadSheetPlugin::Calc::getTableRange( $theAttr )" ) if $debug;
    unless( $theAttr =~ /\s*R([0-9]+)\:C([0-9]+)\s*\.\.+\s*R([0-9]+)\:C([0-9]+)/ ) {
        return @arr;
    }
    my $r1 = $1 - 1;
    my $c1 = $2 - 1;
    my $r2 = $3 - 1;
    my $c2 = $4 - 1;
    my $r = 0;
    my $c = 0;
    if( $c1 < 0     ) { $c1 = 0; }
    if( $c2 < 0     ) { $c2 = 0; }
    if( $c2 < $c1   ) { $c = $c1; $c1 = $c2; $c2 = $c; }
    if( $r1 > $rPos ) { $r1 = $rPos; }
    if( $r1 < 0     ) { $r1 = 0; }
    if( $r2 > $rPos ) { $r2 = $rPos; }
    if( $r2 < 0     ) { $r2 = 0; }
    if( $r2 < $r1   ) { $r = $r1; $r1 = $r2; $r2 = $r; }

    my $pRow = ();
    for $r ( $r1 .. $r2 ) {
        $pRow = $tableMatrix[$r];
        for $c ( $c1 .. $c2 ) {
            if( $c < @$pRow ) {
                push( @arr, $$pRow[$c] );
            }
        }
    }
    TWiki::Func::writeDebug( "- SpreadSheetPlugin::Calc::getTableRange() returns @arr" ) if $debug;
    return @arr;
}

# =========================
sub _date2serial
{
    my ( $theText ) = @_;

    my $sec = 0; my $min = 0; my $hour = 0; my $day = 1; my $mon = 0; my $year = 0;

    if( $theText =~ m|([0-9]{1,2})[-\s/]+([A-Z][a-z][a-z])[-\s/]+([0-9]{4})[-\s/]+([0-9]{1,2}):([0-9]{1,2})| ) {
        # "31 Dec 2003 - 23:59", "31-Dec-2003 - 23:59", "31 Dec 2003 - 23:59 - any suffix"
        $day = $1; $mon = $mon2num{$2} || 0; $year = $3 - 1900; $hour = $4; $min = $5;
    } elsif( $theText =~ m|([0-9]{1,2})[-\s/]+([A-Z][a-z][a-z])[-\s/]+([0-9]{2,4})| ) {
        # "31 Dec 2003", "31 Dec 03", "31-Dec-2003", "31/Dec/2003"
        $day = $1; $mon = $mon2num{$2} || 0; $year = $3;
        $year += 100 if( $year < 80 );      # "05"   --> "105" (leave "99" as is)
        $year -= 1900 if( $year >= 1900 );  # "2005" --> "105"
    } elsif( $theText =~ m|([0-9]{4})[-/\.]([0-9]{1,2})[-/\.]([0-9]{1,2})[-/\.\,\s]+([0-9]{1,2})[-\:/\.]([0-9]{1,2})[-\:/\.]([0-9]{1,2})| ) {
        # "2003/12/31 23:59:59", "2003-12-31-23-59-59", "2003.12.31.23.59.59"
        $year = $1 - 1900; $mon = $2 - 1; $day = $3; $hour = $4; $min = $5; $sec = $6;
    } elsif( $theText =~ m|([0-9]{4})[-/\.]([0-9]{1,2})[-/\.]([0-9]{1,2})[-/\.\,\s]+([0-9]{1,2})[-\:/\.]([0-9]{1,2})| ) {
        # "2003/12/31 23:59", "2003-12-31-23-59", "2003.12.31.23.59"
        $year = $1 - 1900; $mon = $2 - 1; $day = $3; $hour = $4; $min = $5;
    } elsif( $theText =~ m|([0-9]{4})[-/]([0-9]{1,2})[-/]([0-9]{1,2})| ) {
        # "2003/12/31", "2003-12-31"
        $year = $1 - 1900; $mon = $2 - 1; $day = $3;
    } elsif( $theText =~ m|([0-9]{1,2})[-/]([0-9]{1,2})[-/]([0-9]{2,4})| ) {
        # "12/31/2003", "12/31/03", "12-31-2003"
        # (shh, don't tell anyone that we support ambiguous American dates, my boss asked me to)
        $year = $3; $mon = $1 - 1; $day = $2;
        $year += 100 if( $year < 80 );      # "05"   --> "105" (leave "99" as is)
        $year -= 1900 if( $year >= 1900 );  # "2005" --> "105"
    } else {
        # unsupported format
        return 0;
    }
    if( ( $sec > 60 ) || ( $min > 59 ) || ( $hour > 23 ) || ( $day < 1 ) || ( $day > 31 ) || ( $mon > 11 ) ) {
        # unsupported, out of range
        return 0;
    }
    if( $theText =~ /gmt/i ) {
        return timegm( $sec, $min, $hour, $day, $mon, $year );
    } else {
        return timelocal( $sec, $min, $hour, $day, $mon, $year );
    }
}

# =========================
sub _serial2date
{
    my ( $theTime, $theStr, $isGmt ) = @_;

    my( $sec, $min, $hour, $day, $mon, $year, $wday, $yday ) = localtime( $theTime );
    (   $sec, $min, $hour, $day, $mon, $year, $wday, $yday ) = gmtime( $theTime ) if( $isGmt );

    $theStr =~ s/\$sec[o]?[n]?[d]?[s]?/sprintf("%.2u",$sec)/geoi;
    $theStr =~ s/\$min[u]?[t]?[e]?[s]?/sprintf("%.2u",$min)/geoi;
    $theStr =~ s/\$hou[r]?[s]?/sprintf("%.2u",$hour)/geoi;
    $theStr =~ s/\$day/sprintf("%.2u",$day)/geoi;
    $theStr =~ s/\$mon(?!t)/$monArr[$mon]/goi;
    $theStr =~ s/\$mo[n]?[t]?[h]?/sprintf("%.2u",$mon+1)/geoi;
    $theStr =~ s/\$yearday/$yday+1/geoi;
    $theStr =~ s/\$yea[r]?/sprintf("%.4u",$year+1900)/geoi;
    $theStr =~ s/\$ye/sprintf("%.2u",$year%100)/geoi;
    $theStr =~ s/\$wday/substr($wdayArr[$wday],0,3)/geoi;
    $theStr =~ s/\$wd/$wday+1/geoi;
    $theStr =~ s/\$weekday/$wdayArr[$wday]/goi;

    return $theStr;
}

# =========================
sub _properSpace
{
    my ( $theStr ) = @_;

    # FIXME: I18N

    unless( $dontSpaceRE ) {
        $dontSpaceRE = &TWiki::Func::getPreferencesValue( "DONTSPACE" ) ||
                       &TWiki::Func::getPreferencesValue( "SPREADSHEETPLUGIN_DONTSPACE" ) ||
                       "UnlikelyGibberishWikiWord";
        $dontSpaceRE =~ s/[^a-zA-Z0-9\,\s]//go;
        $dontSpaceRE = "(" . join( "|", split( /[\,\s]+/, $dontSpaceRE ) ) . ")";
        # Example: "(RedHat|McIntosh)"
    }
    $theStr =~ s/$dontSpaceRE/_spaceWikiWord( $1, "<DONT_SPACE>" )/geo;  # e.g. "Mc<DONT_SPACE>Intosh"
    $theStr =~ s/(^|[\s\(]|\]\[)([a-zA-Z0-9]+)/$1 . _spaceWikiWord( $2, " " )/geo;
    $theStr =~ s/<DONT_SPACE>//go;  # remove "<DONT_SPACE>" marker

    return $theStr;
}

# =========================
sub _spaceWikiWord
{
    my ( $theStr, $theSpacer ) = @_;

    $theStr =~ s/([a-z])([A-Z0-9])/$1$theSpacer$2/go;
    $theStr =~ s/([0-9])([a-zA-Z])/$1$theSpacer$2/go;

    return $theStr;
}

# =========================
sub _workingDays
{
    my ( $start, $end ) = @_;

    # Contributed by CrawfordCurrie - 17 Jul 2004
    # Calculate working days between two times. Times are standard system times (secs since 1970). 
    # Working days are Monday through Friday (sorry, Israel!)

    use integer;
    my $elapsed_days = ( $end - $start ) / ( 60 * 60 * 24 );
    # total number of elapsed 7-day weeks
    my $whole_weeks = $elapsed_days / 7;
    my $extra_days = $elapsed_days - ( $whole_weeks * 7 );
    if( $extra_days > 0 ) {
      my @lt = gmtime( $start );
      my $wday = $lt[6]; # weekday, 0 is sunday

      if( $wday == 0 ) {
        $extra_days-- if( $extra_days > 0 );
      } else {
        $extra_days-- if( $extra_days > ( 6 - $wday ) );
        $extra_days-- if( $extra_days > ( 6 - $wday ) );
      }
    }
    return $whole_weeks * 5 + $extra_days;
}

# =========================

1;

# EOF
