package Convert::IBM390;

use Carp;
use POSIX qw(mktime);
use strict;
use vars qw($VERSION @ISA @EXPORT @EXPORT_OK %EXPORT_TAGS);

require Exporter;

@ISA = qw(Exporter);
@EXPORT = qw();
@EXPORT_OK = qw(asc2eb eb2asc eb2ascp packeb unpackeb hexdump
  packed2num num2packed zoned2num num2zoned fcs_xlate
  set_codepage set_translation);
$VERSION = '0.27';

%EXPORT_TAGS = ( all => [ @EXPORT_OK ] );

# $warninv = issue warning message if a field is invalid.  Default
# is FALSE (don't issue the message).  Used by packed2num, num2packed,
# zoned2num, num2zoned.
$Convert::IBM390::warninv = 0;

my ($a2e_table, $e2a_table, $e2ap_table);
$a2e_table = pack "H512",
 "00010203372d2e2f1605150b0c0d0e0f101112133c3d322618193f271c1d1e1f".
 "405a7f7b5b6c507d4d5d5c4e6b604b61f0f1f2f3f4f5f6f7f8f97a5e4c7e6e6f".
 "7cc1c2c3c4c5c6c7c8c9d1d2d3d4d5d6d7d8d9e2e3e4e5e6e7e8e9ade0bd5f6d".
 "79818283848586878889919293949596979899a2a3a4a5a6a7a8a9c04fd0a107".
 "202122232425061728292a2b2c090a1b30311a333435360838393a3b04143eff".
 "41aa4ab19fb26ab5bbb49a8ab0caafbc908feafabea0b6b39dda9b8bb7b8b9ab".
 "6465626663679e687471727378757677ac69edeeebefecbf80fdfefbfcbaae59".
 "4445424643479c4854515253585556578c49cdcecbcfcce170dddedbdc8d8edf";

$e2a_table = pack "H512",
 "000102039c09867f978d8e0b0c0d0e0f101112139d0a08871819928f1c1d1e1f".
 "808182838485171b88898a8b8c050607909116939495960498999a9b14159e1a".
 "20a0e2e4e0e1e3e5e7f1a22e3c282b7c26e9eaebe8edeeefecdf21242a293b5e".
 "2d2fc2c4c0c1c3c5c7d1a62c255f3e3ff8c9cacbc8cdcecfcc603a2340273d22".
 "d8616263646566676869abbbf0fdfeb1b06a6b6c6d6e6f707172aabae6b8c6a4".
 "b57e737475767778797aa1bfd05bdeaeaca3a5b7a9a7b6bcbdbedda8af5db4d7".
 "7b414243444546474849adf4f6f2f3f57d4a4b4c4d4e4f505152b9fbfcf9faff".
 "5cf7535455565758595ab2d4d6d2d3d530313233343536373839b3dbdcd9da9f";

$e2ap_table =
  ' ' x 64 .
  '           .<(+|&         !$*); -/         ,%_>?         `:#@\'="'.
  ' abcdefghi       jklmnopqr       ~stuvwxyz   [               ]  '.
  '{ABCDEFGHI      }JKLMNOPQR      \\ STUVWXYZ      0123456789      ';

# Days Before This Month, Leap and Common years
my @dbtm_com =
  ( 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 );
my @dbtm_leap =
  ( 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335 );

# ASCII to EBCDIC
sub asc2eb {
 my $String = shift;
 return "" if $String eq "";
 return fcs_xlate($String, $a2e_table);
}

# EBCDIC to ASCII
sub eb2asc {
 my $String = shift;
 return "" if $String eq "";
 return fcs_xlate($String, $e2a_table);
}

# EBCDIC to ASCII printable
sub eb2ascp {
 my $String = shift;
 return "" if $String eq "";
 return fcs_xlate($String, $e2ap_table);
}

# Pack a Perl list into an EBCDIC record (structure).
sub packeb {
 my ($template, @inlist) = @_;
 my ($datumtype, $len, $star, $ndec, $item, $ebstring, $padl);

 my $espace = "\x40";  # EBCDIC space
 my $nspace = ' ';     # Native space
 my $ii = 0;
 my $tp = 0;  # Template position -- where are we in the template?
 my $result = "";

 while ($tp < length($template)) {
# Have we gone past the end of the list of values?  If so, stop.
    last if $ii >= @inlist;

    $star = ' ';  # '*' if a star is found, blank otherwise
    $datumtype = substr($template, $tp, 1);
    $tp++;
    next if $datumtype =~ /\s/;
    if (substr($template, $tp, 1) eq '*') {
       $star = '*';
       $len = ($datumtype =~ /[pz]/) ? 8 :
         ($datumtype =~ /[x\@]/) ? 0 : @inlist - $ii;
       $tp++;
    } elsif (substr($template, $tp, 1) =~ /\d/) {
       substr($template, $tp) =~ m/^(\d+)/;
       $len = $1;  $tp += length($len);
# Decimal places (this result will be ignored if the datumtype
# is not packed or zoned).
       $ndec = 0;
       if (substr($template, $tp, 1) eq '.') {
          $tp++;
          substr($template, $tp) =~ m/^(\d+)/;
          $ndec = $1;  $tp += length($ndec);
       }
    } else {
       $len = ($datumtype =~ /[pz]/) ? 8 : 1;
    }

    if ($len > 32767) {
      Carp::croak("Field length too large in packeb: $datumtype$len");
    }
    $_ = $datumtype;
    DSWITCH: {
     if (/\@/) {  # Here $len is really an offset.
        my $Lr = length($result);
        if ($len > $Lr) {	# Grow
           $result .= "\x00" x ($len - $Lr);
        } elsif ($len < $Lr) {	# Shrink
           $result = substr($result, 0, $len);
        } else { ; }
        last DSWITCH;
     }
     if (/x/) {
        $result .= "\x00" x $len;
        last DSWITCH;
     }
     # [Ee]:  EBCDIC character string
     if (/[Ee]/) {
        $item = $inlist[$ii];  $ii++;
        $len = length($item) if $star eq '*';
        $ebstring = asc2eb($item);
        $padl = $len - length($ebstring);
        if ($padl == 0) { ; }
        elsif ($padl < 0) {
           $ebstring = substr($ebstring, 0, $len);
        } else {
           if ($datumtype eq 'E') {  # Pad with EBCDIC spaces
              $ebstring .= $espace x $padl;
           } else {
              $ebstring .= "\x00" x $padl;
           }
        }
        $result .= $ebstring;
        last DSWITCH;
     }
     # [Cc]: characters without translation.  Same as Perl's [Aa].
     if (/[Cc]/) {
        $item = $inlist[$ii];  $ii++;
        $len = length($item) if $star eq '*';
        if ($datumtype eq 'C') {
           $result .= pack("A$len", $item);
        } else {
           $result .= pack("a$len", $item);
        }
        last DSWITCH;
     }

     # [pP]: S/390 packed decimal.  $len is a field length.
     if (/[pP]/) {
        Carp::croak("Field length too large in packeb: $datumtype$len")
            if $len > 16;
        $item = $inlist[$ii];  $ii++;
        $result .= num2packed($item, $len, $ndec, $datumtype eq 'P');
        last DSWITCH;
     }

     # i: S/390 fullword (signed). */
     if (/i/) {
        for (my $j = 0; $j < $len; $j++) {
           $item = $inlist[$ii];  $ii++;
           $result .= pack("N", $item);
        }
        last DSWITCH;
     }
     # s: S/390 halfword (signed). */
     if (/s/) {
        for (my $j = 0; $j < $len; $j++) {
           $item = $inlist[$ii];  $ii++;
           $result .= pack("n", $item);
        }
        last DSWITCH;
     }
     # S: S/390 halfword (unsigned). */
     if (/S/) {
        for (my $j = 0; $j < $len; $j++) {
           $item = $inlist[$ii];  $ii++;
           $result .= substr(pack("N", $item), 2,2);
        }
        last DSWITCH;
     }

     # [zZ]: S/390 zoned decimal.  $len is a field length.
     if (/[zZ]/) {
        Carp::croak("Field length too large in packeb: z$len") if $len > 32;
        $item = $inlist[$ii];  $ii++;
        $result .= num2zoned($item, $len, $ndec, $datumtype eq 'Z');
        last DSWITCH;
     }
     # [Hh]: hex, high-order nybble always first
     if (/[Hh]/) {
        $item = $inlist[$ii];  $ii++;
        $len = length($item) if $star eq '*';
        $result .= pack("H$len", $item);
        last DSWITCH;
     }

     Carp::croak("Invalid type in packeb: '$datumtype'");
    }
 }
 return $result;
}

# Unpack an EBCDIC record into a Perl list.
sub unpackeb {
 my ($template, $ebrecord) = @_;
 my ($datumtype, $len, $ndec, $brem);

 my $s = 0;   # Points to current position within $ebrecord
 my $tp = 0;  # Template position -- where are we in the template?
 my @rlist = ();	# Result list

 while ($tp < length($template)) {
    $datumtype = substr($template, $tp, 1);
    $tp++;
# Have we gone past the end of the input?  If so, stop, unless they
# want to reposition within the record.
    last if $s >= length($ebrecord) && $datumtype ne '@';

    next if $datumtype =~ /\s/;
    $ndec = 0;
    if (substr($template, $tp, 1) eq '*') {
       $len = length($ebrecord) - $s;
       $len = int($len / 4)  if $datumtype =~ /[iI]/;
       $len = int($len / 2)  if $datumtype =~ /[sS]/;
       $tp++;
    } elsif (substr($template, $tp, 1) =~ /\d/) {
       substr($template, $tp) =~ m/^(\d+)/;
       $len = $1;  $tp += length($len);
# Decimal places (this result will be ignored if the datumtype
# is not packed or zoned).
       $ndec = 0;
       if (substr($template, $tp, 1) eq '.') {
          $tp++;
          substr($template, $tp) =~ m/^(\d+)/;
          $ndec = $1;  $tp += length($ndec);
       }
    } else {
       $len = 1;
    }
    if ($len > 32767) {
       Carp::croak("Field length too large in unpackeb: $datumtype$len");
    }

    $_ = $datumtype;
    $brem = length($ebrecord) - $s;  # Bytes REMaining
    DSWITCH: {
     # @: absolute offset
     if (/\@/) {
        if ($len >= length($ebrecord) || $len < 0) {
           Carp::croak("Absolute position is outside string: \@$len");
        }
        $s = $len;
        last DSWITCH;
     }

     # [Ee]:  EBCDIC character string.  $len is a field length.
     if (/[Ee]/) {
        $len = $brem  if $len > $brem;
        $a = eb2asc(substr($ebrecord, $s, $len));
        $a =~ s/[\0 ]+$//  if $datumtype eq 'E';
        push @rlist, $a;
        $s += $len;
        last DSWITCH;
     }

     # p: S/390 packed decimal.  $len is a field length.
     if (/p/) {
        $len = $brem  if $len > $brem;
        if ($len > 16) {
           Carp::croak("Field length too large in unpackeb: p$len");
        }
        push @rlist, packed2num(substr($ebrecord, $s, $len), $ndec);
        $s += $len;
        last DSWITCH;
     }
     # z: S/390 zoned decimal.  $len is a field length.
     if (/z/) {
        $len = $brem  if $len > $brem;
        if ($len > 32) {
           Carp::croak("Field length too large in unpackeb: z$len");
        }
        push @rlist, zoned2num(substr($ebrecord, $s, $len), $ndec);
        $s += $len;
        last DSWITCH;
     }
     # [Cc]: characters without translation
     if (/[Cc]/) {
        $len = $brem  if $len > $brem;
        push @rlist, substr($ebrecord, $s, $len);
        $s += $len;
        last DSWITCH;
     }

     # i: signed integer (System/390 fullword)
     if (/i/) {
        $len = int($brem / 4)  if $len > int($brem / 4);
        for (my $i = 0; $i < $len; $i++) {
           my @byt = unpack('cC3', substr($ebrecord, $s, 4));
           push @rlist, (16777216 * $byt[0] + 65536 * $byt[1] +
             256 * $byt[2] + $byt[3]);
           $s += 4;
        }
        last DSWITCH;
     }
     # s: signed short integer (System/390 halfword)
     if (/s/) {
        $len = int($brem / 2)  if $len > int($brem / 2);
        for (my $i = 0; $i < $len; $i++) {
           my @byt = unpack('cC', substr($ebrecord, $s, 2));
           push @rlist, (256 * $byt[0] + $byt[1]);
           $s += 2;
        }
        last DSWITCH;
     }

     # [hH]: unpack to printable hex digits
     if (/[hH]/) {
        $len = $brem * 2  if $len > $brem * 2;
        my $bytes = int($len/2);
        push @rlist, unpack("H$len", substr($ebrecord, $s, $bytes));
        $s += $bytes;
        last DSWITCH;
     }

     # v: varchar EBCDIC character string; i.e., a string of
     # EBCDIC characters preceded by a halfword length field (as
     # in DB2/MVS, for instance).  $len here is a repeat count,
     # but don't go beyond the end of the record.
     if (/v/) {
        for (my $i=0; $i < $len; $i++) {
            last if $len > $brem;
            my @byt = unpack('cC', substr($ebrecord, $s, 2));
            my $fieldlen = 256 * $byt[0] + $byt[1];
            $s += 2;
            $brem = length($ebrecord) - $s;

            $fieldlen = $brem  if $fieldlen > $brem;
            if ($fieldlen < 0) {
               push @rlist, undef();
            } elsif ($fieldlen == 0) {
               push @rlist, "";
            } else {
               push @rlist, eb2asc(substr($ebrecord, $s, $fieldlen));
            }
            $s += $fieldlen;
            $brem = length($ebrecord) - $s;
        }
        last DSWITCH;
     }
     # x: ignore these bytes (do not return an element)
     if (/x/) {
        $len = $brem  if $len > $brem;
        $s += $len;
        last DSWITCH;
     }

     # I: unsigned integer (4 bytes).  Same as Perl's 'N'.
     if (/I/) {
        $len = int($brem / 4)  if $len > int($brem / 4);
        for (my $i = 0; $i < $len; $i++) {
           push @rlist, unpack('N', substr($ebrecord, $s, 4));
           $s += 4;
        }
        last DSWITCH;
     }
     # S: unsigned short integer (2 bytes).  Same as Perl's 'n'.
     if (/S/) {
        $len = int($brem / 2)  if $len > int($brem / 2);
        for (my $i = 0; $i < $len; $i++) {
           push @rlist, unpack('n', substr($ebrecord, $s, 2));
           $s += 2;
        }
        last DSWITCH;
     }

     Carp::croak("Invalid type in unpackeb: '$datumtype'");
    }
 }

 return (wantarray) ? @rlist : $rlist[0];
}


# Print an entire string in hexdump format, 32 bytes at a time
# (like a sysabend dump).
sub hexdump {
 my ($String, $startad, $charset) = @_;
 $startad ||= 0;
 $charset ||= "ascii";
 my ($i, $j, $d, $str, $pri, $hexes);
 my @outlines = ();
 my $L = length($String);
 for ($i = 0; $i < $L; $i += 32) {
    $str = substr($String, $i,32);
#   Generate a printable version of the string.
    if ($charset =~ m/ebc/i) {
       $pri = eb2ascp $str;
    } else {
       $pri = $str;
       $pri =~ tr/\000-\037\177-\377/ /;
    }
    $hexes = unpack("H64", $str);
    $hexes =~ tr/a-f/A-F/;
    if (($L - $i) < 32) {   # Pad with blanks if necessary.
       $pri = pack("A32", $pri);
       $hexes = pack("A64", $hexes);
    }
    $d = sprintf("%06X: ", $startad + $i);
    for ($j = 0; $j < 64; $j += 8) {
       $d .= substr($hexes, $j, 8) . " ";
       $d .= " " if $j == 24;
    }
    $d .= " *$pri*\n";
    push @outlines, $d;
 }
 return @outlines;
}

# Convert a Packed Decimal field to a Perl number.
sub packed2num {
 my ($packed, $ndec) = @_;
 $ndec ||= 0;
 my ($w, $xdigits, $arabic, $sign);
 $w = 2 * length($packed);
 $xdigits = unpack("H$w", $packed);
 $arabic = substr($xdigits, 0, $w-1);
 $sign = substr($xdigits, $w-1, 1);
 if ( $arabic !~ /^\d+$/ || $sign !~ /^[a-f]$/ ) {
    Carp::carp "packed2num: Invalid packed value $xdigits"
      if $Convert::IBM390::warninv;
    return undef();
 }
 $arabic = 0 - $arabic  if $sign =~ /[bd]/;
 $arabic /= 10 ** $ndec  if $ndec != 0;
 return $arabic + 0;
}

# Convert a Perl number to a packed field.
sub num2packed {
 my ($num, $outwidth, $ndec, $fsign) = @_;
 $outwidth ||= 8;
 $ndec ||= 0;
 if ( $num !~ /^([+-]?)(?=\d|\.\d)\d*(\.\d*)?([Ee]([+-]?\d+))?$/ ) {
    Carp::carp "num2packed: Input is not a number"
      if $Convert::IBM390::warninv;
    return undef();
 }
 my ($outdig, $digits, $sign);
 $outdig = $outwidth * 2 - 1;
# sprintf will round to the appropriate number of places.
 $digits = sprintf("%0${outdig}.0f", abs($num * (10 ** $ndec)));
 Carp::croak("Number $num too long for packed decimal")
    if length($digits) > 31;
 $digits = substr($digits, -$outdig);
 $sign = ($num >= 0) ? (($fsign) ? "F" : "C") : "D";
 $outwidth *= 2;
 return pack("H$outwidth", $digits . $sign);
}

# Convert a Zoned Decimal field to a Perl number.
sub zoned2num {
 my ($zoned, $ndec) = @_;
 $ndec ||= 0;
 my ($w, $digits, $sign, $final);
 if ($zoned =~ m/[\xD0-\xD9]/) { $sign = -1; }
 else  { $sign = 1; }
 $zoned = eb2asc($zoned);
 $zoned =~ tr/ {ABCDEFGHI}JKLMNOPQR/001234567890123456789/;
 if ( $zoned !~ /^\d+$/ ) {
    Carp::carp "zoned2num: Invalid zoned value $zoned"
      if $Convert::IBM390::warninv;
    return undef();
 }
 $final = $sign * $zoned;
 $final /= 10 ** $ndec  if $ndec != 0;
 return $final + 0;
}

# Convert a Perl number to a zoned field.
# Last arg: use F instead of C for positives?
sub num2zoned {
 my ($num, $outwidth, $ndec, $unsigned) = @_;
 $outwidth ||= 8;
 $ndec ||= 0;
 if ( $num !~ /^([+-]?)(?=\d|\.\d)\d*(\.\d*)?([Ee]([+-]?\d+))?$/ ) {
    Carp::carp "num2zoned: Input is not a number"
      if $Convert::IBM390::warninv;
    return undef();
 }
 my ($digits, $sign);
# sprintf will round to the appropriate number of places.
 $digits = sprintf("%0${outwidth}.0f", abs($num * (10 ** $ndec)));
 Carp::croak("Number $num too long for zoned decimal")
    if length($digits) > 31;
 $digits = substr($digits, -$outwidth);
 my $last = length($digits) - 1;
 unless ($unsigned) {
    if ($num >= 0) {
       substr($digits, $last, 1) =~ tr/0123456789/{ABCDEFGHI/;
    } else {
       substr($digits, $last, 1) =~ tr/0123456789/}JKLMNOPQR/;
    }
 }
 return asc2eb($digits);
}

# Full Collating Sequence Translate -- like tr///, but assumes that
# the searchstring is a complete 8-bit collating sequence
# (x'00' - x'FF').  I couldn't get tr to do this, and I have my
# doubts about whether it would be possible on systems where char
# is signed.  This approach works on AIX, where char is unsigned,
# and at least has a fighting chance of working elsewhere.
# The second argument is one of the translation tables defined
# above ($a2e_table, etc.).
sub fcs_xlate {
 my ($instring, $to_table) = @_;
 my ($i, $outstring);
 $outstring = "";
 for ($i = 0; $i < length($instring); $i++) {
    $outstring .= substr($to_table, ord(substr($instring, $i,1)), 1);
 }
 return $outstring;
}

sub _set_translation {
  die "Invalid tables" if @_ != 3 or grep { length($_) != 256 } @_;
  ($a2e_table, $e2a_table, $e2ap_table) = @_;
}

sub version {
 return "Convert::IBM390 version $VERSION Perl only";
}
