package RPS::Import::Importer;

use strict;

# When adding/modifying an importer, edit the following files:
#   data_classes/lib/Client/Service.pm
#   sale_import/lib/Sale/File.pm
#   sale_import/lib/Import/Service.pm
#   sale_import/lib/Import/<FILE>

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

use lib '/app/tools/data_classes/lib/';
use Client::Service;

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

use lib '/app/tools/raptor/lib/';
use Raptor::DB::Item::Sale;

use lib '/app/tools/metadata/lib';
use Validate::Util;

use lib '/app/tools/common/lib';
use Common::RSApp;
use Common::Client;
use Common::Locale;
use Common::Consts;
use Common::Country;
use Common::Log;
use Common::Assert;
use Common::CurrencyFormat;
use Common::Util;
use Common::Email;
use Common::DB::Item::ServiceCategory;

use lib '/app/tools/rps/lib';
use RPS::DB::Item::MediaType;
use RPS::Sale::Match;
use RPS::File::Sale;
use RPS::Import::SaleRec;
use RPS::DB::Item::ClientOptions;

use base 'Import::Importer';

use constant WMG => 21;

my $SERVICE_DECORATION = '_(mobile|wireless|networks?|media)$';
my $SERVICE_MAP        = {};

# List of valid product types along with their digital/physical type.
#
my %validProductTypes = (
    RPS::File::Sale::TYPE_ALBUM          => "digital",
    RPS::File::Sale::TYPE_TRACK          => "digital",
    RPS::File::Sale::TYPE_PHYSICAL       => "physical",
    RPS::File::Sale::TYPE_LP             => "physical",
    RPS::File::Sale::TYPE_LP5            => "physical",
    RPS::File::Sale::TYPE_CD             => "physical",
    RPS::File::Sale::TYPE_VHS            => "physical",
    RPS::File::Sale::TYPE_CASS           => "physical",
    RPS::File::Sale::TYPE_VCD            => "physical",
    RPS::File::Sale::TYPE_EP             => "physical",
    RPS::File::Sale::TYPE_MD             => "physical",
    RPS::File::Sale::TYPE_DVD            => "physical",
    RPS::File::Sale::TYPE_BLURAY         => "physical",
    RPS::File::Sale::TYPE_MASTER         => "physical",
    RPS::File::Sale::TYPE_CAS_SIN        => "physical",
    RPS::File::Sale::TYPE_CD_SIN         => "physical",
    RPS::File::Sale::TYPE_DVD_CD_SET     => "physical",
    RPS::File::Sale::TYPE_DBL_CD         => "physical",
    RPS::File::Sale::TYPE_LICENSE_INCOME => "physical",
);

sub importerType { 'rps' }

sub _getClientOptionByName {
    my ($self, $name) = @_;

    return 0 unless defined $name;
    return RPS::DB::Item::ClientOptions->Get($name);
}

sub isDigitalProduct {
    my $self        = shift;
    my $productType = shift;

    if ( $validProductTypes{$productType} eq "digital" ) {
        return 1;
    } else {
        return 0;
    }
}

# Valid accessor fields.  Lists all the valid accessors that can be used in the
# _getOffset method.
sub _validAccessors {
    qw(
      albumName artistName averagePrice channel clientProductID configuration
      conversionRate countryCode currencyCode free importStatus isrc labelName
      outlet payoutType price wholesalePrice retailPrice priceLevel priceType
      retail returns returnsRevenue sales salesRevenue serviceID serviceProductID
      totalRevenue trackName trackNum units upc wholesaleRate mcps_adjustment
      comments mediaType productType formatType dateBegin dateEnd type grossRevenue
    );
}

# 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 albumName        { shift->_getByFieldName('albumName') }
sub artistName       { shift->_getByFieldName('artistName') }
sub averagePrice     { shift->_getByFieldName('averagePrice') }
sub channel          { shift->_getByFieldName('channel') }
sub clientProductID  { shift->_getByFieldName('clientProductID') }
sub configuration    { shift->_getByFieldName('configuration') }
sub conversionRate   { shift->_getByFieldName('conversionRate') }
sub countryCode      { shift->_getByFieldName('countryCode') }
sub currencyCode     { shift->_getByFieldName('currencyCode') }
sub free             { shift->_getByFieldName('free') }
sub importStatus     { shift->_getByFieldName('importStatus') }
sub isrc             { shift->_getByFieldName('isrc') }
sub labelName        { shift->_getByFieldName('labelName') }
sub outlet           { shift->_getByFieldName('outlet') }
sub payoutType       { shift->_getByFieldName('payoutType') }
sub price            { shift->_getByFieldName('price') }
sub wholesalePrice   { shift->_getByFieldName('wholesalePrice') }
sub retailPrice      { shift->_getByFieldName('retailPrice') }
sub priceLevel       { shift->_getByFieldName('priceLevel') }
sub priceType        { shift->_getByFieldName('priceType') }
sub retail           { shift->_getByFieldName('retail') }
sub returns          { shift->_getByFieldName('returns') }
sub returnsRevenue   { shift->_getByFieldName('returnsRevenue') }
sub sales            { shift->_getByFieldName('sales') }
sub salesRevenue     { shift->_getByFieldName('salesRevenue') }
sub serviceProductID { shift->_getByFieldName('serviceProductID') }
sub totalRevenue     { shift->_getByFieldName('totalRevenue') }
sub trackName        { shift->_getByFieldName('trackName') }
sub trackNum         { shift->_getByFieldName('trackNum') }
sub units            { shift->_getByFieldName('units') }
sub upc              { shift->_getByFieldName('upc') }
sub wholesaleRate    { shift->_getByFieldName('wholesaleRate') }
sub mcps_adjustment  { shift->_getByFieldName('mcps_adjustment') }
sub comments         { shift->_getByFieldName('comments') }
sub grossRevenue     { shift->_getByFieldName('grossRevenue') }

# These accessors don't call the default accessor method because they are usually
# determined by 1 field, saleType.
sub mediaType   { shift->_getBySaleType('mediaType') }
sub productType { shift->_getBySaleType('productType') }
sub formatType  { shift->_getBySaleType('formatType') }

# saleType is a special field, not in the sale record, that is used when we
# use a single field to determin mediaType, productType and formatType.
sub type { shift->_getByFieldName('type') }

# This is the class we use for sale table access, by default.
#
sub _defaultSaleObjectClass { return 'RPS::File::Sale'; }

sub _instantiateDBO {
    my ($self) = @_;
    assert( $self->{clientID} );

    return Common::RSDB->new( client_id => $self->{clientID} );
}

# this an importer for physical or digital products
sub physical { }

# pass the potentially ugly service name from a sales file and get a service_id back
# perl -MImport::Importer -le "print Import::Importer->getServiceID('SERVICE_NAME')"
sub getServiceID {
    my $self    = shift;
    my $service = lc $_[0];

    # Strip out diacriticals and other unamericanisms.
    # This two step procedure will first decompose these characters into their
    # component parts (i.e. the letter and the marks), then strips out the marks.
    #
    $service = Unicode::Normalize::NFKD($service);
    $service =~ s/\p{NonspacingMark}//g;

    $service =~ s/^\s+//;
    $service =~ s/\s+$//;
    $service =~ s/,?\s+(inc(orporated)?|llc|corp(oration)?)\.?\b//;
    $service =~ s/^t[-\s]+(mobile|online)/t$1/;    # t-mobile, t-online
    $service =~ s/\s*-a-\s*/a/;                    # dig-a-dub
    $service =~ s/\s*&\s*/_/;                      # mix & burn, at&t
    $service =~ s/\s*-\s*/_/g;                     # all others with a '-' become '_'
    $service =~ s/\.com//;
    $service =~ s/\s*[^\w\s]+\s*//g;
    $service =~ s/\s+/_/g;

    return unless $service;

    _getServiceMap() unless keys %$SERVICE_MAP;

    my $altService;
    ( $altService = $service ) =~ s/$SERVICE_DECORATION//;
    return $SERVICE_MAP->{$service} || $SERVICE_MAP->{$altService} || undef;
}

# pass the potentially ugly format name from a sales file and get the RS format_id back
# perl -MImport::Importer -le "print Import::Importer->getWirelessFormatID('FORMAT_NAME')"
#
# this was originally done for WMG but a more robust solution now exists in
# Import::WMG::Util as getWirelessFormatTypeID
# no other clients currently distinguish between the various wireless formats but we'll
# leave it here for now in case that changes
sub getWirelessFormatID {
    my $self   = shift;
    my $format = lc $_[0];

    return $format =~ /voice/ ? RPS::File::Sale::FORMAT_RINGTONE :    # was FORMAT_VOICERINGER (FB1324)
      $format =~ /hi\W?fi/    ? RPS::File::Sale::FORMAT_RINGTONE :    # was FORMAT_MASTERTONE (FB1324)
      $format =~ /phonic/     ? RPS::File::Sale::FORMAT_RINGTONE :    # was FORMAT_MIDI (FB1324)
      $format =~ /ringback/   ? RPS::File::Sale::FORMAT_RINGBACK :

      #$format =~ /(wallpaper|graphic|screen.?saver)/ ? RPS::File::Sale::FORMAT_GRAPHIC : # retired (FB1324)
      $format =~ /video/ ? RPS::File::Sale::FORMAT_RINGTONE :         # was FORMAT_VIDEORINGER (FB1324)
      $format =~ /tone/  ? RPS::File::Sale::FORMAT_RINGTONE :         # was FORMAT_MASTERTONE (FB1324)
      '';
}

sub numericToDate {
    my $self = shift;
    my @date = Add_Delta_Days( 1900, 1, 1, shift );
    return wantarray ? @date : sprintf( "%d-%02d-%02d", @date );
}

# !!! This will have to be updated to be language-aware as well.
# Some countries use ',' for decimals and '.' or ' ' or '`' for their
# thousands seperator...
#
sub numeric {
    my $self = shift;
    my $number = join( '', @_ );

    $number =~ s/^[^\d\-\.]*//;
    my $negative = $number =~ s/^-// ? 1 : 0;

    $number =~ s/,(\d\d\d)/$1/g;
    $number =~ s/,/./;    # should only be 1 leftover comma at this point (if any) which should be a decimal pt
    $number =~ s/[^\d\.]+//g;
    $number =~ s/\.00$//;

    $number *= -1 if $negative;

    return $number =~ m/^-?\d*\.?\d+$/ ? $number : undef;
}

sub is_wmg {
    my $self = shift;
    return $self->{clientID} == WMG ? 1 : 0;
}

#
# Private methods
#

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

    my $saleRec = $args{sale};

    # Set mediaType to default value if it's not already set.
    $saleRec->mediaType( Raptor::DB::Item::Sale::kMediaTypeDefault() ) if ( $saleRec->mediaType eq '' );

    # Normalize ISRC
    $saleRec->isrc( Common::Util::normalize_isrc( $saleRec->isrc ) ) if ( $saleRec->isrc );

    # 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 );

    if ( $saleRec->serviceID == 5 && $saleRec->formatType eq RPS::File::Sale::FORMAT_DOWNLOAD ) {
        $saleRec->formatType(RPS::File::Sale::FORMAT_VPD);
    }

    # 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',
    );

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

    # If the derived class didn't set the currencyCode explicitly, we'll default to 'USD'.
    # - Yes, 'USD', even if that isn't our client's base currency.  This is because we're
    # assuming that the _file_ came from a US source.
    #
    # !!! I am _VERY_ tempted to remove this bit of code.  We should set the currency code in every importer, period.
    #
    if ( !defined $saleRec->currencyCode ) {
        $saleRec->currencyCode('USD');
    }

    # Similar to what we do for country codes above, we'll map currency codes to what they should be here.
    my %currenciesFixed = ( RMB => 'CNY', );

    $saleRec->currencyCode( $currenciesFixed{ $saleRec->currencyCode } ) if ( defined $currenciesFixed{ $saleRec->currencyCode } );

    # 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.
    #
    my $nativeCurrency = Common::Client::Current()->Locale()->currencyFormat()->currencyCode();
    if ( $saleRec->currencyCode ne $nativeCurrency ) {
        $saleRec->conversionRate(0);
    } else {
        $saleRec->conversionRate(1);
    }

    $self->_checkForRevenueWithoutUnits($saleRec);

    $self->_sanitizeFormatType($saleRec);

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

    my $sale = $self->_saleObjectClass()->CreateFromSaleRec(
        saleRec => $saleRec,
        file => $self->{_file},
        hasError => $self->{_hasError},
        linesImported => $self->{_linesImported},
        salesBatchMode => $self->salesBatchMode(),
        salesBatchSize => $self->salesBatchSize()
    );

    # Ok, now we need to go through and fill in the sale id for any sale_import_error and sale_import_error_raw_value
    # entries we created for this sale -- IF this is an importer that has opted in to MapFace.
    $self->_fillInSaleID( $sale->SaleID() );
}

sub _fillInSaleID {

    # The MapFace base class overloads this and actually does something with the sale ID.
}

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

    my $sale = $args{sale};

    warn join( "\t",
        $sale->lineNum,   $sale->productType,      $sale->formatType,      $sale->dateBegin,
        $sale->dateEnd,   $sale->serviceProductID, $sale->clientProductID, $sale->upc,
        $sale->isrc,      $sale->artistName,       $sale->albumName,       $sale->trackName,
        $sale->labelName, $sale->units,            $sale->price,           $sale->free ),
      "\n";

}

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

    my $saleDB = $args{sale};

    my $data = {
        service_id         => $self->{fileServiceID},
        media_type         => $saleDB->MediaType,
        album              => $saleDB->AlbumName,
        artist             => $saleDB->ArtistName,
        client_product_id  => $saleDB->ClientProductID,
        format             => $saleDB->FormatType,
        isrc               => $saleDB->ISRC,
        product_type       => $saleDB->ProductType,
        service_product_id => $saleDB->ServiceProductID,
        track_num          => $saleDB->TrackNum,
        track              => $saleDB->TrackName,
        upc                => $saleDB->UPC,
        outlet             => $saleDB->Outlet,
    };

    my $match = RPS::Sale::Match->new( client_id => $self->{clientID} );
    my $result = $match->FindBestMatch( data => $data, min_match_level => 90, skip_rec => 1 );

    $saleDB->ImportStatus( $result->ImportStatus );
    if ( $saleDB->ImportStatus == File::Sale::STATUS_MATCH ) {
        $saleDB->ProductID( $result->ProductIDs->[0] );
    } elsif ( $saleDB->ImportStatus == File::Sale::STATUS_MAPPED ) {
        $saleDB->ProductID( $result->ProductIDs->[0] );
        $saleDB->MapID( $result->MapID );
    } elsif ( $saleDB->ImportStatus == File::Sale::STATUS_AUTO_MAPPED ) {
        $saleDB->ProductID( $result->ProductIDs->[0] );
        $saleDB->MapID( $result->MapID );
    } elsif ( $saleDB->ImportStatus == File::Sale::STATUS_BATCH_MAPPED ) {
        $saleDB->ProductID( $result->ProductIDs->[0] );
        $saleDB->MapID( $result->MapID );
    } elsif ( $saleDB->ImportStatus == File::Sale::STATUS_DONT_MATCH ) {
        $saleDB->ProductID( $result->ProductIDs->[0] );
        $saleDB->MapID( $result->MapID );
    }
}

sub _getServiceMap {
    $SERVICE_MAP = Client::Service::GetServiceNameMap( clean => 1 );

    # some "other" variations of service names we support
    $SERVICE_MAP->{'3gupload/mixxer_inc'}  = Client::Service::DSP_MIXXER;
    $SERVICE_MAP->{'9_squared'}            = Client::Service::DSP_NINESQUARED;
    $SERVICE_MAP->{america_online}         = Client::Service::DSP_AOL;
    $SERVICE_MAP->{at_t_wireless_services} = Client::Service::DSP_ATT;
    $SERVICE_MAP->{docomo}                 = Client::Service::DSP_NTTDOCOMO;
    $SERVICE_MAP->{groovemobile}           = Client::Service::DSP_GROOVEMOBILE;
    $SERVICE_MAP->{microsoft}              = Client::Service::DSP_MSNMUSIC;
    $SERVICE_MAP->{musicnet}               = Client::Service::DSP_MEDIANET;
    $SERVICE_MAP->{realnetworks}           = Client::Service::DSP_REAL;
    $SERVICE_MAP->{rhapsody}               = Client::Service::DSP_REAL;
    $SERVICE_MAP->{ringtonejukebox}        = Client::Service::DSP_RINGTONEJUKEBOX;
    $SERVICE_MAP->{imeem}                  = Client::Service::DSP_IMEEMINC;
    $SERVICE_MAP->{jriver}                 = Client::Service::DSP_JRIVER;
    $SERVICE_MAP->{slacker}                = Client::Service::DSP_SLACKERINC;
    $SERVICE_MAP->{djtunes}                = Client::Service::DSP_DJTUNES;
    $SERVICE_MAP->{hip_digital_media_us}   = Client::Service::DSP_HIP_DIGITAL_MEDIA;

    # add some known alternate naming conventions
    my %altNames = ();
    while ( my ( $svc, $id ) = each %$SERVICE_MAP ) {
        next unless ( $svc =~ s/$SERVICE_DECORATION// );
        next if ( exists $SERVICE_MAP->{$svc} );

        if ( exists $altNames{$svc} ) {
            delete $altNames{$svc};
            next;
        }
        $altNames{$svc} = $id;
    }

    while ( my ( $svc, $id ) = each %altNames ) {
        $SERVICE_MAP->{$svc} = $id;
    }
}

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

    my $productTypeStr = $self->_validateProductType($saleRec);

    # If we have a digital sale with revenue but no units, set units to 1 or -1.
    # Also, reset the price since unitPrice will have set it to 0.
    if ( ( $productTypeStr eq "digital" || $self->{_file}->Physical == 0 ) && $saleRec->units == 0 && $saleRec->price ) {
        my $price = $self->_formatPrice( $saleRec->price );

        if ( $price < 0 ) {
            $saleRec->units(-1);
        } elsif ( $price > 0 ) {
            $saleRec->units(1);
        }

        $saleRec->price( abs($price) );
    }
}

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

    if ( $saleRec->price > 0 ) {

        # thankfully (at least i think so?), publishing royalties are paid directly
        # in other countries besides US and Canada, so not going to worry about
        # those. which is nice, because it's not a manageable thing to sanity check
        # prices in, well, every other currency in the world.
        if ( $saleRec->currencyCode eq 'USD' || $saleRec->currencyCode eq 'CAD' ) {

            # we'll treat albums and tracks the same, meaning if an album
            # download or ringtone can be co-erced to a stream, it must be
            # be a single-track album, at least practical purposes.
            #
            # the only thing magical about 20- or 50-cents is that it's twice
            # the going mechanical rate at the moment. so really, meaning
            # there's room to pay it.
            #
            # Notice that we don't coerce album sales to stream as singles/EPs
            # may vary greatly in price.
            if (   $saleRec->formatType eq RPS::File::Sale::FORMAT_DOWNLOAD
                || $saleRec->formatType eq RPS::File::Sale::FORMAT_DUALDOWNLOAD
                || $saleRec->formatType eq RPS::File::Sale::FORMAT_DOWNLOADPREMIUM
                || $saleRec->formatType eq RPS::File::Sale::FORMAT_VPD ) {
                if ( $saleRec->productType eq RPS::File::Sale::TYPE_TRACK && $saleRec->price < .20 ) {
                    $saleRec->formatType(RPS::File::Sale::FORMAT_STREAM);
                }
            } elsif ( $saleRec->formatType eq RPS::File::Sale::FORMAT_RINGTONE ) {
                if ( $saleRec->price < .50 ) {
                    $saleRec->formatType(RPS::File::Sale::FORMAT_STREAM);
                }
            }
        }
    }
}

# 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 ) = @_;

    my $productTypeStr = $self->_validateProductType($saleRec);

    #print STDERR "product type string = $productTypeStr\n";
    #print STDERR "file physical value = " . $self->{_file}->Physical . "\n";

    if ( $productTypeStr eq "physical" || $self->{_file}->Physical == 1 ) {
        $self->_validateSalesReturns($saleRec);
        $self->_validatePriceLevel($saleRec);
        $self->_validateChannel($saleRec);
        $self->_validatePhysicalFormatType($saleRec);
    } else {
        $self->_validatePrice($saleRec);
        $self->_validateUnits($saleRec);
        $self->_validateDigitalFormatType($saleRec);
    }

    $self->_validateGrossRevenue($saleRec);

    $self->_validateMediaType($saleRec);
    $self->_validateDates($saleRec);
    $self->_validateFree($saleRec);
    $self->_validateCurrencyCode($saleRec);
    $self->_validateCountryCode($saleRec);
    $self->_validateUPC($saleRec);
    $self->_validateService($saleRec);

    # Files with Physical == 2 can include both digital and physical sales.
    # We want to make sure those sales aren't storing units and revenue
    # in both the physical and digital columns.
    if ( $self->{_file}->Physical == 2 ) {
        $self->_validateRevenueFields($saleRec);
        $self->_validateUnitFields($saleRec);
    }
}

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

    my $totalRevenue = $saleRec->totalRevenue;    # physical
    my $price        = $saleRec->price;           # digital

    if ( $totalRevenue != 0 && $price != 0 ) {
        return $self->handleError( "Digital and physical revenue stored for sale", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

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

    my $sales   = $saleRec->sales;                # physical
    my $returns = $saleRec->returns;              # physical
    my $units   = $saleRec->units;                # digital

    if ( ( $sales || $returns ) && $units ) {
        return $self->handleError( "Digital and physical units stored for sale", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

#------------------------------------------------------------
# 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 ( !$productType ) {
        $productType = $self->handleError( "Missing product type", RPS::DB::Item::SaleImportError::kErrorProductType );
        $saleRec->productType($productType);
        return;
    }

    if ( !$validProductTypes{$productType} ) {
        $productType = $self->handleError( "Invalid product type: $productType", RPS::DB::Item::SaleImportError::kErrorProductType );
        $saleRec->productType($productType);
        return;
    }

    return $validProductTypes{$productType};
}

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

    my $sales          = $saleRec->sales;
    my $salesRevenue   = $saleRec->salesRevenue ? $saleRec->salesRevenue : 0;
    my $returns        = $saleRec->returns;
    my $returnsRevenue = $saleRec->returnsRevenue ? $saleRec->returnsRevenue : 0;
    my $totalRevenue   = $saleRec->totalRevenue;

    #Go through all 4 possibilities for sales and returns being either
    #set or not set.
    if ( !( $sales || $returns ) ) {
        return $self->handleError( "No sales or returns set for physical sale", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( $sales && !$returns ) {
        $self->_validateSales($saleRec);
        $self->_validateSalesRevenue($saleRec);
        $self->_validateTotalRevenue($saleRec);
        $self->_validateSaleTotalSum($saleRec);
    }

    if ( $sales && $returns ) {
        $self->_validateSales($saleRec);
        $self->_validateSalesRevenue($saleRec);
        $self->_validateReturns($saleRec);
        $self->_validateReturnsRevenue($saleRec);
        $self->_validateTotalRevenue($saleRec);
    }

    if ( !$sales && $returns ) {
        $self->_validateReturns($saleRec);
        $self->_validateReturnsRevenue($saleRec);
        $self->_validateTotalRevenue($saleRec);

        $self->_validateReturnTotalSum($saleRec);
    }

    $self->_validateTotalSum($saleRec);
}

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

    my $salesRevenue   = $saleRec->salesRevenue   ? $saleRec->salesRevenue   : 0;
    my $returnsRevenue = $saleRec->returnsRevenue ? $saleRec->returnsRevenue : 0;
    my $totalRevenue   = $saleRec->totalRevenue;

    # No matter WHAT, I expect this to be correct.
    #
    my $expectedRevenue = $salesRevenue - $returnsRevenue;

    #First check to see if the total revenue is within a penny of the
    #expected revenue, then check to see if the sign of the total revenue
    #matches the sign of $salesRevenue - $returnsRevenue within a penny
    if (   ( ( abs($totalRevenue) - abs($expectedRevenue) ) > .01 )
        || ( ( $salesRevenue > $returnsRevenue ) && ( ( $totalRevenue + .01 ) < 0 ) )
        || ( ( $salesRevenue < $returnsRevenue ) && ( ( $totalRevenue - .01 ) > 0 ) ) ) {
        return $self->handleError(
            "Total revenue $totalRevenue does not equal sales revenue $salesRevenue minus returns revenue $returnsRevenue",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

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

    my $returnsRevenue = $saleRec->returnsRevenue ? $saleRec->returnsRevenue : 0;
    my $totalRevenue = $saleRec->totalRevenue;

    if (   ( ( abs($totalRevenue) - abs($returnsRevenue) ) > .01 )
        || ( ( $totalRevenue - .01 ) > 0 ) ) {
        return $self->handleError(
            "Returns revenue $returnsRevenue negated does not equal total revenue $totalRevenue for sale with no sales",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying,
        );
    }
}

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

    my $salesRevenue = $saleRec->salesRevenue ? $saleRec->salesRevenue : 0;
    my $totalRevenue = $saleRec->totalRevenue;

    if (   ( ( abs($salesRevenue) - abs($totalRevenue) ) > .01 )
        || ( ( $totalRevenue + .01 ) < 0 ) ) {
        return $self->handleError( "Sales revenue $salesRevenue does not match total revenue $totalRevenue for sale with no returns",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

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

    my $sales = $saleRec->sales;

    if ( !defined $sales ) {
        return $self->handleError( "Sales not defined for physical sale", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( $sales < 0 ) {
        return $self->handleError( "Sales $sales is invalid because it is negative", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( $sales !~ /^\d+$/ ) {
        return $self->handleError( "Sales $sales in non-positive integer format", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

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

    my $salesRevenue = $saleRec->salesRevenue;

    if ( !defined $salesRevenue ) {
        return $self->handleError( "Sales revenue not defined", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( $salesRevenue < 0 ) {
        return $self->handleError( "Sales revenue $salesRevenue is invalid because it is negative",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( $salesRevenue !~ /^(?:(?:\d+)?\.\d+|\d+(?:\.)?(?:\d+)?)$/ ) {
        return $self->handleError( "Sales revenue $salesRevenue is in an incorrect format",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

#------------------------------------------------------------
# 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 _validateTotalRevenue {
    my ( $self, $saleRec ) = @_;
    my $totalRevenue = $saleRec->totalRevenue;

    if ( !defined $totalRevenue ) {
        return $self->handleError( "Total revenue not defined for physical sale", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( $totalRevenue !~ /^\-?(?:(?:\d+)?\.\d+|\d+(?:\.)?(?:\d+)?)$/ ) {
        return $self->handleError( "Total revenue $totalRevenue is in an incorrect format",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

#------------------------------------------------------------
# Gross 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 _validateGrossRevenue {
    my ( $self, $saleRec ) = @_;
    my $grossRevenue = $saleRec->grossRevenue;

    if ( $grossRevenue && $grossRevenue !~ /^\-?(?:(?:\d+)?\.\d+|\d+(?:\.)?(?:\d+)?)$/ ) {
        return $self->handleError( "Gross revenue $grossRevenue is in an incorrect format",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

sub _validateReturns {
    my ( $self, $saleRec ) = @_;
    my $returns = $saleRec->returns;

    if ( $returns < 0 ) {
        return $self->handleError( "Returns $returns is invalid because it is negative",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( $returns !~ /^\d+$/ ) {
        return $self->handleError( "Returns $returns in non-positive integer format", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

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

    my $returnsRevenue = $saleRec->returnsRevenue;

    if ( !defined $returnsRevenue ) {
        return $self->handleError( "Returns revenue has not been set for physical sale with returns",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( $returnsRevenue < 0 ) {
        return $self->handleError( "Returns revenue $returnsRevenue is invalid because it is negative",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( $returnsRevenue !~ /^(?:(?:\d+)?\.\d+|\d+(?:\.)?(?:\d+)?)$/ ) {
        return $self->handleError( "Returns revenue $returnsRevenue is in an incorrect format",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

sub _validatePriceLevel {
    my ( $self, $saleRec ) = @_;
    my $priceLevel = $saleRec->priceLevel;

    my %priceLevels = (

        # Unknown _is_ a valid price level - It means we'll use the product's default.
        #
        RPS::File::Sale::PLEVEL_UNKNOWN => 1,
        RPS::File::Sale::PLEVEL_FULL    => 1,
        RPS::File::Sale::PLEVEL_MID     => 1,
        RPS::File::Sale::PLEVEL_BUDGET  => 1,
        RPS::File::Sale::PLEVEL_PROMO   => 1,
    );

    if ( !defined $priceLevel ) {
        return $self->handleError( "Price level for physical sale has not been set", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( !$priceLevels{$priceLevel} ) {
        return $self->handleError( "Unknown price level $priceLevel", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

sub _validateChannel {
    my ( $self, $saleRec ) = @_;
    my $channel = $saleRec->channel;

    my %channels = (
        RPS::File::Sale::CHANNEL_RETAIL    => 1,
        RPS::File::Sale::CHANNEL_MILITARY  => 1,
        RPS::File::Sale::CHANNEL_CLUB      => 1,
        RPS::File::Sale::CHANNEL_MAILORDER => 1,
        RPS::File::Sale::CHANNEL_DIRECT    => 1,
    );

    if ( !defined $channel ) {
        return $self->handleError( "Channel for physical sale has not been set", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( !$channels{$channel} ) {
        return $self->handleError( "Unknown channel $channel", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

sub _validatePrice {
    my ( $self, $saleRec ) = @_;
    my $price = $saleRec->price;

    if ( !defined $price ) {

        #die "Price not defined for digital sale";
        # !!! Why would we not want to die?
        # !!! When is not having a price valid?
        #
        return;
    }

    if ( $price < 0 ) {
        return $self->handleError( "Price $price is invalid because it is negative", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    # 0 | 0.0 | .00 |0.
    if ( $price !~ /^(?:(?:\d+)?\.\d+|\d+(?:\.)?(?:\d+)?)$/ ) {
        return $self->handleError( "Price $price is in an incorrect format", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

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

    if ( !defined $units ) {
        return $self->handleError( "Units not set for digital sale", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( $units !~ /^\-?\d+$/ ) {
        return $self->handleError( "Units $units in non-integer format", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

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

    my $formatType = $saleRec->formatType;

    # This should be easy - There should not BE a formatType declared.
    #
    if ($formatType) {
        my $productType = $saleRec->productType;
        return $self->handleError( "FormatType $formatType specified for a physical sale (with product type $productType)",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

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

    my $formatType = $saleRec->formatType;

    # Note: the following format types have been removed (FB1324)
    #
    #  FORMAT_MECHANICAL
    #  FORMAT_VIDEO
    #  FORMAT_VIDEOSTREAM
    #  FORMAT_RADIO
    #  FORMAT_TELEVISION
    #  FORMAT_MASTERTONE
    #  FORMAT_ANIMATEDRINGTONE
    #  FORMAT_VOICERINGER
    #  FORMAT_VIDEORINGER
    #  FORMAT_MIDI
    #  FORMAT_GRAPHIC
    #  FORMAT_SMSTONE
    #
    my %formatTypes = (
        RPS::File::Sale::FORMAT_DOWNLOAD        => 1,
        RPS::File::Sale::FORMAT_DOWNLOADPREMIUM => 1,
        RPS::File::Sale::FORMAT_DOWNLOADUPGRADE => 1,
        RPS::File::Sale::FORMAT_STREAM          => 1,
        RPS::File::Sale::FORMAT_TETHERED        => 1,
        RPS::File::Sale::FORMAT_RINGTONE        => 1,
        RPS::File::Sale::FORMAT_JUKEBOX         => 1,
        RPS::File::Sale::FORMAT_DUALDOWNLOAD    => 1,
        RPS::File::Sale::FORMAT_BACKGROUNDMUSIC => 1,
        RPS::File::Sale::FORMAT_VPD             => 1,

        # wireless format types
        RPS::File::Sale::FORMAT_RINGBACK => 1,

        # other
        RPS::File::Sale::FORMAT_PERFORMANCE => 1,

        RPS::File::Sale::FORMAT_EPHEMERAL            => 1,
        RPS::File::Sale::FORMAT_EPHEMERAL_WEBCAST    => 1,
        RPS::File::Sale::FORMAT_EPHEMERAL_SDARS      => 1,
        RPS::File::Sale::FORMAT_EPHEMERAL_BES        => 1,
        RPS::File::Sale::FORMAT_EPHEMERAL_CABLERAD   => 1,
        RPS::File::Sale::FORMAT_EPHEMERAL_PES        => 1,
        RPS::File::Sale::FORMAT_EPHEMERAL_ATU        => 1,
        RPS::File::Sale::FORMAT_EPHEMERAL_IP         => 1,
        RPS::File::Sale::FORMAT_EPHEMERAL_SETTLEMENT => 1,

        RPS::File::Sale::FORMAT_NON_EPHEMERAL            => 1,
        RPS::File::Sale::FORMAT_NON_EPHEMERAL_WEBCAST    => 1,
        RPS::File::Sale::FORMAT_NON_EPHEMERAL_SDARS      => 1,
        RPS::File::Sale::FORMAT_NON_EPHEMERAL_BES        => 1,
        RPS::File::Sale::FORMAT_NON_EPHEMERAL_CABLERAD   => 1,
        RPS::File::Sale::FORMAT_NON_EPHEMERAL_PES        => 1,
        RPS::File::Sale::FORMAT_NON_EPHEMERAL_ATU        => 1,
        RPS::File::Sale::FORMAT_NON_EPHEMERAL_IP         => 1,
        RPS::File::Sale::FORMAT_NON_EPHEMERAL_SETTLEMENT => 1,
        RPS::File::Sale::FORMAT_SYNC => 1,
    );

    if ( !defined $formatType ) {
        $formatType = $self->handleError( "No format type set for digital sale", RPS::DB::Item::SaleImportError::kErrorFormatType );
        $saleRec->formatType($formatType);
        return;
    }

    if ( !$formatTypes{$formatType} ) {
        $formatType =
          $self->handleError( "Invalid or unsupported format type: $formatType", RPS::DB::Item::SaleImportError::kErrorFormatType );
        $saleRec->formatType($formatType);
        return;
    }

    # !!! It would be a good idea to make sure the format type matches up with the product type in a
    # sane fashion...
    #
}

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

    my $mediaType = $saleRec->mediaType;

    my %validMediaTypes = (
        RPS::DB::Item::MediaType::kMediaTypeAudio => "audio",
        RPS::DB::Item::MediaType::kMediaTypeVideo => "video",
    );

    if ( !$mediaType ) {
        $mediaType = $self->handleError( "Media type not set for sale", RPS::DB::Item::SaleImportError::kErrorMediaType );
        $saleRec->mediaType($mediaType);
        return;
    }

    if ( !$validMediaTypes{$mediaType} ) {
        $mediaType = $self->handleError( "Invalid media type: $mediaType", RPS::DB::Item::SaleImportError::kErrorMediaType );
        $saleRec->mediaType($mediaType);
        return;
    }

}

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;

    #number of days in the month of $dateEnd in the year of $dateEnd
    my $daysInMonth;

    #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;

    #Die if $dateBegin or $dateEnd is missing
    if ( !$dateBegin ) {
        return $self->handleError( "No date begin", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( !$dateEnd ) {
        return $self->handleError( "No date end", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    #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 {
        return $self->handleError(
            "'Date begin' in invalid format $dateBegin, need: yyyy-mm-dd",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying,
        );
    }

    if ( $dateEnd =~ /^(\d{4})\-(\d{2})\-(\d{2})$/ ) {
        $endYear  = $1;
        $endMonth = $2;
        $endDay   = $3;
    } else {
        return $self->handleError(
            "'Date end' in invalid format $dateEnd, need: yyyy-mm-dd",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying,
        );
    }

    #Check $dateBegin date ranges
    if ( ( $beginYear < 1978 ) || ( $beginYear > $currentYear ) ) {
        return $self->handleError( "Date begin year $beginYear out of range", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( ( $beginMonth == 0 ) || ( $beginMonth > 12 ) ) {
        return $self->handleError( "Date begin month $beginMonth out of range", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( $beginDay != 1 ) {
        return $self->handleError( "Date begin day $beginDay not equal to 1", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    #check $dateEnd date ranges
    if ( ( $endYear < 1978 ) || ( $endYear > ( $currentYear + 1 ) ) ) {
        return $self->handleError( "Date end year $endYear out of range", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( ( $endMonth == 0 ) || ( $endMonth > 12 ) ) {
        return $self->handleError( "Date end month $endMonth out of range", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    $daysInMonth = Days_in_Month( $endYear, $endMonth );

    if ( $endDay != $daysInMonth ) {
        return $self->handleError( "Date end day $endDay is not last day in end month $endMonth in end year $endYear",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    #Make sure $dateBegin is earlier than $dateEnd
    $dateBeginUnix = timegm( 0, 0, 0, $beginDay, $beginMonth - 1, $beginYear );

    $dateEndUnix = timegm( 0, 0, 0, $endDay, $endMonth - 1, $endYear );

    if ( $dateBeginUnix >= $dateEndUnix ) {
        return $self->handleError( "Date begin $dateBegin is equal to or later than date end $dateEnd",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

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

    my $free = $saleRec->free;

    # Free isn't always defined.
    #
    return unless defined $free;

    if ( $free == 0 || $free == 1 ) {
        return;
    } else {
        return $self->handleError( "Free must be 0 or 1 ('$free' is not valid)", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }
}

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

    my $currencyCode = $saleRec->currencyCode;

    if ( !defined $currencyCode ) {
        return $self->handleError( "Currency code not set", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    if ( !Common::CurrencyFormat::CodeIsValid($currencyCode) ) {
        return $self->handleError( "Invalid or unsupported currency code: $currencyCode",
            RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

}

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

    if ( !$countryCode || $countryCode eq '' ) {
        return $self->handleError( "Missing country code", RPS::DB::Item::SaleImportError::kErrorNonqualifying );
    }

    # If we find a valid 2 or 3 character country code, save it back into the salerec.
    # This takes care of any lowercase country codes that verify OK but cause run-related
    # issues later on (see FB16152).
    #
    my $country = Common::Country->GetByAlpha2($countryCode);
    if ( !$country ) {
        $country = Common::Country->GetByAlpha3($countryCode);
    }
    $saleRec->countryCode( $country->alpha2 ) if ($country);

    unless ($country) {
        $countryCode =
          $self->handleError( "Invalid or unsupported country code: $countryCode", RPS::DB::Item::SaleImportError::kErrorCountry );

        # We should get back either nothing (for non-mapface importers and mapface importers without a mapping)
        # or a country code that this test is mapped to.

        $saleRec->countryCode($countryCode);

        return;
    }
}

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

    my $upc = $saleRec->upc;

    # Not all sales will have a UPC...
    #
    if ($upc) {

        # ... but if they do, it should not be gibberish.
        #
        # The Validate::Util::is_valid_upc_or_ean method is TOO strict.
        # We're going to get weird UPCs - All we really want is to insure
        # that these _look_ like UPCs.
        # Meaning:
        # - They are 12 or 13 characters in length.
        # - They only contain digits.
        # - ... and maybe that the last 4 or 5 digits are not all 0s (which
        #       is usually a signal that they were truncated and re-expanded).
        #
        # JPK - Well, unfortunately, we need to accept extremely poor 'UPC' values.
        # At this point, I'm just going to complain if they give us any non-digits.
        #
        #        if (! Validate::Util::is_valid_upc_or_ean($upc))

        #        if (((length($upc) != 12 ) && (length($upc) != 13))
        #         || ($upc =~ /\D/)
        #         || (substr($upc, -5) eq '00000'))
        #        if ($upc =~ /\D/)
        #        {
        #            die Import::ValidationError->new($saleRec, "Invalid UPC '$upc'");
        #        }
    }
}

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

    my $serviceID     = $saleRec->serviceID;
    my $fileServiceID = $self->{_file}->ServiceID;

    if ($serviceID) {

        if ( $serviceID == Client::Service::DSP_SOUNDEXCHANGE ) {

            # If these are direct SoundExchange sales, we want to set the flag in the database.
            if ( $fileServiceID == Client::Service::DSP_SOUNDEXCHANGE && !$self->{_file}{_SoundExchangeChecked} ) {

                # We'll need to store the current count so that we'll know later if it's changed.
                my $serviceCategories    = Common::DB::Item::ServiceCategory->GetAll();
                my $currentCategoryCount = $serviceCategories->size();

                # Set a flag so that we know that this client has received
                # SoundExchange sales (this information will be used to
                # show/suppress the service category option picker on the
                # rps/services page).
                #
                my $options = RPS::DB::Item::ClientOptions->Lookup( name => 'has_soundexchange' );
                my $count;
                if ($options) {

                    # If they have already processed a SoundExchange file, let's see
                    # if the service_category table still has the same number of options.
                    # If not, let's notify them about it.
                    my $storedCategoryCount;
                    $count = RPS::DB::Item::ClientOptions->Lookup( name => 'service_category_count' );
                    if ($count) {
                        $storedCategoryCount = $count->value;
                    } else {
                        $count = RPS::DB::Item::ClientOptions->Create( name => 'service_category_count' );
                        $storedCategoryCount = 0;
                    }

                    if ( $currentCategoryCount > $storedCategoryCount ) {
                        $self->_sendSoundExchangeEmail( $self->{_file}, 1 );
                    }
                } else {
                    $options = RPS::DB::Item::ClientOptions->Create( name => 'has_soundexchange' );

                    # If this is the first time a client has uploaded a SoundExchange file,
                    # we need to send them an email.
                    $self->_sendSoundExchangeEmail( $self->{_file} );

                    # We'll also store the service category count so that we can alert the user if it changes.
                    $count = RPS::DB::Item::ClientOptions->Create( name => 'service_category_count' );
                }

                $options->value('1');
                $options->save();

                $count->value($currentCategoryCount);
                $count->save();

                # Setting a flag so that we only do this check once per file, rather than once per sale.
                $self->{_file}{_SoundExchangeChecked} = 1;
            }

            # Make sure we're only processing valid categories of service
            #
            my $formatType = $saleRec->formatType;

            if (   $formatType ne RPS::File::Sale::FORMAT_PERFORMANCE
                && $formatType ne RPS::File::Sale::FORMAT_EPHEMERAL
                && $formatType ne RPS::File::Sale::FORMAT_EPHEMERAL_WEBCAST
                && $formatType ne RPS::File::Sale::FORMAT_EPHEMERAL_SDARS
                && $formatType ne RPS::File::Sale::FORMAT_EPHEMERAL_BES
                && $formatType ne RPS::File::Sale::FORMAT_EPHEMERAL_CABLERAD
                && $formatType ne RPS::File::Sale::FORMAT_EPHEMERAL_PES
                && $formatType ne RPS::File::Sale::FORMAT_EPHEMERAL_ATU
                && $formatType ne RPS::File::Sale::FORMAT_EPHEMERAL_IP
                && $formatType ne RPS::File::Sale::FORMAT_EPHEMERAL_SETTLEMENT
                && $formatType ne RPS::File::Sale::FORMAT_NON_EPHEMERAL
                && $formatType ne RPS::File::Sale::FORMAT_NON_EPHEMERAL_WEBCAST
                && $formatType ne RPS::File::Sale::FORMAT_NON_EPHEMERAL_SDARS
                && $formatType ne RPS::File::Sale::FORMAT_NON_EPHEMERAL_BES
                && $formatType ne RPS::File::Sale::FORMAT_NON_EPHEMERAL_CABLERAD
                && $formatType ne RPS::File::Sale::FORMAT_NON_EPHEMERAL_PES
                && $formatType ne RPS::File::Sale::FORMAT_NON_EPHEMERAL_ATU
                && $formatType ne RPS::File::Sale::FORMAT_NON_EPHEMERAL_IP
                && $formatType ne RPS::File::Sale::FORMAT_NON_EPHEMERAL_SETTLEMENT ) {
                return $self->handleError( "SoundExchange sales must have a format type of Performance Income, Ephemeral or Non-Ephemeral",
                    RPS::DB::Item::SaleImportError::kErrorNonqualifying );
            }
        }
    }
}

sub unitPrice {
    my $self      = shift;
    my $netPrice  = $self->_formatPrice( $self->price() ) || 0;
    my $unitPrice = ( $self->units() ) ? $netPrice / $self->units() : $netPrice;

    # Negative zero?  Really?
    # I am disappoint.
    if ( $unitPrice eq "-0" ) {
        $unitPrice = 0;
    }

    return $unitPrice;
}

sub _saveLine {
    my $self = shift;

    my ( $startDate, $endDate ) = $self->saleDates();

    my $unitPrice = $self->unitPrice;

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

    $sale->price($unitPrice);

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

    $sale->mediaType( $self->mediaType );
    $sale->productType( $self->productType );
    $sale->formatType( $self->formatType );
    $sale->albumName( $self->albumName );
    $sale->artistName( $self->artistName );
    $sale->averagePrice( $self->averagePrice );
    $sale->channel( $self->channel );
    $sale->clientProductID( $self->clientProductID );
    $sale->configuration( $self->configuration );
    $sale->conversionRate( $self->conversionRate );
    $sale->countryCode( $self->countryCode );
    $sale->currencyCode( $self->currencyCode );
    $sale->free( $self->free );
    $sale->importStatus( $self->importStatus );
    $sale->isrc( $self->isrc );
    $sale->labelName( $self->labelName );
    $sale->outlet( $self->outlet );
    $sale->payoutType( $self->payoutType );
    $sale->wholesalePrice( $self->wholesalePrice );
    $sale->retailPrice( $self->retailPrice );
    $sale->priceLevel( $self->priceLevel );
    $sale->priceType( $self->priceType );
    $sale->retail( $self->retail );
    $sale->returns( $self->returns );
    $sale->returnsRevenue( $self->returnsRevenue );
    $sale->sales( $self->sales );
    $sale->salesRevenue( $self->salesRevenue );
    $sale->serviceID( $self->serviceID );
    $sale->serviceProductID( $self->serviceProductID );
    $sale->totalRevenue( $self->totalRevenue );
    $sale->trackName( $self->trackName );
    $sale->trackNum( $self->trackNum );
    $sale->units( $self->units );
    $sale->upc( $self->upc );
    $sale->wholesaleRate( $self->wholesaleRate );
    $sale->mcps_adjustment( $self->mcps_adjustment );
    $sale->comments( $self->comments );
    $sale->grossRevenue( $self->grossRevenue );

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

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

sub _isValidSaleRecord {
    my $self = shift;

    return ( defined( $self->units ) && ( $self->trackName || $self->albumName ) );
}

sub handleError {
    my ( $self, $description, $type ) = @_;
    $self->fail($description);
}

sub _sendSoundExchangeEmail {
    my ( $self, $file, $optionsChanged ) = @_;

    my $user = AppUser::User::User->new( userID => $file->UserID );
    my $client = Common::Client::Current();

    if ( $user && $user->Email && $client->isClientType(Common::Client::kArtist) ) {

        my $to      = $user->Email;
        my $from    = 'do-not-reply@royaltyshare.com';
        my $subject = "SoundExchange Admin Options Available";
        my $url     = "https://" . Common::RSApp::GetClientVHost() . ".royaltyshare.com/rps/service";
        my $body    = "Hello -

You've recently uploaded a SoundExchange sales file, and we wanted to make you aware of administrative options related to this service in the RoyaltyShare application.

You can exclude SoundExchange Categories of Sale from artist royalties on the Admin tab.  To manage these options, go to $url.

";

        if ($optionsChanged) {
            $body .= "Options have recently changed, so please review when you have a chance.

";
        }

        $body .= "Thanks,

The RoyaltyShare Support Team";

        Common::Email->SendAWS(
            to      => $to,
            from    => $from,
            subject => $subject,
            body    => $body
        );
    }
}

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