package BookPub::Import::Importer;

use strict;

# When adding an importer, edit the following files:
#   Tracker/Service.pm
#   Sale/File.pm
#   Import/Factory.pm
#   Import/Importer/SERVICE>/Version<N>.pm

use Date::Calc qw(Add_Delta_Days Days_in_Month);
use Time::Local;
use Data::Dumper;

use lib '/app/tools/bookpub/lib';
use BookPub::DB::Item::PriceType;
use BookPub::DB::Item::rNativeListPrice;
use BookPub::Tracker::Service;

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

use lib '/app/tools/data_classes/lib/';
use File::Sale;

use lib '/app/tools/common/lib';
use Common::RSApp;
use Common::Client;
use Common::Locale;
use Common::Consts;
use Common::Log;
use Common::Assert;
use Common::CurrencyFormat;
use Common::Util;
use Common::Date;
use Common::Postal;
use Common::Country;

use lib '/app/tools/bookpub/lib';
use BookPub::DB::Item::Sale;
use BookPub::DB::Item::PriceTypeQualifier;
use BookPub::Sale::Match;
use BookPub::Import::SaleRec;
use BookPub::DB::Item::BookProduct;

use Data::Dumper;
use base 'Import::Importer';

sub _defaultSaleObjectClass { return 'BookPub::Import::Sale'; }

# Valid accessor fields.  Lists all the valid accessors that can be used in the
# _getOffset method.
sub _validAccessors {
    qw(
      serviceID importStatus purchaseType serviceProductID rServiceName rDistributor
      isAgency isFree isPreOrder units listPrice discount unitPrice revenue taxAmount conversionRate
      currencyCode countryCode state postalCode incentiveAmount dateBegin dateEnd rFormat rClientProductID
      rIsbn10 rIsbn13 rTitle rSubtitle rAuthor rChapterTitle rChapterOrdinal
      rImprint rNarrator rComments rChannel rUnits rSales rReturns rAdjustments
      rFee rListPrice rDiscount rUnitPrice rRevenue rPromoCode priceType isbn
      rProductType rListPriceCurrency rPurchaseType rPriceType rState rPostalCode rCOGS rPurchasePrice
      rPurchasePriceCurrency rTaxAmount rDuration rPreviousDuration rInstitution rIncentiveAmount
      rModel rDeliveryMethod rTransactionCategory rTaxUnitPrice priceTypeQualifierID
      rNativeListPrice rNativeListPriceCurrency
    );
}

# This is a list of accessors that return derived data and have a complimenting
# rField column.  In order to prevent confussion we required you identify columns
# using the rField.
sub _derivedFields {
    qw( revenue taxAmount listPrice productType formatType units discount unitPrice priceTypeID
      state postalCode incentiveAmount
    );
}

# Sale record accessors
#
#  These methods mirror fields in the RPS::Import::SaleRec struct, infact they
#  will be used to populate that struct.
#
#  The default behavior is to pull the data from the column using an offset.  However,
#  the methods can be overloaded for trickier imports.
#
#  There are a couple exceptions to the default behavior.
#    - mediaType, productType, and formatType are generally identified by a single field
#      so we handle those with a more complex method.
#    - dateBegin and dateEnd usually need special formatting so don't use the default
#      accessor methods.

sub importStatus             { shift->_getByFieldName('importStatus') }
sub purchaseType             { shift->_getByFieldName('purchaseType') }
sub priceType                { shift->_getByFieldName('priceType') }
sub serviceProductID         { shift->_getByFieldName('serviceProductID') }
sub isAgency                 { shift->_getByFieldName('isAgency') }
sub isFree                   { shift->_getByFieldName('isFree') }
sub isPreOrder               { shift->_getByFieldName('isPreOrder') }
sub conversionRate           { shift->_getByFieldName('conversionRate') }
sub currencyCode             { shift->_getByFieldName('currencyCode') }
sub countryCode              { shift->_getByFieldName('countryCode') }
sub rFormat                  { shift->_getByFieldName('rFormat') }
sub rServiceName             { shift->_getByFieldName('rServiceName') }
sub rDistributor             { shift->_getByFieldName('rDistributor') }
sub rProductType             { shift->_getByFieldName('rProductType') }
sub rClientProductID         { shift->_getByFieldName('rClientProductID') }
sub rAuthor                  { shift->_getByFieldName('rAuthor') }
sub rChapterTitle            { shift->_getByFieldName('rChapterTitle') }
sub rChapterOrdinal          { shift->_getByFieldName('rChapterOrdinal') }
sub rImprint                 { shift->_getByFieldName('rImprint') }
sub rNarrator                { shift->_getByFieldName('rNarrator') }
sub rComments                { shift->_getByFieldName('rComments') }
sub rChannel                 { shift->_getByFieldName('rChannel') }
sub rUnits                   { shift->_getByFieldName('rUnits') }
sub rSales                   { shift->_getByFieldName('rSales') }
sub rReturns                 { shift->_getByFieldName('rReturns') }
sub rAdjustments             { shift->_getByFieldName('rAdjustments') }
sub rFee                     { shift->_getByFieldName('rFee') }
sub rTaxUnitPrice            { shift->_getByFieldName('rTaxUnitPrice') }
sub rTaxAmount               { shift->_getByFieldName('rTaxAmount') }
sub rPurchasePrice           { shift->_getByFieldName('rPurchasePrice') }
sub rPurchasePriceCurrency   { shift->_getByFieldName('rPurchasePriceCurrency') }
sub rListPrice               { shift->_getByFieldName('rListPrice') }
sub rListPriceCurrency       { shift->_getByFieldName('rListPriceCurrency') }
sub rNativeListPrice         { shift->_getByFieldName('rNativeListPrice') }
sub rNativeListPriceCurrency { shift->_getByFieldName('rNativeListPriceCurrency') }
sub rUnitPrice               { shift->_getByFieldName('rUnitPrice') }
sub rRevenue                 { shift->_getByFieldName('rRevenue') }
sub rPromoCode               { shift->_getByFieldName('rPromoCode') }
sub rPurchaseType            { shift->_getByFieldName('rPurchaseType') }
sub rPriceType               { shift->_getByFieldName('rPriceType') }
sub rState                   { shift->_getByFieldName('rState') }
sub rPostalCode              { shift->_getByFieldName('rPostalCode') }
sub rDuration                { shift->_getByFieldName('rDuration') }
sub rPreviousDuration        { shift->_getByFieldName('rPreviousDuration') }
sub rInstitution             { shift->_getByFieldName('rInstitution') }
sub rIncentiveAmount         { shift->_getByFieldName('rIncentiveAmount') }
sub rModel                   { shift->_getByFieldName('rModel') }
sub rDeliveryMethod          { shift->_getByFieldName('rDeliveryMethod') }
sub rTransactionCategory     { shift->_getByFieldName('rTransactionCategory') }

# Date begin and date end work differently in BookPub land than in RPS.
# Dates in bookpub are exact if we can and no date normalization happens by default.
# Some importers will override this default behavior and will normalize dates.

sub dateBegin {
    my $self = shift;
    my $startDate = $self->_getDateBegin() || return;

    my $date;

    eval { $date = new Common::Date( date => $startDate, primary_type => $self->_derivedDateFormat, type => $self->_dateFormat() ) };

    if ($@) {
        $self->fail("Unrecognized begin date: $startDate");
    }

    if ( !$self->_dateNormalization ) {
        return $date->asString();
    } elsif ( $self->_dateNormalization eq 'month' ) {
        return $date->monthBegin();
    } elsif ( $self->_dateNormalization eq 'quarter' ) {
        return $date->quarterBegin();
    } elsif ( $self->_dateNormalization eq 'year' ) {
        return $date->yearBegin();
    } else {
        die "Unknown Normalization Type";
    }

    return $date->asString();
}

sub dateEnd {
    my $self = shift;
    my $endDate = $self->_getDateEnd() || $self->dateBegin();
    return unless ($endDate);

    my $date;

    eval { $date = new Common::Date( date => $endDate, primary_type => $self->_derivedDateFormat, type => $self->_dateFormat() ) };

    if ($@) {
        $self->fail("Unrecognized end date: $endDate");
    }

    if ( !$self->_dateNormalization ) {
        return $date->asString();
    } elsif ( $self->_dateNormalization eq 'month' ) {
        return $date->monthEnd();
    } elsif ( $self->_dateNormalization eq 'quarter' ) {
        return $date->quarterEnd();
    } elsif ( $self->_dateNormalization eq 'year' ) {
        return $date->yearEnd();
    } else {
        die "Unknown Normalization Type";
    }
}

sub _dateNormalization { }

# There are a few fields that are generally derived, these are their accessors

sub rIsbn10 {
    my $self = shift;
    my $isbn = $self->_getByFieldName('isbn');

    return $self->_getByFieldName('rIsbn10') unless ( defined($isbn) );

    return length($isbn) == 10 ? $isbn : undef;
}

sub rIsbn13 {
    my $self = shift;
    my $isbn = $self->_getByFieldName('isbn');

    return $self->_getByFieldName('rIsbn13') unless ( defined($isbn) );

    return $isbn;
}

sub rTitle {
    my $self = shift;
    return $self->_autoParseTitleAndSubtitle
      ? ( ParseTitleAndSubtitle( $self->_getByFieldName('rTitle') ) )[0]
      : $self->_getByFieldName('rTitle');
}

sub rSubtitle {
    my $self = shift;
    return $self->_autoParseTitleAndSubtitle
      ? ( ParseTitleAndSubtitle( $self->_getByFieldName('rTitle') ) )[1]
      : $self->_getByFieldName('rSubtitle');
}

sub taxAmount {
    my $self      = shift;
    my $taxAmount = $self->rTaxAmount();

    if ($taxAmount) {
        return $taxAmount;
    }

    my $taxUnit = $self->rTaxUnitPrice();
    my $units   = $self->units();

    if ( $taxUnit && $units ) {
        return abs($taxUnit) * $units;
    }

    return;
}

# Unit price is calculated Revenue / Units.
# sah - 2014-05-12 - started calculating when revenue is zero as well.
sub unitPrice {
    my $self    = shift;
    my $units   = $self->units();
    my $revenue = $self->revenue();

    if ($units) {
        return abs( $revenue / $units );
    } else {
        return $self->listPrice();
    }
}

# See case #14352 for a good conversation about the discount field.
sub discount {
    my $self      = shift;
    my $unitPrice = $self->unitPrice;
    my $listPrice = $self->listPrice + 0;
    my $discount;

    my $listPriceCurrency = $self->rListPriceCurrency();
    my $unitPriceCurrency = $self->currencyCode();

    if ( defined( $self->rDiscount() ) ) {
        $discount = $self->rDiscount();

    } elsif ( defined( $self->rCOGS() ) ) {
        $discount = 1 - $self->rCOGS();

    } elsif ( defined($unitPrice)
        && defined($listPrice)
        && $listPrice != 0
        && ( !$listPriceCurrency || $listPriceCurrency eq $unitPriceCurrency ) ) {
        $discount = 1 - $unitPrice / $listPrice;
    } else {

        # No discount stored if we dont't have it and can't derive it.
    }

    return $discount && $discount > 1 ? $discount / 100 : $discount;
}

# We store rDiscount as a decimal.  So if it's > 1 convert it.
sub rDiscount {
    my $self     = shift;
    my $discount = $self->_getByFieldName('rDiscount');

    $discount =~ s/%// if ($discount);

    return $discount && $discount > 1 ? $discount / 100 : $discount;
}

# We store rCOGS as a decimal.  So if it's > 1 convert it.
sub rCOGS {
    my $self = shift;
    my $cogs = $self->_getByFieldName('rCOGS');

    $cogs =~ s/%// if ($cogs);

    return $cogs && $cogs > 1 ? $cogs / 100 : $cogs;
}

sub formatType {
    my $self   = shift;
    my $format = $self->rFormat();

    return unless ($format);

    my $formatCode = $self->_parseFormat($format);
    $self->fail("Unrecognized format type '$format'") unless ($formatCode);
}

sub productType {
    my $self = shift;
    my $type = $self->rProductType() || return;

    my $typeCode = $self->_parseProductType($type);
    $self->fail("Unrecognized product type '$type'") unless ($typeCode);
}

sub units {
    my $self        = shift;
    my $units       = $self->rUnits();
    my $sales       = $self->rSales();
    my $returns     = $self->rReturns();
    my $adjustments = $self->rAdjustments();

    if ( $self->_advancedUnitProcessing() ) {

        # Special behavior for importers that opt in to it. FB10714
        # We don't care if sales or returns are positive or negative,
        # as long as: sales - returns = units.
        if ( $sales - $returns != $units ) {
            $self->fail("Sales and Returns values do not reconcile with Units value");
        }

        # And now we'll add the adjustments in with sales and returns
        # to come up with the true units for this sale.
        return $sales - $returns + $adjustments;
    } else {

        # Default behavior
        if ( defined($sales) && $sales < 0 ) {
            $self->fail("Negative sales");
        }

        if ( defined($returns) && $returns < 0 ) {
            $self->fail("Negative returns");
        }

        if ( defined($sales) && defined($returns) ) {
            return $sales - $returns;
        } else {
            return $units;
        }
    }
}

sub priceTypeID {
    my $self      = shift;
    my $priceType = lc( $self->priceType );
    my $priceTypeID;

    unless ($priceType) {
        $priceType = $self->isFree() && $self->isFree() =~ /^y$/i ? 'agency' : 'rrp';
    }

    if ( $priceType eq 'agency' ) {
        return $self->_getAgencyPriceType();
    } elsif ( $priceType eq 'agencyplustax' || $priceType eq 'agency+tax' ) {
        return $self->_getAgencyPlusTaxPriceType();
    } elsif ( $priceType eq 'rrp' ) {
        return $self->_getRRPPriceType();
    } elsif ( $priceType eq 'rrpplustax' || $priceType eq 'rrp+tax' ) {
        return $self->_getRRPPlusTaxPriceType();
    } elsif ( $priceType eq 'suppliersnet' ) {
        return BookPub::DB::Item::PriceType::kPriceTypeSuppliersNet;
    } else {
        die("priceType '$priceType' not recognized");
    }

    return $priceTypeID;
}

# This will have to be overridden if we want to set the value to anything.
sub priceTypeQualifierID {
    my $self = shift;

    return;
}

sub serviceID {
    my $self   = shift;
    my $offset = $self->_getOffset('rServiceName');

    if ( defined($offset) ) {
        my $name = $self->rServiceName();
        my $id   = BookPub::Tracker::Service::GetServiceIDByName($name);

        if ($id) {
            return $id;
        } else {
            $self->fail("Unknow service name '$name'");
        }
    }

    return $self->SUPER::serviceID();
}

# These three fields are generally not derived, but they are stored twice in the sale
# record.  If they are derived then they can be overloaded
sub revenue         { shift->rRevenue }
sub listPrice       { abs( shift->rListPrice ) }
sub incentiveAmount { shift->rIncentiveAmount }

# Pretty sure I don't mean for these ever to be overridden, but sticking with the
# design pattern... I might try changing them (and references) to _state and
# _postalCode, but we'll see. Seems like a number of derived fields should be
# private?

sub state {
    my $self = shift;

    my $state;

    if ( my $rState = $self->rState() ) {
        my $countryCode = $self->countryCode();

        if ( $countryCode && length($countryCode) != 2 ) {
            my $countryObj = Common::Country->Get($countryCode);
            if ($countryObj) {
                $countryCode = $countryObj->alpha2();
            }
        }

        if ( my $postal = Common::Postal->new($countryCode) ) {
            if ( $postal->getName($rState) ) {

                # acceptable state abbreviation
                $state = $rState;
            } else {

                # look up by the full state name
                $state = $postal->getAbbr($rState);
            }

            if ( !$state ) {

                # Apple seems to run with PI right now, an
                # obsolete code for Philippine Islands.
                # We'll let that slide (but we won't normalize
                # with it either).

                if ( uc($rState) ne 'PI' ) {
                    $self->fail( "Invalid state '$rState' (country: " . $self->countryCode . ")" );
                }
            }
        }
    }

    return $state;
}

sub postalCode {
    my $self = shift;

    my $postalCode;

    if ( my $rPostalCode = $self->rPostalCode() ) {
        if ( my $postal = Common::Postal->new( $self->countryCode() ) ) {
            $postalCode = $postal->formatPostalCode($rPostalCode);

            # Let's try it again with spaces removed.
            if ( !$postalCode ) {
                my $rPostalCodeClean = $rPostalCode;
                $rPostalCodeClean =~ s/ //g;
                $postalCode = $postal->formatPostalCode($rPostalCodeClean);
            }

            # !!! Postal code is not a required field, so we really shouldn't error out over it.
            # !!! Instead, we'll just leave postalCode blank (but we'll still have the data in rPostalCode).

            # Ok, now we'll give up.

            #if (!$postalCode)
            #{
            #    $self->fail("Invalid postal code '$rPostalCode'");
            #}
        }
    }

    return $postalCode;
}

#
# Private methods
#

sub _validateFieldMap {
    my $self = shift;

    my $map = $self->_fieldMap();

    foreach my $key ( keys %$map ) {
        $self->fail("Field map contains a derived field accessor '$key'")
          if ( grep( /^$key$/, $self->_derivedFields() ) );
    }

    return $self->SUPER::_validateFieldMap();
}

# Overloading this from the base class so that we can get rid of isValidSaleRecord
# in bookpub without breaking anything in rps.
sub _processLine {
    my $self = shift;

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

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

# Temporary flag for opting in to new _isSaleRec method
sub _useIsSaleRec { 0 }

# Temporary flag for opting in to new _advancedUnitProcessing method
sub _advancedUnitProcessing { 0 }

sub _isSaleRec {
    my $self = shift;

    # In an effort to prevent sales lines from being skipped, the base class is going
    # to attempt to process everything except for blank lines.
    # It will be up to the individual importers to skip specific lines (headers, footer, subtotals, etc.).
    # Headers are typically skipped already, so the majority of importers should just work.

    # For now, this is opt-in behavior.  If the flag isn't set, we'll still use the old logic.
    if ( $self->_useIsSaleRec() ) {
        foreach my $cell ( @{ $self->line } ) {
            if ( $cell ne '' ) {
                return 1;
            }
        }
        return 0;
    } else {
        return $self->_isValidSaleRecord();
    }
}

sub _isValidSaleRecord {
    my $self = shift;

    Common::Log::Print("_isValidSaleRecord is deprecated.  This importer needs to be updated to use _isSaleRec instead.");

    return $self->rTitle && ( $self->rIsbn10 || $self->rIsbn13 ) && $self->dateBegin;
}

sub _saveLine {
    my $self = shift;

    my $startDate = $self->dateBegin();
    my $endDate   = $self->dateEnd();

    my $sale = BookPub::Import::SaleRec->new();

    $sale->dateBegin($startDate);
    $sale->dateEnd($endDate);

    $sale->serviceID( $self->serviceID );
    $sale->importStatus( $self->importStatus );
    $sale->productType( $self->productType );
    $sale->formatType( $self->formatType );
    $sale->priceTypeID( $self->priceTypeID );
    $sale->priceTypeQualifierID( $self->priceTypeQualifierID );
    $sale->purchaseType( $self->purchaseType );
    $sale->serviceProductID( $self->serviceProductID );
    $sale->isAgency( $self->isAgency );
    $sale->isFree( $self->isFree );
    $sale->isPreOrder( $self->isPreOrder );
    $sale->units( $self->units );
    $sale->listPrice( $self->listPrice );
    $sale->discount( $self->discount );
    $sale->unitPrice( $self->unitPrice );
    $sale->revenue( $self->revenue );
    $sale->taxAmount( $self->taxAmount );
    $sale->conversionRate( $self->conversionRate );
    $sale->currencyCode( $self->currencyCode );
    $sale->countryCode( $self->countryCode );
    $sale->state( $self->state );
    $sale->postalCode( $self->postalCode );
    $sale->incentiveAmount( $self->incentiveAmount );
    $sale->rFormat( $self->rFormat );
    $sale->rPriceType( $self->rPriceType );
    $sale->rPurchaseType( $self->rPurchaseType );
    $sale->rProductType( $self->rProductType );
    $sale->rClientProductID( $self->rClientProductID );
    $sale->rIsbn10( $self->rIsbn10 );
    $sale->rIsbn13( $self->rIsbn13, $self->rProductType );
    $sale->rServiceName( $self->rServiceName );
    $sale->rDistributor( $self->rDistributor );
    $sale->rTitle( $self->rTitle );
    $sale->rSubtitle( $self->rSubtitle );
    $sale->rAuthor( $self->rAuthor );
    $sale->rChapterTitle( $self->rChapterTitle );
    $sale->rChapterOrdinal( $self->rChapterOrdinal );
    $sale->rImprint( $self->rImprint );
    $sale->rNarrator( $self->rNarrator );
    $sale->rComments( $self->rComments );
    $sale->rChannel( $self->rChannel );
    $sale->rUnits( $self->rUnits );
    $sale->rSales( $self->rSales );
    $sale->rReturns( $self->rReturns );
    $sale->rAdjustments( $self->rAdjustments );
    $sale->rFee( $self->rFee );
    $sale->rTaxUnitPrice( $self->rTaxUnitPrice );
    $sale->rTaxAmount( $self->rTaxAmount );
    $sale->rPurchasePrice( $self->rPurchasePrice );
    $sale->rPurchasePriceCurrency( $self->rPurchasePriceCurrency );
    $sale->rListPrice( $self->rListPrice );
    $sale->rListPriceCurrency( $self->rListPriceCurrency );
    $sale->rNativeListPrice( $self->rNativeListPrice );
    $sale->rNativeListPriceCurrency( $self->rNativeListPriceCurrency );
    $sale->rDiscount( $self->rDiscount );
    $sale->rCOGS( $self->rCOGS );
    $sale->rUnitPrice( $self->rUnitPrice );
    $sale->rRevenue( $self->rRevenue );
    $sale->rPromoCode( $self->rPromoCode );
    $sale->rState( $self->rState );
    $sale->rPostalCode( $self->rPostalCode );
    $sale->rDuration( $self->rDuration );
    $sale->rPreviousDuration( $self->rPreviousDuration );
    $sale->rInstitution( $self->rInstitution );
    $sale->rIncentiveAmount( $self->rIncentiveAmount );
    $sale->rModel( $self->rModel );
    $sale->rDeliveryMethod( $self->rDeliveryMethod );
    $sale->rTransactionCategory( $self->rTransactionCategory );

    $sale->lineNum( $self->linenum );

    $self->_insertSale( sale => $sale );
}

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

    my $saleRec = $args{sale};

    # Price type is a new feature.  Some importers don't support this field so we
    # have to derive it.
    my $priceTypeID = $saleRec->priceTypeID;

    # As part of FB 4933, we are adding kPriceTypeAgencyPlusTax and kPriceTypeRRPPlusTax to
    # our price type model.  I propose the following convention: if priceTypeID is not set
    # and isAgency is known, then we assume the price type is Agency or RRP based on that.
    # We only set a price type of "PlusTax" if we have a priceTypeID defined in a sales
    # file.

    # case 1) We don't know priceTypeID
    unless ($priceTypeID) {

        # Agency is type 41 or 42, otherwise it is retail price (type 1 or 2) or supplier's net (type 5).
        $priceTypeID =
          $saleRec->isAgency() eq 'Y'
          ? BookPub::DB::Item::PriceType::kPriceTypeAgency
          : BookPub::DB::Item::PriceType::kPriceTypeRRP;
    }

    # case 2) We don't know isAgency
    if ( $priceTypeID && !$saleRec->isAgency() ) {
        $saleRec->isAgency( $priceTypeID == BookPub::DB::Item::PriceType::kPriceTypeAgency
              || $priceTypeID == BookPub::DB::Item::PriceType::kPriceTypeAgencyPlusTax ? 'Y' : 'N' );
    }

    # case 3) Price type mismatch
    if (   lc( $saleRec->isAgency ) eq 'y' && ( $priceTypeID != 41 && $priceTypeID != 42 )
        || lc( $saleRec->isAgency ) eq 'n' && ( $priceTypeID != 1 && $priceTypeID != 2 && $priceTypeID != 5 ) ) {

        $self->fail("Price type mismatch, type: $priceTypeID isAgency: $saleRec->isAgency\n");
    }

    $saleRec->priceTypeID($priceTypeID);

    # Keep track of the service id, if there is one.
    # We'll need to know this during post-processing.
    #
    $self->{services}{ $saleRec->serviceID } = 1 if ( $saleRec->serviceID );

    # We need to remap some older or incorrect country codes to the correct country codes en masse.
    # So we'll use a little hash and swap where appropriate, this will allow for the expantion
    # of the substitutions where necessary.
    my %countriesFixed = (
        FX => 'FR',
        JA => 'JM',
        UK => 'GB',
        YU => 'MK',
        # see RSD-10849
        RSA => 'ZA',
        ZAR => 'ZA',
    );

    $saleRec->countryCode( $countriesFixed{ $saleRec->countryCode } ) if ( defined $countriesFixed{ $saleRec->countryCode } );

    # If we have a two character country code at this point, just let it by.
    # Otherwise, let's try to find the right code for what we do have.
    if ( $saleRec->countryCode && length( $saleRec->countryCode ) != 2 ) {
        my $countryCode;
        my $countryObj = Common::Country->Get( $saleRec->countryCode );
        if ($countryObj) {
            $countryCode = $countryObj->alpha2();
        } else {
            $countryCode = $Common::Consts::COUNTRY_CODE{ lc( $saleRec->countryCode ) }
              || die Import::ValidationError->new( $saleRec, "countryCode '" . $saleRec->countryCode . "' is not recognized" );
        }
        $saleRec->countryCode($countryCode);
    }

    # RSD-3836 - Force all US Territory sales for MMUS to have the RRP price type.
    my $clientID = Common::RSApp::GetClientID();
    if ( $clientID == 318 ) {
        my %usTerritories = (
            AS => 1,
            GU => 1,
            MP => 1,
            PR => 1,
            UM => 1,
        );
        if ( $usTerritories{ $saleRec->countryCode } ) {
            $saleRec->priceTypeID(BookPub::DB::Item::PriceType::kPriceTypeRRP);
        }
    }

    # !!! move this
    # Make sure the currency_code is _VALID_.
    if ( !Common::CurrencyFormat::CodeIsValid( $saleRec->currencyCode ) ) {
        die Import::ValidationError->new( $saleRec, "currencyCode '" . $saleRec->currencyCode . "' is not recognized" );
    }

    # Check for our 'native' currency code here, and adjust
    # conversion_rate if necessary.
    #
    # This will essentially override any specific logic in the derived classes
    # that pertained to setting the conversion rate.
    #
    # But, don't bother to do this if the conversionRate has already been set.
    # !!! Actually, we will _always_ override conversionRate now.
    #
    #    if (! defined $saleRec->conversionRate())
    #    {
    my $nativeCurrency = Common::Client::Current()->Locale()->currencyFormat()->currencyCode();
    if ( $saleRec->currencyCode ne $nativeCurrency ) {
        $saleRec->conversionRate(0);
    } else {
        $saleRec->conversionRate(1);
    }

    #    }

    # Now we can validate the saleRec as best we can.
    # This will throw an exception if validation fails.
    #
    $self->_validateSaleRec($saleRec);

    # !!! JPK - It seems like a bit of a waste, frankly, to have
    # both the SaleRec class and the DB::Item::Sale.
    # I suppose we can end up with some additional conversion stuff in the SaleRec class?
    # Still, though, it seems a bit pointless.
    # !!! But, for now, keep this model.
    #
    my $book_product_isbn = $self->_getFromCache("book_product_isbn");
    my $saleObj = $self->_saleObjectClass()->CreateFromSaleRec( saleRec => $saleRec, file => $self->{_file}, isbn => $book_product_isbn );

    # We may need to add an entry to the r_native_list_price table
    # We really only need it if it's different from the regular list price...
    # but it'll be easier and more consistent to just store it whenever it's passed it.
    if ( $saleRec->rNativeListPriceCurrency && ( $saleRec->rNativeListPrice || $saleRec->rNativeListPrice eq '0' ) ) {

        my $nativeListPrice = BookPub::DB::Item::rNativeListPrice->Create(
            sale_id                      => $saleObj->sale_id,
            r_native_list_price          => abs( $saleRec->rNativeListPrice ),
            r_native_list_price_currency => $saleRec->rNativeListPriceCurrency,
        );

        $nativeListPrice->save();
    }

}

# cmd line testing (from BookPub::Import)
# perl -MImporter -e "print BookPub::Import::Importer->_parseFormat('MobiPocket Blah Blah')"
sub _parseFormat {
    my ( $self, $format ) = @_;
    $format = lc $format;

    # as usual, anything related to MS requires special handling
    $format =~ s/\bmicrosoft\b/lit/ || $format =~ s/\bmsreader\b/lit/;

    return
        $format =~ /pdf/       ? BookPub::DB::Item::Sale::kFormatTypePDF
      : $format =~ /epub/      ? BookPub::DB::Item::Sale::kFormatTypeEPub
      : $format =~ /e101/      ? BookPub::DB::Item::Sale::kFormatTypeEPub
      : $format =~ /aer/       ? BookPub::DB::Item::Sale::kFormatTypeAdobeEBook
      : $format =~ /mobi/      ? BookPub::DB::Item::Sale::kFormatTypeMOBI
      : $format =~ /\blit\b/   ? BookPub::DB::Item::Sale::kFormatTypeMSLITLvl1_3
      : $format =~ /kindle/    ? BookPub::DB::Item::Sale::kFormatTypeKindle
      : $format =~ /palm/      ? BookPub::DB::Item::Sale::kFormatTypeEReader
      : $format =~ /e.?reader/ ? BookPub::DB::Item::Sale::kFormatTypeEReader
      :

      #$format =~ /appli/ ? BookPub::DB::Item::Sale::kFormatTypeApplication :
        $format =~ /\beti\b/ ? BookPub::DB::Item::Sale::kFormatTypeETI
      : $format =~ /topaz/   ? BookPub::DB::Item::Sale::kFormatTypeKindle
      : $format =~ /mp3/     ? BookPub::DB::Item::Sale::kFormatTypeAudioMP3
      :                        undef;
}

# cmd line testing (from BookPub::Import)
# perl -MImporter -e "print BookPub::Import::Importer->_parseProductType('MobiPocket Blah Blah')"
sub _parseProductType {
    my ( $self, $type ) = @_;
    $type = lc $type;

    # I don't know if we actually want these in the base class or not.  It seems
    # this translation should happen in the derived classes.  However, I'll
    # leave this as a valid method just in case.
    return
        $type =~ /ebook/i      ? BookPub::DB::Item::Sale::kProductTypeEBook
      : $type =~ /eaudiobook/i ? BookPub::DB::Item::Sale::kProductTypeAudioBook
      :                          undef;
}

# cmd line testing (from BookPub::Import)
# perl -MImporter -e "print BookPub::Import::Importer->_parseBinding('Perfectbound')"
sub _parseBinding {
    my ( $self, $format ) = @_;
    $format = lc $format;

    return
        $format =~ /perfect/    ? BookPub::DB::Item::Sale::kFormatTypeBindPerfect
      : $format =~ /coil/       ? BookPub::DB::Item::Sale::kFormatTypeBindCoil
      : $format =~ /loop/       ? BookPub::DB::Item::Sale::kFormatTypeBindLoop
      : $format =~ /saddle/     ? BookPub::DB::Item::Sale::kFormatTypeBindSaddle
      : $format =~ /comb/       ? BookPub::DB::Item::Sale::kFormatTypeBindComb
      : $format =~ /case|cloth/ ? BookPub::DB::Item::Sale::kFormatTypeBindCase
      :                           undef;
}

# Although SaleRec is its own class, I'm _not_ going to put the validate logic in there.
# A case could be made that it belongs there, but our current model has the Importer
# class in charge of its own validation.
#
# In other words, if we want to mess around with how certain Importers validate, they just have
# to override their appropriate '_validate*' method.   If we move validation into the SaleRec class,
# then importers will need to create their own subclass of SaleRec to hold any custom validation.
#
# That's not a terrible model - The Importers already instantiate SaleRec themselves.  But, meh.
# I think it makes more sense to leave SaleRec as a relatively dumb container class, and make sure
# Importers do all the heavy lifting. - JPK
#
sub _validateSaleRec {
    my ( $self, $saleRec ) = @_;

    $self->_validateProductType($saleRec);
    $self->_validatePriceTypeQualifier($saleRec);
    $self->_validatePurchaseType($saleRec);
    $self->_validateUnits($saleRec);
    $self->_validateRevenue($saleRec);
    $self->_validateListPrice($saleRec);
    $self->_validateListPriceCurrency($saleRec);
    $self->_validateNativeListPrice($saleRec);
    $self->_validateDates($saleRec);
    $self->_validateFree($saleRec);
    $self->_validateCurrencyCode($saleRec);
    $self->_validateCountryCode($saleRec);
    $self->_validateISBN($saleRec);
    $self->_validateTaxAmount($saleRec);

    $self->_validateLineNum($saleRec);
}

sub _validateLineNum {
    my ( $self, $saleRec ) = @_;

    if ( !$saleRec->lineNum() ) {
        die Import::ValidationError->new( $saleRec, "No lineNum value specified" );
    }
}

#------------------------------------------------------------
# str _validateProductType
#
# This subroutine takes in a sale record, and dies
# if the product type is undefined or invalid. If the product
# type is valid then a string indicating the category of the
# product type is returned (digital or physical).
#
# Parameters:
#   str $productType (required) - A string that is a possible
#   product type.
#
#
# Return:
# str $productTypeStr
#------------------------------------------------------------

sub _validateProductType {
    my ( $self, $saleRec ) = @_;

    my $productType = $saleRec->productType;

    if ( !defined $productType ) {
        die Import::ValidationError->new( $saleRec, 'No product type' );
    }

    # Don't know what the valid ones are, yet.
    #
    #    if (!$validProductTypes{$productType})
    #    {
    #        die Import::ValidationError->new($saleRec, "Invalid product type: $productType");
    #    }

    #    return $validProductTypes{$productType};
    return 1;
}

sub _validatePriceTypeQualifier {
    my ( $self, $saleRec ) = @_;

    my $qualifier = $saleRec->priceTypeQualifierID;

    # If this is set, we need to make sure it is in the right format.
    if ( defined $qualifier ) {
        if ( $qualifier =~ /^\d\d$/ ) {
            return 1;
        } else {
            die Import::ValidationError->new( $saleRec, 'Invalid price type qualifier ID: ' . $qualifier );
        }
    }

    return 1;
}

sub _validatePurchaseType {
    my ( $self, $saleRec ) = @_;

    my $purchaseType = $saleRec->purchaseType;

    if ( !defined $purchaseType ) {
        die Import::ValidationError->new( $saleRec, 'No purchase type' );
    }

    # Don't know what the valid ones are, yet.
    #
    return 1;
}

#------------------------------------------------------------
# Total revenue value must be in one of the following formats,
# with arbitrary number of digits:
# 00
# -00
# 00.
# -00.
# 00.00
# -00.00
# .00
# -.00
#------------------------------------------------------------
sub _validateRevenue {
    my ( $self, $saleRec ) = @_;
    my $revenue = $saleRec->revenue;

    if ( $self->line() ) {
        return 1 if ( $self->isFree && lc( $self->isFree ) eq 'y' );
    }

    if ( !defined $revenue ) {
        die Import::ValidationError->new( $saleRec, "revenue not defined" );
    }

    if ( $revenue !~ /^[+-]?[0-9]*[.]?[0-9]+(?:[e][+-]?[0-9]+)?$/ ) {
        die Import::ValidationError->new( $saleRec, "revenue '$revenue' is in an incorrect format" );
    }
}

sub _validateListPrice {

    # The output file formats for several clients require list prices,
    # so we're going to check for them now.
    my ( $self, $saleRec ) = @_;

    # We'll let free sales skip this validation.
    if ( $self->listPriceRequired && ( $saleRec->isFree eq 'N' || !$saleRec->isFree ) ) {
        my $revenue   = $saleRec->revenue;
        my $listPrice = $saleRec->listPrice;

        if ( ( $revenue && $revenue != 0 ) && ( !$listPrice || $listPrice == 0 ) ) {
            die Import::ValidationError->new( $saleRec, "List Price missing" );
        }
    }
}

sub _validateListPriceCurrency {

    # The output file formats for several clients require list prices in their base currency,
    # so we're going to make sure we have what we need to do that.
    my ( $self, $saleRec ) = @_;

    if ( $self->listPriceRequired ) {
        my $listPriceCurrency = $saleRec->rListPriceCurrency;
        my $baseCurrency      = Common::Client::Current()->Locale()->currencyFormat()->currencyCode();

        if ( $listPriceCurrency && $listPriceCurrency ne $saleRec->currencyCode && $listPriceCurrency ne $baseCurrency ) {
            die Import::ValidationError->new( $saleRec, "List Price Currency must be the same as Revenue Currency or Base Currency" );
        }
    }
}

sub _validateNativeListPrice {

    # We're just going to make sure we have currency to go with the price.
    my ( $self, $saleRec ) = @_;

    if ( $saleRec->rNativeListPrice && !$saleRec->rNativeListPriceCurrency ) {
        die Import::ValidationError->new( $saleRec, "Native List Price Currency missing" );
    }
}

sub _validateUnits {
    my ( $self, $saleRec ) = @_;
    my $units     = $saleRec->units;
    my $revenue   = $saleRec->revenue;
    my $incentive = $saleRec->incentiveAmount;

    if ( !defined $units ) {
        die Import::ValidationError->new( $saleRec, "units not set" );
    }

    if ( $units !~ /^\-?\d+$/ ) {
        die Import::ValidationError->new( $saleRec, "Units $units in non-integer format" );
    }

    #if($units == 0 && $revenue != 0)
    #{
    #    die Import::ValidationError->new($saleRec, "Revenue with 0 units");
    #}

    if ( $units < 0 && $revenue > 0 && ( $revenue + $incentive ) != 0 ) {
        die Import::ValidationError->new( $saleRec, "Positive revenue with negative units" );
    }

    if ( $units > 0 && $revenue < 0 && ( $revenue + $incentive ) != 0 ) {
        die Import::ValidationError->new( $saleRec, "Negative revenue with positive units" );
    }

}

sub _validateDates {
    my ( $self, $saleRec ) = @_;

    my $dateBegin = $saleRec->dateBegin;
    my $dateEnd   = $saleRec->dateEnd;

    #Individual year, month, day values of $dateBegin
    my $beginYear;
    my $beginMonth;
    my $beginDay;

    #Individual year, month, day values of $dateEnd
    my $endYear;
    my $endMonth;
    my $endDay;

    #dateBegin and dateEnd converted into Unix time to do comparison
    my $dateBeginUnix;
    my $dateEndUnix;

    #Current Unix time
    my $currentTime = time();

    #Get the current year to check year ranges
    my ( $sec, $min, $hour, $mday, $mon, $currentYear, $wday, $yday, $isdst ) = localtime($currentTime);

    #Must add 1900 to current year to get 4 digit year value
    $currentYear += 1900;

    my $currentDate = sprintf "%04d-%02d-%02d", $currentYear, $mon + 1, $mday;

    #Die if $dateBegin or $dateEnd is missing
    if ( !$dateBegin ) {
        die Import::ValidationError->new( $saleRec, "No date begin" );
    }

    if ( !$dateEnd ) {
        die Import::ValidationError->new( $saleRec, "No date end" );
    }

    #Make sure $dateBegin and $dateEnd are in valid ISO format (yyyy-mm-dd)
    if ( $dateBegin =~ /^(\d{4})\-(\d{2})\-(\d{2})$/ ) {
        $beginYear  = $1;
        $beginMonth = $2;
        $beginDay   = $3;
    } else {
        die Import::ValidationError->new( $saleRec, "'Date begin' in invalid format $dateBegin, need: yyyy-mm-dd" );
    }

    if ( $dateEnd =~ /^(\d{4})\-(\d{2})\-(\d{2})$/ ) {
        $endYear  = $1;
        $endMonth = $2;
        $endDay   = $3;
    } else {
        die Import::ValidationError->new( $saleRec, "'Date end' in invalid format $dateEnd, need: yyyy-mm-dd" );
    }

    #Check $dateBegin date ranges
    if ( ( $beginYear < 1990 ) || ( $beginYear > $currentYear ) ) {
        die Import::ValidationError->new( $saleRec, "Date begin year $beginYear out of range" );
    }

    if ( ( $beginMonth == 0 ) || ( $beginMonth > 12 ) ) {
        die Import::ValidationError->new( $saleRec, "Date begin month $beginMonth out of range" );
    }

    my $daysInBeginMonth = Days_in_Month( $beginYear, $beginMonth );
    if ( ( $beginDay < 1 ) || ( $beginDay > $daysInBeginMonth ) ) {
        die Import::ValidationError->new( $saleRec, "Date begin day $beginDay out of range" );
    }

    if ( $dateBegin gt $currentDate ) {
        die Import::ValidationError->new( $saleRec, "Date begin $dateBegin is greater than current date $currentDate" );
    }


    #check $dateEnd date ranges
    if ( ( $endYear < 1990 ) || ( $endYear > ( $currentYear + 1 ) ) ) {
        die Import::ValidationError->new( $saleRec, "Date end year $endYear out of range" );
    }

    if ( ( $endMonth == 0 ) || ( $endMonth > 12 ) ) {
        die Import::ValidationError->new( $saleRec, "Date end month $endMonth out of range" );
    }

    my $daysInEndMonth = Days_in_Month( $endYear, $endMonth );
    if ( ( $endDay < 1 ) || ( $endDay > $daysInEndMonth ) ) {
        die Import::ValidationError->new( $saleRec, "Date end day $endDay out of range" );
    }

    if ( $dateEnd gt $currentDate ) {
        die Import::ValidationError->new( $saleRec, "Date end $dateEnd is greater than current date $currentDate" );
    }
}

sub _validateFree {
    my ( $self, $saleRec ) = @_;
    my $free = $saleRec->isFree;

    return if ( !defined $free or $free =~ /^(Y|N)$/ );
    die Import::ValidationError->new( $saleRec, "Free must be Y or N ('$free' is not valid)" );
}

sub _validateCurrencyCode {
    my ( $self, $saleRec ) = @_;

    my $currencyCode = $saleRec->currencyCode;

    if ( !defined $currencyCode ) {
        die Import::ValidationError->new( $saleRec, "Currency code not set" );
    }

    if ( !Common::CurrencyFormat::CodeIsValid($currencyCode) ) {
        die Import::ValidationError->new( $saleRec, "Invalid or unsupported currency code $currencyCode" );
    }

}

sub _validateCountryCode {
    my ( $self, $saleRec ) = @_;
    my $countryCode = $saleRec->countryCode;

    if ( !defined $countryCode || $countryCode eq '' ) {
        die Import::ValidationError->new( $saleRec, "Country code not set" );
    }

    unless ( Common::Country->GetByAlpha2($countryCode) ) {
        die Import::ValidationError->new( $saleRec, "Invalid or unsupported country code $countryCode" );
    }
}

sub _validateISBN {
    my ( $self, $saleRec ) = @_;

    # !!! Disabling ISBN validation for now.
    #
    #    my $isbn10 = $saleRec->isbn10;
    #    my $isbn13 = $saleRec->isbn13;
    #
    #    if ($isbn10)
    #    {
    #        if ($isbn10 =~ /\D/)
    #        {
    #            die Import::ValidationError->new($saleRec, "Invalid isbn10 '$isbn10'");
    #        }
    #    }
    #
    #    if ($isbn13)
    #    {
    #        if ($isbn13 =~ /\D/)
    #        {
    #            die Import::ValidationError->new($saleRec, "Invalid isbn10 '$isbn13'");
    #        }
    #    }
}

sub _validateTaxAmount {
    my ( $self, $saleRec ) = @_;

    my $taxAmount = $saleRec->taxAmount;
    my $revenue   = $saleRec->revenue;

    if ($taxAmount) {
        if ( $taxAmount !~ /^\-?(?:(?:\d+)?\.\d+|\d+(?:\.)?(?:\d+)?)$/ ) {
            die Import::ValidationError->new( $saleRec, "tax amount $taxAmount is in an incorrect format" );
        }

        if ( $taxAmount < 0 && $revenue > 0 ) {
            die Import::ValidationError->new( $saleRec, "Positive revenue with negative tax" );
        }

        if ( $taxAmount > 0 && $revenue < 0 ) {
            die Import::ValidationError->new( $saleRec, "Negative revenue with positive tax" );
        }
    }
}

#  Need to use the bookpub service list.
#
sub _postProcess {
    my ( $self, %args ) = @_;

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

}

# A static method to standardize the logic for parsing a title into title and subtitle.
# What we commonly see is ':' used as the separator.  However, sometime there are more than
# one of those (i.e 'Food: Basics: Deserts' - 'Deserts' is the subtitle).
# So we'll use the _last_ ':' as the separator.
# Obviously this won't cover all conceivable situations, but it does come up a lot...
#
sub ParseTitleAndSubtitle {
    my ( $title, $separator ) = @_;
    $separator = ':' unless $separator;

    my $subtitle;
    my @titleBits = split( $separator, $title );
    if ( scalar @titleBits > 1 ) {
        $subtitle = pop @titleBits;
        $title = join( ':', @titleBits );
    }

    $title    = Common::Util::trimspaces($title);
    $subtitle = Common::Util::trimspaces($subtitle);

    return ( $title, $subtitle );
}

# Override with a 1 if 'title' and 'subtitle' should automatically
# come from rTitle as passed through ParseTitleAndSubtitle
sub _autoParseTitleAndSubtitle { 0 }

# pass the unit-price and list-price to determine the discount rate
# # cmd line testing (from BookPub::Import)
# # # perl -MImporter -e "print BookPub::Import::Importer::CalculateDiscount(9.99, 14.99)"
sub CalculateDiscount {
    my ( $unitPrice, $listPrice ) = @_;
    return unless ( $listPrice =~ m/\d/ && $listPrice - 0 );

    my $rate = abs( $unitPrice / $listPrice );

    return ( $rate > 1 or 1 - $rate < 0.011 ) ? 0 : 1 - $rate;
}

sub _getAgencyPriceType {
    my $self = shift;

    unless ( defined( $self->{_disableAgencyPricing} ) ) {
        my $config = new Common::ClientSettings::Service( serviceID => $self->serviceID );

        $self->{_disableAgencyPricing} = $config->{DisableAgencyPricing} ? 1 : 0;
    }

    if ( $self->{_disableAgencyPricing} ) {
        return BookPub::DB::Item::PriceType::kPriceTypeRRP;
    } else {
        return BookPub::DB::Item::PriceType::kPriceTypeAgency;
    }
}

sub _getAgencyPlusTaxPriceType {
    my $self = shift;

    unless ( defined( $self->{_disableAgencyPricing} ) ) {
        my $config = new Common::ClientSettings::Service( serviceID => $self->serviceID );

        $self->{_disableAgencyPricing} = $config->{DisableAgencyPricing} ? 1 : 0;
    }

    if ( $self->{_disableAgencyPricing} ) {
        return BookPub::DB::Item::PriceType::kPriceTypeRRPPlusTax;
    } else {
        return BookPub::DB::Item::PriceType::kPriceTypeAgencyPlusTax;
    }
}

sub _getRRPPriceType {
    my $self = shift;

    unless ( defined( $self->{_disableRRPPricing} ) ) {
        my $config = new Common::ClientSettings::Service( serviceID => $self->serviceID );

        $self->{_disableRRPPricing} = $config->{DisableRRPPricing} ? 1 : 0;
    }

    if ( $self->{_disableRRPPricing} ) {
        return BookPub::DB::Item::PriceType::kPriceTypeAgency;
    } else {
        return BookPub::DB::Item::PriceType::kPriceTypeRRP;
    }
}

sub _getRRPPlusTaxPriceType {
    my $self = shift;

    unless ( defined( $self->{_disableRRPPricing} ) ) {
        my $config = new Common::ClientSettings::Service( serviceID => $self->serviceID );

        $self->{_disableRRPPricing} = $config->{DisableRRPPricing} ? 1 : 0;
    }

    if ( $self->{_disableRRPPricing} ) {
        return BookPub::DB::Item::PriceType::kPriceTypeAgencyPlusTax;
    } else {
        return BookPub::DB::Item::PriceType::kPriceTypeRRPPlusTax;
    }
}

# Code for dynamic column mapping and file identification

sub _requiredColumnMap { }
sub _optionalColumnMap { }
sub _excludedColumns   { }

sub _fieldMap {
    my $self = shift;

    if ( $self->_requiredColumnMap || $self->_optionalColumnMap() ) {
        return $self->_generateFieldMap();
    } else {
        return $self->SUPER::_fieldMap();
    }
}

sub _generateFieldMap {
    my $self = shift;

    return unless ( $self->line );
    return $self->{_fieldMap} if ( $self->{_fieldMap} );

    assert( $self->_requiredColumnMap(), "requiredColumnMap must return a value" );

    if ( $self->_isHeaderGenerated() ) {
        $self->{_fieldMap} = $self->_createFieldMap();

        Log->info( "Field Map: " . Dumper( $self->{_fieldMap} ) );
        return $self->{_fieldMap};
    }

    return;

}

sub _createFieldMap {
    my $self = shift;
    my @missing;
    my $result;
    my $i;

    my @line = @{ $self->line };

    my $map         = $self->_requiredColumnMap() || return;
    my $optionalMap = $self->_optionalColumnMap() || {};

    foreach my $header ( keys %$map ) {
        Log->debug("Checking for '$header'");
        for ( $i = 0 ; $i < scalar @line ; $i++ ) {
            if ( $self->_headerMatch( $line[$i], $map->{$header} ) ) {
                $result->{$header} = $i;
                last;
            }
        }

        push( @missing, $header ) unless ( defined( $result->{$header} ) );
    }

    foreach my $header ( keys %$optionalMap ) {
        Log->debug("Checking for '$header'");
        for ( $i = 0 ; $i < scalar @line ; $i++ ) {
            if ( $self->_headerMatch( $line[$i], $optionalMap->{$header} ) ) {
                $result->{$header} = $i;
                last;
            }
        }
    }

    $self->fail("Missing required column: @missing") if (@missing);

    return $result;
}

sub _isHeaderGenerated {
    my $self       = shift;
    my $matchCount = 0;

    return unless ( $self->line );

    my $map = $self->_requiredColumnMap() || return;
    my $minMatchCount = $self->_getMinMatchCount();

    Log->debug(" ** Minimum header match count: $minMatchCount");

    foreach my $cell ( @{ $self->line } ) {
        next unless ($cell);

        # If we find an excluded column then we fail immediatly.
        foreach my $excludedHeader ( $self->_excludedColumns ) {
            return if ( $cell =~ /^$excludedHeader$/ );
        }

        foreach my $header ( keys %$map ) {
            if ( $self->_headerMatch( $cell, $map->{$header} ) ) {
                $matchCount++;
            }
        }
    }

    # If we have matched three headers then we call it good.
    return $matchCount >= $minMatchCount;
}

sub _getMinMatchCount {
    my $self = shift;
    my $map = $self->_requiredColumnMap() || die "required column map not defined";

    my $requiredColumns = scalar keys(%$map);
    my $count           = $self->_minimumHeaderMatchCount();
    $count = undef if ( $count && $count eq 'all' );

    if ( !$count || $count > $requiredColumns ) {
        return $requiredColumns;
    } else {
        return $count;
    }
}

sub _headerMatch {
    my $self   = shift;
    my $cell   = shift || return;
    my $header = shift || return;
    chomp($cell);

    my @headers = ref($header) ? @$header : ($header);

    for my $h (@headers) {
        Log->debug("Cell data: '$cell', Header: '$h'");
        return 1 if ( $cell =~ /^$h$/ );
    }

    return;
}

##
##  File identification
##

# Pattern for filename match
sub _matchFilename { }

# Check for a header on any row, not just the first few rows.
sub _matchOnAnyRow { }

# Number of rows that should be examined for a header
sub _headerRowCount { 8 }

# Number of required headers must match before a positive match is returned.
# Use undef or 'all' for all rows.  If an integer is used the actual count will
# be the specified number or the sheet column count, whichever is less.
sub _minimumHeaderMatchCount { 'all' }

# Look for the identifying header on this sheet index.  0 based.
# use 'any' or undef to search in all sheets.  If you specify all, then
# we require a header on ALL sheets.
sub _sheetIndex { 0 }

# If looking for headers on ALL sheets, ignore these sheets.  This can be an
# integer or list.
sub _ignoreSheetIndex { }

sub _isIgnoredSheet {
    my $self       = shift;
    my $sheetIndex = shift;

    return unless ( defined($sheetIndex) );

    return unless ( defined( $self->_ignoreSheetIndex ) );

    my @indexes = $self->_ignoreSheetIndex();
    @indexes = @{ $indexes[0] } if ( ref( $indexes[0] ) );

    for my $i (@indexes) {
        return 1 if ( $i == $sheetIndex );
    }

    return;
}

sub canParse {
    my $self = shift;
    my %args = @_;
    my $sheetIndex;
    my $lineIndex;
    my $headerMatch;

    my $matchAnySheet  = $self->_sheetIndex && $self->_sheetIndex eq 'any' ? 1 : undef;
    my $matchAllSheets = $self->_sheetIndex && $self->_sheetIndex eq 'all' ? 1 : undef;
    my $sheetMatch = $self->_sheetIndex() unless ( $matchAnySheet || $matchAllSheets );

    my $maxRows = $self->_headerRowCount() || die "header row count not defined";

    # In BookPub::Sale::File when we say match on any row it is actually limited
    # to 80 rows.
    $maxRows = 80 if ( $self->_matchOnAnyRow() );

    my $sheets   = $args{sheets};
    my $filename = $args{filename};

    Log->info( " **** " . ref($self) . "->canParse ****" );

    # Check for a filename match
    if ( $self->_matchFilename() ) {
        my $fnamePattern = $self->_matchFilename();
        Log->info("  ---  Test filename '$filename' matches '$fnamePattern'");
        return unless ( $filename =~ m/$fnamePattern/i );
    }

    # Start iterating over sheets.
    for ( $sheetIndex = 0 ; $sheetIndex < scalar @{$sheets} ; $sheetIndex++ ) {
        Log->info("  ---  Test for headers. $filename  sheet: $sheetIndex");
        $self->{sheet} = $sheets->[$sheetIndex];

        # Is the the sheet we are looking for?
        if ( defined($sheetMatch) && $sheetMatch != $sheetIndex ) {
            Log->info( sprintf( "  Failed sheet match.  Match Index: %s", defined($sheetMatch) ? $sheetMatch : 'any' ) );
            next;
        }

        # Should we ignore this sheet?
        if ( $self->_isIgnoredSheet($sheetIndex) ) {
            Log->info("  Ignore sheet match. ");
            next;
        }

        $headerMatch = undef;
        $lineIndex   = undef;

        for ( $lineIndex = 0 ; $lineIndex < $maxRows && !$headerMatch ; $lineIndex++ ) {
            $self->{line} = $sheets->[$sheetIndex]->[$lineIndex];
            $headerMatch = $self->_isHeaderGenerated();

            # If we are matching any sheet and we find a match we are done!
            return 1 if ( $headerMatch && $matchAnySheet );
        }

        # If we have a sheet without a header match and we are matching all
        # sheets we are done.
        if ( $matchAllSheets && !$headerMatch ) {
            Log->info("  Failed header match.  sheet Index: $sheetIndex");
            return undef;
        }
    }

    return $headerMatch;
}

sub listPriceRequired {
    my $clientID = Common::RSApp::GetClientID();

    # The list of clients who require list price for their output file.
    # Currently: Macmillan, Wiley, and RLPG
    my @clientList = ( 318, 324, 346 );
    foreach my $client (@clientList) {
        if ( $client == $clientID ) {
            return 1;
        }
    }

    return 0;
}

# Caching
sub _loadCache {
    my ( $self, $key, @args ) = @_;

    my %map = ( book_product_isbn => \&BookPub::DB::Item::BookProduct::GetISBN );

    if ( exists $map{$key} ) {
        $self->{__cache}{$key} = &{ $map{$key} }(@args);
    }

    return 1;
}

sub _reloadCache {
    my ( $self, $key ) = @_;

    $self->_clearCache($key);
    $self->_loadCache($key);
    return 1;
}

sub _clearCache {
    my ( $self, $key ) = @_;

    delete $self->{__cache} unless defined $key;
    delete $self->{__cache}{$key} if exists $self->{__cache}{$key};
    return 1;
}

sub _getFromCache {
    my ( $self, $key ) = @_;

    unless ( exists $self->{__cache}{$key} && ref $self->{__cache}{$key} ) {
        $self->_loadCache($key);
    }

    if ( exists $self->{__cache}{$key} && ref $self->{__cache}{$key} ) {
        return $self->{__cache}{$key};
    }

    return;
}

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