package Import::Importer;

use strict;
use lib '/app/tools/common/lib';
use Common::Log;
use Common::Assert;

use lib '/app/tools/sale_import/lib';
use Import::Date;


use constant SALES_BATCH_INSERT_MODE => 0;        # disabled by default
use constant SALES_BATCH_INSERT_SIZE => 5_000;    # default value


# Common accessors
sub sheetname { shift->{sheetname} }
sub sheetnum  { shift->{sheetnum} }    # Note: returns 1 for first sheet, not zero
sub linenum   { shift->{linenum} }
sub line      { shift->{line} }
sub filename  { shift->{filename} }
sub version   { shift->{version} }

# Service ID is generally determined by the file.  However this method can be overloaded
# for distribution services.
sub serviceID { shift->{serviceID} }

# These are combined into one method because begin and end dates are normalized.
# Generally the individual accessors won't be called.
sub dateBegin { my ($date) = shift->saleDates(); return $date }
sub dateEnd { my ( undef, $date ) = shift->saleDates(); return $date }

sub saleDates {
    my $self = shift;

    my $dateBegin;
    my $dateEnd;

    my $startDate = $self->_getDateBegin();
    my $endDate   = $self->_getDateEnd();

    if ($startDate) {
        my %args = ( date_begin => $self->_formatDate($startDate) );
        $args{date_end} = $self->_formatDate($endDate) if ($endDate);
        $args{type}     = $self->_dateFormat()         if ( $self->_dateFormat() );

        ( $dateBegin, $dateEnd ) = $self->_getDates(%args);
    }

    return ( $dateBegin, $dateEnd );
}

sub _getDateBegin { shift->_getByFieldName('dateBegin') }
sub _getDateEnd   { shift->_getByFieldName('dateEnd') }

# should be overridden
sub importerType { 'base' }

# These two routines configure sales batch inserts
sub salesBatchMode {
    my ($self, $flag) = @_;
    # enables/disables sales insert batch mode

    # Default is SALES_BATCH_INSERT_MODE (disabled)
    $self->{_SALES_BATCH_INSERT_MODE} = SALES_BATCH_INSERT_MODE;

    # Check for setting in the client_options table
    if ( $self->_getClientOptionByName('batch_sales_insert') ) {
        $self->{_SALES_BATCH_INSERT_MODE} = 1;
    }

    if ( defined $flag ) {
        $self->{_SALES_BATCH_INSERT_MODE} = $flag > 0 ? 1 : SALES_BATCH_INSERT_MODE;
    }

    return $self->{_SALES_BATCH_INSERT_MODE};
}

sub salesBatchSize {
    my ($self, $value) = @_;
    # Default is SALES_BATCH_INSERT_SIZE
    if ( defined $value ) {
        $self->{_sales_batch_insert_size} = $value > 0 ? $value : SALES_BATCH_INSERT_SIZE;
    }

    return $self->{_sales_batch_insert_size} // SALES_BATCH_INSERT_SIZE;
}

sub _getClientOptionByName {
    my $self = shift;
    # should be redefined, default nothing
    return;
}


# For now, this class will just be a stub.  It'll provide the constructor, and that's about it.
#

sub new {
    my $class = shift;
    my %args  = @_;

    my $self = bless {}, $class;
    return $self->_init(%args);
}

sub _init {
    my ( $self, %args ) = @_;

    $self->{versionNum} = $args{version_num};

    $self->{_saleObjClass} = $args{sale_object_class};
    $self->{_saleObjClass} = $self->_defaultSaleObjectClass() unless $self->{_saleObjClass};

    return $self;
}

sub _getDates {
    my $self = shift;
    return Import::Date::GetDates(@_);
}

# Configuration / Customization methods
#
#   The following methods are used to format or configure the behavior of the system
#

# The actual class used to encapsulate the 'sale' table is treated abstractly.
# This will allow us to specify a different sale table and handling strategy without
# needing to change the actual Importer subclasses.
#
sub _defaultSaleObjectClass { die "Must be overloaded" }

# _headerIdentifier defines which column to use to identify the header row and
# what value it should have.  It can either be a list or hashref.
sub _headerIdentifier { die "Must be overloaded" }

# _fieldMap returns a hashref of key / column pairs which is used by the default
# accessor method.  If a key is left undef then the accessors will return undef
# as well.  If you attempt to call the accessor with a keyname not listed in the
# hashref an error will be thrown.
sub _fieldMap { die "Must be overloaded" }

# _unverifiedFieldMap works just like _fieldMap except there is no fieldname validation
# which is useful when you have a column that doesn't corrispond to a saleRec field, but
# you need the value to derive some data.
sub _unverifiedFieldMap { }

# _productTypes returns a hashref of hashrefs with the saleType as the key.  Each
# child hashref must contain mediaType, productType, and formatType.
sub _productTypes { die "Must be overloaded" }

# What format dates are in?  Default to undef, YYYY-MM-DD.  See RPS::Import::Importer::_getDates
# for other formats.
sub _dateFormat { undef }

# _formatDate - Used to adjust the format of a date from raw data.  The default
#               behavior is to do nothing.  However, it can be overloaded to do
#               something more interesting.
sub _formatDate { shift; return shift; }    # Just return passed in value (no formatting)

# _noHeader - If the file doesn't use headers then overload this method and have
#             it return a true value.  Otherwise header lines are skipped.
sub _noHeader { }

#public methods

sub _saleObjectClass {
    my ($self) = @_;

    my $class = $self->{_saleObjClass};

    eval "require $class";
    die $@ if ($@);

    return $class;
}

sub _importLines {
    my $self = shift;
    my %args = @_;

    my $lines = $args{lines};

    $self->{version}   = $args{file}->VersionNum();
    $self->{filename}  = $args{file}->OrigFileName();
    $self->{serviceID} = $args{file}->ServiceID();

    $self->{_linesImported} = 0;
    $self->{_linesIgnored}  = 0;

    return undef unless ($lines);

    $self->{sheetnum} = 0;

    $self->_validateFieldMap();

    if ( $self->salesBatchMode ) {
        Common::Log::Print("BATCH INSERT MODE: is enabled.");
    }

    foreach my $sheet ( @{ $args{sheets} } ) {
        $self->{sheetname} = $args{sheet_names}->[ $self->{sheetnum} ];
        $self->_processSheet( $sheet, $self->{sheetnum}++ );

        # Commit remaining sheet lines if it is enabled
        if ( $self->importerType eq 'rps' && $self->salesBatchMode ) {
            $self->_commitRemainingSheetLines();
        }
    }

    Common::Log::Print("Import Complete.  Lines imported: $self->{_linesImported}, ignored: $self->{_linesIgnored}");
}

sub _processSheet {
    my $self  = shift;
    my $sheet = shift;

    $self->{linenum} = 1;

    $self->{_headerFound} = undef;
    $self->{_fieldMap}    = undef;

    Common::Log::Print(" -- Processing Sheet #$self->{sheetnum}");
    foreach my $line (@$sheet) {
        $self->{line} = $line;

        if ( defined $line ) {
            if ( $self->{_headerFound} || $self->_noHeader() ) {
                $self->preProcessLine();
                $self->_processLine();
            } else {
                if ( $self->_isHeader() ) {
                    my $serviceID  = $self->{fileServiceID};
                    my $versionNum = $self->_version;
                    Common::Log::Print(" ++ Header found for Service: $serviceID, Version: $versionNum. Start line parsing.");
                    $self->{_headerFound} = 1;
                }
                $self->_processHeader();
            }
        }

        $self->{linenum}++;
    }
    Common::Log::Print(" ++ Header not found.")
      unless $self->{_headerFound} || $self->_noHeader;

    $self->_clearSheetCache();       # Get rid of any cache entries specific to this sheet

    # Seems like it would be nice to know if we were able to find the header for this sheet.
    return $self->{_headerFound};
}

# Remove cached field contents, if there is no sheet specified in the field map...
# Clearing the cache is necessary between sheet processing so a location like A2
# will refer to A2 on the *current* sheet, not whatever first sheet the field was
# cached on
sub _clearSheetCache {
    my $self = shift;
    foreach my $field ( keys %$self ) {

        # Cached excel locations are prepended with two underscores, check that we're
        # dealing with one of those
        next unless $field =~ s/^__//;
        my $loc = eval { $self->_fieldMap->{$field} || $self->_unverifiedFieldMap->{$field} };

        # All excel locations that don't have a '!' in them refer to the current sheet,
        # so we clear them out between sheets
        if ( defined($loc) && $loc !~ m/\!/ ) { delete $self->{ '__' . $field } }
    }
}

# Commit remaining sheet lines when batch mode is enabled
sub _commitRemainingSheetLines {
    my $self = shift;

    return unless $self->{_headerFound} || $self->_noHeader;

    my $dbo = Common::RSApp::GetClientDBBatchMode();
    my $dbh = $dbo->DBH();
    $dbh->commit;

    return 1;
}

# If you need to do any work with the header overload this method.
sub _processHeader { }

# Override if you want to look at raw fields, usually in some combination.
# Helps avoid circular references when there are dependencies between fields.
sub preProcessLine { }

sub _processLine {
    my $self = shift;

    if ( $self->_isValidSaleRecord() ) {
        $self->_saveLine();

        $self->{_linesImported}++;
    } else {
        Common::Log::Print( " -- Ignore line sheet: " . $self->sheetname . " line: " . $self->linenum );
        $self->{_linesIgnored}++;
    }

    $self->_clearLineCache;
}

# Similar to _clearSheetCache, but line-based, gets rid of all cached
# fields for lines, which start with three underscores
sub _clearLineCache {
    my $self = shift;
    delete( $self->{$_} ) foreach grep { m/^___/ } keys %$self;
}

# This allows you to manipulate price before it is stored.
sub _formatPrice {
    my $self = shift;
    my $price = shift || return;

    return $price;
}

sub _getByFieldName {
    my $self  = shift;
    my $field = shift;
    my $line  = $self->line;

    assert( $field, "Field required" );
    assert( $line,  "Line not set" );

    # If we have an Excel cell-style specification, use that cell as a
    # static mapping rather than per-column on each row
    my $loc = eval { $self->_fieldMap->{$field} || $self->_unverifiedFieldMap->{$field} };

    # $loc now contains the column or cell spec for the field...  check
    # to see if it's a cell-style location, if so, get that cell's contents
    if ( defined($loc) && $loc =~ m/ ^ (?:.*\!)? [A-Z][A-Z]? \d+ $ /ix ) {

        # We use two leading underscores to indicate cached fields fetched by Excel location
        return $self->{ '__' . $field } if exists $self->{ '__' . $field };    # Return from cache
        return $self->{ '__' . $field } = $self->cell($loc);                   # Fetch, cache contents
    }

    my $offset = $self->_getOffset($field);

    # Only return a value if the offset has been defined.
    return defined($offset) ? $line->[$offset] : undef;
}

sub _getOffset {
    my $self  = shift;
    my $field = shift;

    my $validAccessors = $self->_getValidAccessorHash();

    assert( $field, "Field required" );

    my $map = $self->_fieldMap();
    my $unverifiedMap = $self->_unverifiedFieldMap() || {};

    if ( $validAccessors->{$field} ) {
        return $self->_convertIndex( $map->{$field} );

    } elsif ( exists( $unverifiedMap->{$field} ) ) {
        return $self->_convertIndex( $unverifiedMap->{$field} );

    } else {
        $self->fail("'$field' is not a valid accessor field");
    }

    return;
}

sub _validateFieldMap {
    my $self           = shift;
    my $validAccessors = $self->_getValidAccessorHash();
    my $map            = $self->_fieldMap();

    foreach my $key ( keys %$map ) {
        $self->fail("Field map contains an invalid accessor field '$key'") unless ( $validAccessors->{$key} );
    }
}

sub _getValidAccessorHash {
    my $self = shift;

    unless ( $self->{_accessorHash} ) {
        my @list = $self->_validAccessors();
        my %hash;

        foreach my $key (@list) {
            $hash{$key} = 1;
        }

        $self->{_accessorHash} = \%hash;
    }

    return $self->{_accessorHash};

}

sub col {
    my $self   = shift;
    my $column = shift;
    my $line   = $self->line;

    assert( $column, "Column required" );
    assert( $line,   "Line not set" );

    my $val = eval { $line->[ $self->_convertIndex($column) ] };
    $@ && $self->fail("Invalid column '$column': $@");
    return $val;
}

# Return a cell at a given Excel-style location, like B2, AF99
# or Sheet1!A1.
#
# Also supports proprietary 1!!A2 to indicate cell A2 in the first worksheet,
# or -1!!A2 to indicate the same location in the *last* sheet
sub cell {
    my $self = shift;
    my $loc  = shift;

    # Get the sheet that we'll be plucking the cell from...
    my $lines;    # Will be an arrayref (rows) of arrayrefs (cols)
    my $cell = $loc;    # Cell location without 'SheetName!' (or number) indicator
    if ( $cell =~ s/ ^ ( \-? \d+ ) \!\! (.*?) $ /$2/x ) {

        # Number-style sheet reference like 1!!A1 (first) 2!!A1 (second) or -1!!A1 (last sheet)
        $lines = $self->_getSheetByNum($1);
    } elsif ( $cell =~ s/ ^ (.*) \! (.*?) $ /$2/x ) {

        # Name-based sheet reference like Sheet1!A1
        $lines = $self->_getSheetByName($1);
    } else {

        # Default sheet...  current sheet during line processing,
        # or the first sheet during _preProcess
        $lines = $self->_getCurrentSheet();
    }

    # $cell should now only contain the Excel row/column
    if ( $cell !~ m/^([A-Z]?[A-Z])(\d+)$/i ) {
        $self->fail("Invalid Excel cell location spec: $loc");
    }

    my $col = $self->_convertIndex($1);
    my $row = $2 - 1;

    my $result = eval { use strict; $lines->[$row][$col] };
    $@ && $self->fail("Reference to non-existent location: $loc");
    return $result;
}

# Gets a sheet by name, returns an arrayref (rows) of arrayrefs (cols)
sub _getSheetByName {
    my $self = shift;
    my $name = shift;
    my $num  = $self->{sheetnames}{$name};
    defined($num)
      || $self->fail("Reference to non-existent sheet '$name'");
    return $self->{sheets}->[ $num - 1 ];
}

# Gets a sheet by number (with the first sheet being number 1)
# returns an arrayref (rows) of arrayrefs (cols).  If passed a negative number
# the sheet returned will be from the end of the sheets, so -2 would be the
# next-to-last sheet, -1 would be the last sheet
sub _getSheetByNum {
    my $self = shift;
    my $num  = shift;
    if ( $num == 0 || $num !~ m/^\-\d+$/ ) {
        $self->fail("Invalid sheet number $num");
    }
    if ( $num < 0 ) { $num = scalar( @{ $self->{sheets} } ) - $num + 1; }
    defined( $self->{sheets}[ $num - 1 ] )
      || $self->fail("Reference to non-existent sheet number $num");
    return $self->{sheets}[ $num - 1 ];
}

# Gets the current sheet...  if we aren't currently processing lines,
# this will get the first sheet.  Returns an arrayref (rows) of
# arrayrefs (cols)
sub _getCurrentSheet {
    my $self = shift;
    my $num = defined( $self->{sheetnum} ) ? ( $self->{sheetnum} - 1 ) : 0;
    return $self->{sheets}[$num];
}

# Method to convert column indexes from letters (excel format) to 0 based index.
sub _convertIndex {
    my $self  = shift;
    my $index = shift;

    return unless defined($index);

    # If we are a number no conversion is required
    if ( $index =~ /^\d+$/ ) {
        return $index;

        # If we are a single character the math is a little simpler
    } elsif ( $index =~ /^\w$/ ) {
        return ord( lc($index) ) - ord('a');

        # Two character column indexes need to be multiplied
    } elsif ( $index =~ /^(\w)(\w)$/ ) {
        my $upper = ord( lc($1) ) - ord('a') + 1;
        my $lower = ord( lc($2) ) - ord('a');

        return ( $upper * 26 ) + $lower;
    }

    # BOOM
    else {
        $self->fail("Invalid column index '$index'");
    }
}

sub _getBySaleType {
    my $self  = shift;
    my $field = shift;

    assert( $field, "Field required" );

    my $type = lc( $self->_getByFieldName("type") ) || return;

    my $map = $self->_productTypes() || return;

    return $map->{$type} ? $map->{$type}->{$field} : undef;
}

sub _isHeader {
    my $self = shift;

    assert( $self->line, "Line required" );

    my ( $field, $value ) = $self->_headerIdentifier();
    if ( ref($field) ) {
        my ($key) = keys(%$field);
        $value = $field->{$key};
        $field = $key;
    }

    my $offset = $self->_getOffset($field);

    if ( defined( $self->line->[$offset] ) ) {
        if ( ref($value) eq 'Regexp' && $self->line->[$offset] =~ /$value/ ) {
            return 1;
        } elsif ( lc( $self->line->[$offset] ) eq lc($value) ) {
            return 1;
        }
    }

    return undef;
}

sub _isValidSaleRecord { die "Must be overloaded" }

sub fail {
    my $self    = shift;
    my $message = shift;

    if ( $self->linenum ) {
        $message .= ' (sheet: ' . $self->sheetname . ' line: ' . $self->linenum . ')';
    }

    # Add some debugging info into the error if not running on a production server
    unless ( Common::RSApp::IsProductionServer() ) {
        $message .= ' [failed in ' . (caller)[1] . ' line ' . (caller)[2] . ']';
    }

    die "$message\n";    # Add a newline to the die so it doesn't report this module's name/line number
}

# _preProcess : Provides an _overridable_ method that subclasses can use
# to parse arguments passed in to the 'Import' method, and get ready to
# do work.
#
# The 'Import' method should generally not be overridden - Instead, override
# _preProcess, _importLines, or _postProcess to do what you need to do.
#
sub _preProcess {
    my ( $self, %args ) = @_;

    my $fileObj = $args{file};
    assert($fileObj);

    my $clientID = $args{client_id};
    assert($clientID);

    my $fileID = $fileObj->FileID;

    $self->{clientID} = $clientID;
    $self->{fileID}   = $fileID;

    # JPK - Seems a little funny to dereference this here.
    # If we're getting passed in a File object, can't we just look at that?
    # Do we cache that anyplace?
    #
    $self->{fileServiceID} = $fileObj->ServiceID;

    # Make note of all the service IDs we've seen.
    #
    $self->{services} = {};
    $self->{services}{ $fileObj->ServiceID } = 1;

    $self->{filename} = $args{file}->OrigFileName();

    $self->{_derivedDateFormat} = $self->_getDerivedDate(%args);

    $self->{sheets} = $args{sheets};

    # Hashref of sheet names, keyed by name of the sheet, value is
    # the sheet number (firsh sheet being number 1)
    $self->{sheetnames} = { map { $args{sheet_names}->[$_] => ( $_ + 1 ) } 0 .. $#{ $args{sheet_names} } };
}

sub _postProcess {
    my ( $self, %args ) = @_;

    # Attempt to add entries in the service table
    # for every service referenced in the last import.
    #
    my $clientID        = $args{client_id};
    my $servicesHashRef = $self->{services};
    foreach my $serviceID ( keys %$servicesHashRef ) {
        Client::Service::AddService(
            client_id  => $clientID,
            service_id => $serviceID
        );
    }

}

sub Import {
    my $self = shift;
    my %args = @_;

    Common::Log::Print( "IMPORT: " . ref($self) );

    # JPK - For some reason we go through the trouble of dereferencing a bunch of stuff from the file object.
    # Now I don't want to change that, but I am going to go ahead and keep a reference to the file object around as well.
    #
    my $fileObj = $args{file};
    assert($fileObj);
    $self->{_file} = $fileObj;

    my $retval = undef;

    # Catch exceptions here.
    #
    eval {
        $self->_preProcess(%args);
        $retval = $self->_importLines(%args);
    };

    if ($@) {
        my $eMsg = $@;
        if ( ref $@ && $@->isa('Import::ValidationError') ) {
            $eMsg = $@->errorMessage();
        }

        # !!! Reporting the actual importer Package name will make our lives a lot easier.
        #
        $eMsg .= ": pkg=" . ref($self) . " v=" . $self->_version();

        $self->errstr($eMsg);
        return undef;
    }
    $self->_postProcess(%args) if $retval;

    return $retval;
}

sub warning {
    my $self = shift;
    $self->{errstr} = 'Warnings - Check Log File';
}

sub errstr {
    my $self = shift;
    if (@_) {
        $self->{errstr} = shift;
    }
    return $self->{errstr};
}

sub _version {
    my ($self) = @_;
    return $self->{versionNum};
}

sub _dbo {
    my ($self) = @_;

    if ( !$self->{_dbo} ) {
        $self->{_dbo} = $self->_instantiateDBO();
    }

    return $self->{_dbo};
}

#
# Abstract methods
#
sub _instantiateDBO {
    my ($self) = @_;
    assert( 0, 'override this' );
}

# !!! We might look at making this abstract
#
sub _insertSale {
    my ( $self, %args ) = @_;
    assert( 0, 'override this' );
}

# Overload this method and return 1 if you want to attempt to derive the date
# format
sub deriveDateFormat           { }
sub _derivedDateFormat         { shift->{_derivedDateFormat} }
sub _derivedDateMonthHint      { }
sub _derivedDateMinimumChanges { 14 }

#
# In this preprocess we are going to try and determine the date format based on
# deltas in the data.  All we need to determine is whether the date is MM/DD/YYYY
# or DD/MM/YYYY.  We will do that by examining date to see which protion of the
# date is changing.  One additional check is for date components that are > than
# 13.  If we see a 13 in one of the fields we stop looking for test dates and
# make a decision.
#

sub _getDerivedDate {
    my $self = shift;
    my %args = @_;
    my $monthHint;
    my $dateMatchFound;

    return unless ( $self->deriveDateFormat() );
    Log->info("Starting dynamic date format search");

    assert( $args{sheets} );
    assert( $monthHint =~ /^\d+$/, "monthHint must be numeric" ) if ($monthHint);

    my $partA13Seen;
    my $partB13Seen;

    my $lastPartA;
    my $lastPartB;

    my $partAChanges = 0;
    my $partBChanges = 0;

    my $partAHintMatch;
    my $partBHintMatch;

    foreach my $sheet ( @{ $args{sheets} } ) {
        foreach my $line (@$sheet) {
            $self->{line} = $line;

            $monthHint = $self->_derivedDateMonthHint();

            my $date = $self->_getDateBegin();
            if ( $date && $date =~ /^(\d{1,2})[\-\/](\d{1,2})[\-\/](\d{2,4})/ ) {
                $dateMatchFound = 1;
            } else {
                next;
            }

            # Is the first or second part of the date greater than 13?
            $partA13Seen = 1 if ( $1 >= 13 && !$partA13Seen );
            $partB13Seen = 1 if ( $2 >= 13 && !$partB13Seen );

            # First record.  Just set it and forget it.
            if ( !$lastPartA ) {
                $lastPartA = $1;
                $lastPartB = $2;

                # Otherwise lets see if we need to update anything
            } else {
                if ( $lastPartA != $1 ) {
                    $lastPartA = $1;
                    $partAChanges++;
                }

                if ( $lastPartB != $2 ) {
                    $lastPartB = $2;
                    $partBChanges++;
                }

                # Short circut if we have seen enough changes to make a confident decision. Comment out
                # This line to scan the entire file.
                last
                  if ( $partAChanges >= $self->_derivedDateMinimumChanges()
                    || $partBChanges >= $self->_derivedDateMinimumChanges()
                    || $partA13Seen
                    || $partB13Seen );
            }
        }
    }

    # We can pass a month hint into this method.  This is used when we don't have
    # enough data to derive a date.  We assume that these cases don't span months.
    if ( $lastPartA && $lastPartA == $monthHint && !$partAChanges ) {
        $partAHintMatch = 1;
    }

    if ( $lastPartB && $lastPartB == $monthHint && !$partBChanges ) {
        $partBHintMatch = 1;
    }

    # If we've seen a 13 or greater in the first token or
    # If the first token of the date changes more than the second, call it eur
    if ( $partA13Seen || ( $partAChanges > $partBChanges && $partAChanges >= $self->_derivedDateMinimumChanges() ) || $partBHintMatch ) {
        Log->info(" -- setting date format to 'eur'");
        return 'eur';

        # If we've seen a 13 or greater in the second token or
        # If the second token of the date changes more than the first, call it us
    } elsif ( $partB13Seen || ( $partBChanges > $partAChanges && $partBChanges >= $self->_derivedDateMinimumChanges() ) || $partAHintMatch )
    {
        Log->info(" -- setting date format to 'US'");
        return 'us';

        # If they both have changed the same amount of times, error our because it's ambiguous
    } elsif ($dateMatchFound) {
        $self->fail("Unable to determine date format");

        # Otherwise there were no changes (didn't identify any matching date formats)
    } else {
        Log->warn("No derived date format determined");
    }

    Log->info(" -- not setting date format dynamically");
}

# Provides a more succinct desription of the object than a Dumper, easier to read
sub summary {
    my $self = shift;

    my %info = map { $_ => $self->{$_} }
      grep { not ref $self->{$_} } keys %$self;

    foreach my $field ( $self->_validAccessors ) {
        my $val = eval "\$self->$field()";
        next unless defined $val;
        $info{$field} = $val;
    }

    my $len = 0;
    foreach ( keys %info ) {
        if ( length($_) > $len ) { $len = length($_); }
    }
    my $format = '%-' . $len . "s : %s\n";

    my $out = '';
    foreach my $key ( sort { $a cmp $b } keys %info ) {
        $out .= sprintf( $format, $key, $info{$key} );
    }

    return $out;
}

# Prints ->summary as a Log->debug message
sub debugSummary {
    Log->debug($_) foreach grep { $_ ne '' } split /\n/, shift->summary;
}

###
1;    # Play nicely.
###

# JPK - Not sure yet what to do with SaleRec.  The one we have here is clearly RPS-Only.
# So far, all the 'generic' sale import code might care about is lineNum (for the ValidationError class).
# - I do believe the Class::Struct package supports inheritance.  If not, we can fix that.
#
# For now, I will leave SaleRec in the RPS namespace.
