package BookPub::Sale::Match;

use strict;

#use warnings;

use Data::Dumper;
use Encode qw(encode_utf8);

use lib '/app/tools/sale_import/lib';
use base 'Sale::Match';

use lib '/app/tools/common/lib';
use Common::Util qw(clean word_containment);
use Common::RSDB;
use Common::Log;
use Common::Assert;
use Common::DB::Item::OnixCode;

use lib '/app/tools/bookpub/lib/';
use BookPub::DB::Item::BookContributor;
use BookPub::DB::Item::Product;
use BookPub::DB::Item::BookProduct;
use BookPub::DB::Item::Sale;
use BookPub::DB::Item::File;
use BookPub::DB::Item::BookMatch;
use BookPub::DB::Item::BookFormat;
use BookPub::Sale::Match::Result;
use BookPub::DB::Item::ProductInputMap;
use BookPub::Import::Importer;

use constant NP      => 0;
use constant NOMATCH => 1;
use constant ISMATCH => 2;

use constant SUGGEST => 'suggest';
use constant MATCH   => 'match';

use constant LIMIT_SEARCH => 100;

sub errstr {
    my $self = shift;
    return $self->{errstr};
}

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

    $self->{__cache}->{book_product_isbn} = $args{book_product_isbn};
    $self->SUPER::_init(@_);
}

sub SearchCatalog {
    my ( $self, %in ) = @_;

    my $search_type = lc $in{type};
    my $keyword     = $in{keyword};
    my $sale        = $in{sale};

    my %search_types_allowed = (
        'smart'  => 1,
        'all'    => 1,
        'author' => 1,
        'title'  => 1,
        'isbn10' => 1,
        'isbn13' => 1,
    );

    unless ( defined $search_type && $search_types_allowed{$search_type} ) {
        $self->{errstr} = "You must specify a valid type parameter: " . join( ', ', keys %search_types_allowed );
        return undef;
    }

    unless ( defined $keyword && length( Common::Util::clean($keyword) ) >= 3 ) {
        $self->{errstr} = "You must specify a keyword value with at least 3 characters";
        return undef;
    }

    my $keyword_clean = Common::Util::clean($keyword);
    my $smart_key_found;
    if ( $search_type eq 'smart' ) {
        unless ( defined $sale ) {
            $self->{errstr} = "Missing 'sale' parameter";
            return undef;
        }

        my $keyword_match = $keyword;
        $keyword_match =~ s/([^\w\s])/\\$1/g;

        foreach my $key ( 'author', 'title', 'isbn10', 'isbn13' ) {
            my $field = 'r_' . $key;
            my $val   = $sale->$field;
            next if ( !defined $val or length $val < 3 );

            if ( $val =~ /$keyword_match/i ) {
                $smart_key_found = $key;
                last;
            }

            my $data_field_clean = Common::Util::clean($val);
            if (   length $keyword_clean >= 3
                && length $data_field_clean >= 3
                && $data_field_clean =~ /$keyword_clean/ ) {
                $smart_key_found = $key;
                last;
            }
        }

        # default to title search if smart key wasn't found
        $search_type = $smart_key_found || 'title';
    }

    #Common::Log::Print("SEARCH TYPE: $search_type");

    my @search_products;
    if ( $search_type eq 'all' ) {
        my $titleMatches  = $self->_searchTitle($keyword);
        my $authorMatches = $self->_searchAuthor($keyword);
        my $isbn10Matches = $self->_searchISBN10($keyword);
        my $isbn13Matches = $self->_searchISBN13($keyword);

        push( @search_products, @$titleMatches )  if $titleMatches;
        push( @search_products, @$authorMatches ) if $authorMatches;
        push( @search_products, @$isbn10Matches ) if $isbn10Matches;
        push( @search_products, @$isbn13Matches ) if $isbn13Matches;

    } elsif ( $search_type eq 'title' ) {
        my $titleMatches = $self->_searchTitle($keyword);
        push( @search_products, @$titleMatches ) if $titleMatches;
    } elsif ( $search_type eq 'author' ) {
        my $authorMatches = $self->_searchAuthor($keyword);
        push( @search_products, @$authorMatches ) if $authorMatches;
    } elsif ( $search_type eq 'isbn10' ) {
        my $isbn10Matches = $self->_searchISBN10($keyword);
        push( @search_products, @$isbn10Matches ) if $isbn10Matches;
    } elsif ( $search_type eq 'isbn13' ) {
        my $isbn13Matches = $self->_searchISBN13($keyword);
        push( @search_products, @$isbn13Matches ) if $isbn13Matches;
    } else {
        die "I don't know how to search $search_type";
    }

    my $detail = {};
    foreach my $productID (@search_products) {
        $detail->{$productID} = {
            level => 60,
            type  => $search_type . '_search',
        };
    }

    return BookPub::Sale::Match::Result->new(
        products        => [],
        num_products    => 0,
        import_status   => undef,
        map_id          => undef,
        rec_products    => [],
        search_products => \@search_products,
        detail          => $detail,
    );
}

sub FindBestMatch {
    my ( $self, %in ) = @_;

    my $skip_rec   = ( exists $in{skip_rec} && $in{skip_rec} ) ? 1 : 0;
    my $map_only   = $in{map_only};
    my $saleDBItem = $in{data};

    #    Common::Log::Print("FindBestMatch: data (sale) = " . Dumper($saleDBItem));

    # !!! Going to skip the translation step for now.
    # !!! I think we're going to approach this differently.
    #
    ## Well, what we need to do is translate from format_type to book_format_id.
    #
    #    my $product_type_id = _translate_product_type($in{data}{product_type});

    # check to see if we have a previous map entry for this input data
    # if we do, then we can short-circuit the normal search
    #
    my $result = $self->_search_product_input_map($saleDBItem);
    return $result if defined $result;

    return undef if $map_only;

    my $matches     = {};
    my $productType = $saleDBItem->product_type;

    # !!! We do a lot of this test... We may want to break this out into book/chapter match classes
    #
    if ( BookPub::DB::Item::Sale->ProductTypeIsBook($productType) ) {
        my %saleHash;
        $saleHash{r_isbn13}     = $saleDBItem->r_isbn13;
        $saleHash{r_isbn10}     = $saleDBItem->r_isbn10;
        $saleHash{r_title}      = $saleDBItem->r_title;
        $saleHash{r_subtitle}   = $saleDBItem->r_subtitle;
        $saleHash{r_author}     = $saleDBItem->r_author;
        $saleHash{product_type} = $saleDBItem->product_type;

        $matches = $self->get_book_matches( \%saleHash );
    } elsif ( BookPub::DB::Item::Sale->ProductTypeIsChapter($productType) ) {

        #        $matches = $self->_get_chapter_matches($saleDBItem);
    } elsif ( BookPub::DB::Item::Sale->ProductTypeIsEJournal($productType) ) {
        my %saleHash;
        $saleHash{r_isbn13}     = $saleDBItem->r_isbn13;
        $saleHash{r_isbn10}     = $saleDBItem->r_isbn10;
        $saleHash{r_title}      = $saleDBItem->r_title;
        $saleHash{r_subtitle}   = $saleDBItem->r_subtitle;
        $saleHash{r_author}     = $saleDBItem->r_author;
        $saleHash{product_type} = $saleDBItem->product_type;

        $matches = $self->get_book_matches( \%saleHash );

    } else {
        die "ERROR - unable to match product_type '$productType'";
    }

    #Common::Log::Print("MATCHES: " . Dumper($matches));

    if ( $skip_rec == 1 )    # return only matches (if any)
    {
        # remove any suggestions
        while ( my ( $id, $match ) = each %$matches ) {
            delete $matches->{$id} if ( $match->{status} eq SUGGEST );
        }

        my $num_products = scalar keys %$matches;
        Common::Log::Debug("matched product count: $num_products");

        if ( $num_products > 1 ) {
            Common::Log::Debug("sort by weight");
            my $max_weight = 0;
            foreach my $id ( $self->_sort_by_weight($matches) ) {
                if ( $matches->{$id}{weight} < $max_weight ) {
                    delete $matches->{$id};
                    next;
                }
                $max_weight = $matches->{$id}{weight};
            }

            $num_products = scalar keys %$matches;
            unless ( $num_products == 1 ) {
                Common::Log::Debug("filter by author name");
                $self->_filter_by_author_name( $saleDBItem->r_author, $matches ) if $saleDBItem->r_author;
                $num_products = scalar keys %$matches;

                unless ( $num_products == 1 ) {

                    # !!! why?
                    Common::Log::Debug("filter by release date");
                    $self->_filter_by_release_date($matches);
                    $num_products = scalar keys %$matches;
                }
            }
        }

        my $import_status =
            $num_products == 1 ? BookPub::DB::Item::Sale::STATUS_MATCH
          : $num_products == 0 ? BookPub::DB::Item::Sale::STATUS_NOMATCH
          : $num_products > 1  ? BookPub::DB::Item::Sale::STATUS_MULTIMATCH
          :                      '';

        $result = BookPub::Sale::Match::Result->new(
            products        => [ keys %$matches ],
            num_products    => $num_products,
            import_status   => $import_status,
            map_id          => undef,
            rec_products    => [],
            search_products => undef,
            detail          => $matches,
        );
    } else    # return matches or suggestions
    {
        my %suggestions = ();
        while ( my ( $id, $match ) = each %$matches ) {
            if ( $match->{status} eq SUGGEST ) {
                $suggestions{$id} = $match;
                delete $matches->{$id};
            }
        }
        my @recommended = ();
        my @matched = keys %$matches ? $self->_sort_by_weight($matches) : ();
        unless ( scalar @matched > 0 ) {
            @recommended =
              keys %suggestions
              ? $self->_sort_suggestions( $saleDBItem, \%suggestions )
              : $self->_best_recommended_search( $saleDBItem, $productType );
        }

        my $import_status = scalar @matched == 1 ? BookPub::DB::Item::Sale::STATUS_MATCH : '';

        $result = BookPub::Sale::Match::Result->new(
            products        => \@matched,
            import_status   => $import_status,
            num_products    => scalar @matched,
            map_id          => undef,
            rec_products    => \@recommended,
            search_products => undef,
            detail          => {},
        );
    }

    #Common::Log::Print("RESULT: " . Dumper($result));

    return $result if ( defined $result );

    $self->{errstr} = "Could not find/return results!";
    return undef;
}

sub RemoveMatch {
    my $self = shift;
    my %in   = @_;

    my $map_id = ( exists $in{map_id} ) ? $in{map_id} : undef;

    unless ( defined $map_id ) {
        $self->{errstr} = "You must specify a map_id param";
        return undef;
    }

    my $sql = "DELETE FROM product_input_map WHERE map_id = ?";

    my $sth = $self->dbh()->prepare($sql);
    unless ($sth) {
        $self->errstr = $self->dbh()->errstr || "Can't prepare SQL";
        return undef;
    }

    unless ( $sth->execute($map_id) ) {
        $self->{errstr} = $sth->errstr;
        return undef;
    }

    return 1;
}

sub MakeMatch {
    my $self = shift;
    my %in   = @_;

    # 	my $match_data = {
    #		product_type => $self->ProductType,
    #        format => $self->FormatType,
    #		isbn13 => $self->ISBN13,
    #		isbn10 => $self->ISBN10,
    #		author => $self->Author,
    #		title  => $self->Title,
    #		subtitle  => $self->Subtitle,
    #        service_id => $serviceID,
    #	};

    my $saleDBItem = $in{sale};
    assert($saleDBItem);
    my $productID = $in{productID};
    assert($productID);

    my $allow_reuse = 0;

    # As long as we have at least one identifier, the match can be re-used.
    #
    #
    if (   $saleDBItem->r_isbn13
        || $saleDBItem->r_isbn10
        || $saleDBItem->r_title
        || $saleDBItem->service_product_id ) {
        $allow_reuse = 1;
    }

    # If our input data does NOT meet a certain threshold,
    # do NOT allow our map entry to be re-used automatically in the future
    # (use it ONLY for this one-time manual product match)
    #
    my $productType = $saleDBItem->product_type;

    #if ( BookPub::DB::Item::Sale->ProductTypeIsBook($productType) ) {
    #    $allow_reuse = $self->_check_match_for_reuse($saleDBItem);
    #} elsif ( BookPub::DB::Item::Sale->ProductTypeIsChapter($productType) ) {
    #
    #    # !!! No clue yet !!!
    #}

    my $match_md5 = $self->_match_md5( sale => $saleDBItem ) || return undef;

    # We are going to start storing format type here, but it will only be used
    # when multiple products have the same ISBN.
    my $newMapEntry = BookPub::DB::Item::ProductInputMap->Create(
        product_id         => $productID,
        product_type       => $productType,
        format_type        => $saleDBItem->format_type,
        match_md5          => $match_md5,
        isbn13             => $saleDBItem->r_isbn13,
        isbn10             => $saleDBItem->r_isbn10,
        author_name        => $saleDBItem->r_author,
        title              => $saleDBItem->r_title,
        subtitle           => $saleDBItem->r_subtitle,
        service_product_id => $saleDBItem->service_product_id,
        allow_reuse        => $allow_reuse,
        service_id         => $saleDBItem->service_id,
    );
    $newMapEntry->save();

    my $map_id = $newMapEntry->map_id;

    unless ($map_id) {
        $self->{errstr} = "Could not get the last inserted map_id";
        return undef;
    }

    return $map_id;
}

# private methods

sub _is_apple_exception {

    # Apple App sales frequently do not have author or isbn.
    # We want to treat them differently both when setting the reuse flag and when looking for matches,
    # so this function will be used in both places.
    # !!! Actually, let's do this for all Apple sales, regardless of format type.
    my ( $self, $saleDBItem ) = @_;
    if (   $saleDBItem->r_title
        && $saleDBItem->service_product_id
        && !$saleDBItem->r_author
        && !$saleDBItem->r_isbn10
        && !$saleDBItem->r_isbn13
        && $saleDBItem->service_id == BookPub::Tracker::Service::APPLE() ) {
        return 1;
    } else {
        return 0;
    }
}

sub _has_multiple_isbn_matches {

    # HUK uses the same ISBN for multiple products, so we need to check for that and handle them differently.
    my ( $self, $saleDBItem ) = @_;

    my $isbn10Matches = 0;
    if ( $saleDBItem->r_isbn10 ) {
        my $isbn10 = $saleDBItem->r_isbn10;
        if ( !exists $self->{__cache} ) {
            Common::Log::Debug("Warning: GetCountByISBN10 is not cached");
            $isbn10Matches = BookPub::DB::Item::BookProduct->GetCountByISBN10( isbn10 => $saleDBItem->r_isbn10 );
        } elsif ( exists $self->{__cache}->{book_product_isbn}->{isbn10}->{$isbn10} ) {
            $isbn10Matches = $self->{__cache}->{book_product_isbn}->{isbn10}->{$isbn10};
        }
    }

    my $isbn13Matches = 0;
    if ( $saleDBItem->r_isbn13 ) {
        my $isbn13 = $saleDBItem->r_isbn13;
        if ( !exists $self->{__cache} ) {
            Common::Log::Debug("Warning: GetCountByISBN13 is not cached");
            $isbn13Matches = BookPub::DB::Item::BookProduct->GetCountByISBN13( isbn13 => $saleDBItem->r_isbn13 );
        } elsif ( exists $self->{__cache}->{book_product_isbn}->{isbn13}->{$isbn13} ) {
            $isbn13Matches = $self->{__cache}->{book_product_isbn}->{isbn13}->{$isbn13};
        }
    }

    if ( $isbn10Matches > 1 || $isbn13Matches > 1 ) {
        return 1;
    } else {
        return 0;
    }
}

sub _check_match_for_reuse {
    my ( $self, $saleDBItem ) = @_;
    if ( $saleDBItem->r_title && ( $saleDBItem->r_author || $saleDBItem->r_isbn10 || $saleDBItem->r_isbn13 ) && $saleDBItem->service_id ) {
        return 1;
    }

    # For Apple sales only, we are going to allow matches
    # with a title and service_product_id to be reused
    elsif ( $self->_is_apple_exception($saleDBItem) == 1 ) {
        return 1;
    } else {
        return 0;
    }
}

sub _do_list_query {
    my $self = shift;

    Sale::Match::DEBUG && print STDERR 'list query: ' . join( ',', @_ ) . "\n";
    my $sth = $self->rsdb->DBH->prepare(shift);
    $sth->execute(@_);

    if ( $sth->rows ) {
        my @list = map { @{$_} } @{ $sth->fetchall_arrayref( [0] ) };
        return \@list;
    }
    return undef;
}

sub _search_product_input_map {
    my ( $self, $saleDBItem ) = @_;

    my $match_md5 = $self->_match_md5( sale => $saleDBItem );

    #    Common::Log::Print("_search_product_input_map: sale_id=" . $saleDBItem->sale_id . ", md5=$match_md5");
    return undef unless $match_md5;

    my ( $map_id, $product_id, $dont_match ) = $self->_lookup_product_map( md5 => $match_md5, saleDBItem => $saleDBItem );

    return undef unless ( $map_id && defined $product_id );

    my $import_status = $dont_match ? BookPub::DB::Item::Sale::STATUS_DONT_MATCH : $self->{map_type};

    return BookPub::Sale::Match::Result->new(
        products        => [$product_id],
        num_products    => 1,
        import_status   => $import_status,
        map_id          => $map_id,
        rec_products    => [],
        search_products => [],
        detail          => {
            $product_id => {
                level => 100,
                type  => 'map',
            },
        }
    );
}

sub _match_md5 {
    my ( $self, %in ) = @_;
    my $saleDBItem = $in{sale};

    #Common::Log::Print("_match_md5: sale=" . Dumper($saleDBItem));

    my $productType  = $saleDBItem->product_type;
    my @match_fields = ();

    if ( BookPub::DB::Item::Sale->ProductTypeIsBook($productType) ) {
        @match_fields = qw(product_type r_isbn13 r_isbn10 r_author r_title r_subtitle service_id);
    } elsif ( BookPub::DB::Item::Sale->ProductTypeIsChapter($productType) ) {
        @match_fields = qw(product_type r_isbn13 r_isbn10 r_author r_title r_subtitle service_id);
    } elsif ( BookPub::DB::Item::Sale->ProductTypeIsEJournal($productType) ) {
        @match_fields = qw(product_type r_isbn13 r_isbn10 r_author r_title r_subtitle service_id);
    } else {

        #        Common::Log::Print("unknown product type: '$productType'");
        return undef;
    }

    # We are going to use the file's service ID for this.
    # That's what's stored with the match in BookPub::Matcher::Sale,
    # so it only makes sense to use that on this end as well.
    my $file = BookPub::DB::Item::File->Lookup( file_id => $saleDBItem->file_id );
    my $serviceID = $file->service_id;

    # copy and adjust the values
    my %args = (
        product_type => uc $saleDBItem->product_type,
        r_isbn13     => uc $saleDBItem->r_isbn13,
        r_isbn10     => uc $saleDBItem->r_isbn10,
        r_author     => lc $saleDBItem->r_author,
        r_title      => lc $saleDBItem->r_title,
        r_subtitle   => lc $saleDBItem->r_subtitle,
        service_id   => lc $serviceID,

        #service_id   => lc $saleDBItem->service_id,
    );

    my $md5_string = '';
    map { $md5_string .= "$_=$args{$_};" } @match_fields;
    $md5_string =~ s/;$//;

    #    Common::Log::Print("md5 arg: $md5_string");

    # We're going to "encode" the input MD5 string as UTF8, but strictly for the purpose
    # of clearing the UTF8 flag for the field, which makes Digest::MD5::md5_base64() happy
    # (as it doesn't operate on wide characters, i.e. scalars with the UTF8 flag set).

    # We _don't_ care if the string even contains a valid (or correct) UTF8 sequences, but
    # since encode_utf8() doesn't do any data conversion other than clearing the flag, this
    # seems the most conservative thing to do. The alternative would have been to use
    # Encode::_utf8_off() for this purpose, but given that this is embroiled in warnings
    # regarding it's internal nature and being subject to change, the choice of encode_utf8()
    # seems prudent

    return Digest::MD5::md5_base64( encode_utf8($md5_string) );
}

sub _lookup_product_map {
    my ( $self, %in ) = @_;

    my $md5         = $in{md5};
    my $saleDBItem  = $in{saleDBItem};
    my $productType = $saleDBItem->product_type;
    my $item;

    #    Common::Log::Print("_lookup_product_map: md5=$md5, productType=$productType");

    # We will now check the service_product_id for all sales.
    # There are some cases where it's only the metadata we have,
    # and ignoring it here results in mappings being applied too broadly.
    #
    # I considered adding it to the md5, but I'm concerned that would break
    # compatiblity with all of the remembered matches we have.
    # Sending it as an extra param should be less disruptive.
    #
    if ( $self->_is_apple_exception($saleDBItem) == 1 ) {
        $item = BookPub::DB::Item::ProductInputMap->Lookup(
            match_md5          => $md5,
            product_type       => $productType,
            service_product_id => $saleDBItem->service_product_id,
            allow_reuse        => 1
        );
    } elsif ( $self->_has_multiple_isbn_matches($saleDBItem) == 1 ) {
        my $formatType = $saleDBItem->format_type;
        if ( !defined($formatType) ) {
            $formatType = '';
        }
        $item = BookPub::DB::Item::ProductInputMap->Lookup(
            match_md5          => $md5,
            product_type       => $productType,
            format_type        => $formatType,
            service_product_id => $saleDBItem->service_product_id,
            allow_reuse        => 1
        );
    } else {
        $item = BookPub::DB::Item::ProductInputMap->Lookup(
            match_md5          => $md5,
            product_type       => $productType,
            service_product_id => $saleDBItem->service_product_id,
            allow_reuse        => 1
        );
    }

    #    Common::Log::Print("_lookup_product_map Lookup returned: " . Dumper($item));
    return undef unless $item;

    return ( $item->map_id, $item->product_id, $item->dont_match );
}

sub _best_recommended_search {
    my $self        = shift;
    my $saleDBItem  = shift;
    my $productType = shift;

    my ( $sql, $match, $limit );
    my @params = ();

    my $collection;
    if ( BookPub::DB::Item::Sale->ProductTypeIsChapter($productType) ) {

        # Yeah... we'll get to this in a bit.
    } elsif ( defined $saleDBItem->r_title && '' ne $saleDBItem->r_title ) {
        $collection = BookPub::DB::Item::BookMatch->GetPartialMatchesByTitle( $saleDBItem->r_title );
    } else {
        return ();
    }

    return () unless $collection;

    my @productList;
    while ( my $matchItem = $collection->next() ) {
        push @productList, $matchItem->product_id;
    }

    return @productList;
}

# !!! I think I'll move this into a DB::Item class.
#
# !!! I will need to map format_type to book_format_id.
# !!! Or, we just store book_format_id in sale?
#
sub get_book_matches {
    my ( $self, $saleRef ) = @_;

    #    Common::Log::Print("get_book_matches: sale=" . Dumper($saleRef));

    # Get a collection of BookMatch records.
    #
    my $isbn13   = $saleRef->{r_isbn13};
    my $isbn10   = $saleRef->{r_isbn10};
    my $title    = $saleRef->{r_title};
    my $subtitle = $saleRef->{r_subtitle};
    my $author   = $saleRef->{r_author};

    # These are the titles from the sale record.
    # !!! So for the B&N import, these have been _truncated_.  Possibly.
    #
    my $titleClean    = $title    ? Common::Util::clean_book_title($title)    : undef;
    my $subtitleClean = $subtitle ? Common::Util::clean_book_title($subtitle) : undef;

    # We occasionally get instances where the metadata has the title and subtitle combined into 'title'.
    # We want to match those as well.
    #
    my $comboTitleClean = $titleClean;
    if ( $titleClean && $subtitleClean ) {
        $comboTitleClean .= '_' . $subtitleClean;
    }

    my $productTypes = BookPub::DB::Item::Sale->MapProductTypeToOnixCodes( $saleRef->{product_type} );

#    Common::Log::Print("++ GetMatches called with: productTypes: " . join(',', @$productTypes) .", title: $title, subtitle $subtitle, isbn10: $isbn10, isbn13: $isbn13");

    my $collection = $self->_getMatches(
        productTypes => $productTypes,
        isbn13       => $isbn13,
        isbn10       => $isbn10,
        title        => $title,
        subtitle     => $subtitle,
    );

    #    Common::Log::Print("++ GetMatches returned " . $collection ? $collection->size() : '0' . " results");
    return undef unless $collection && $collection->size > 0;

    my %matches = ();
    my $tt      = $self->_get_book_tt();

    while ( my $item = $collection->next() ) {

        # initialize all flags to NP
        my $isbn_flag     = NP;
        my $title_flag    = NP;
        my $subtitle_flag = NP;

        my $itemTitleClean      = $item->title_clean;
        my $itemComboTitleClean = $item->title_clean;
        if ( $item->subtitle_clean ) {
            $itemComboTitleClean .= '_' . $item->subtitle_clean;
        } else {

            # Another frequently seen case: The metadata has title and subtitle combined into 'title', with a ':' separator.
            # The issue is the capricious use of the word 'The ' in either the title or 'pseudo-subtitle'.
            # So I will want to use the same parsing logic the Importer's use to split this up and strip out that junk.
            # This should increase our hit rate.
            #
            my ( $newTitle, $newSubtitle ) = BookPub::Import::Importer::ParseTitleAndSubtitle( $item->title() );
            if ($newSubtitle) {
                $itemComboTitleClean = Common::Util::clean_book_title($newTitle) . '_' . Common::Util::clean_book_title($newSubtitle);
            }
        }

        #        Common::Log::Print("match: " . Dumper($item) . "\n");
        #        Common::Log::Print("itemComboTitleClean: " . $itemComboTitleClean);

        # JPK - Match on isbn13 preferentially...
        #
        if ( $isbn13 && $item->isbn13 ) {
            $isbn_flag = $item->isbn13 =~ /^$isbn13/ ? ISMATCH : NOMATCH;
        }
        if ( ISMATCH != $isbn_flag && $isbn10 && $item->isbn10 ) {
            $isbn_flag = $item->isbn10 =~ /^$isbn10/ ? ISMATCH : NOMATCH;
        }

        if ( length $title ) {
            $title_flag = $item->title eq $title ? ISMATCH : NOMATCH;

            #            Common::Log::Print("+++ item->title: ".$item->title." eq title: $title == $title_flag");

            if ( NOMATCH == $title_flag and defined $titleClean ) {

                # Some services are giving us titles and subtitles that have been cropped.
                #
                $title_flag = substr( $item->title_clean, 0, length $titleClean ) eq $titleClean ? ISMATCH : NOMATCH;

#                Common::Log::Print("+++ item->title_clean : ".substr($item->title_clean,0,length $titleClean) ." eq titleClean: $titleClean == $title_flag");

                # Some clients are giving us titles and subtitles that have been cropped.
                #
                if ( NOMATCH == $title_flag ) {
                    $title_flag = substr( $titleClean, 0, length $item->title_clean ) eq $item->title_clean ? ISMATCH : NOMATCH;
                }
            }

            if ( NOMATCH == $title_flag ) {

                # Perhaps the 'combo' title matches?
                #
                $title_flag = $comboTitleClean eq substr( $itemComboTitleClean, 0, length $comboTitleClean ) ? ISMATCH : NOMATCH;

#                Common::Log::Print("+++ itemComboTitleClean: ".substr($itemComboTitleClean,0,length $comboTitleClean) . " eq comboTitleClean: $comboTitleClean == $title_flag");

                if ( ISMATCH == $title_flag ) {

                    # If we match on the combo, we 'matched' the subtitle as well.
                    #
                    $subtitle_flag = ISMATCH;
                }
            }

            # If the ISBN matched, let's try flipping the title and subtitle.
            if ( NOMATCH == $title_flag && ISMATCH == $isbn_flag ) {
                if ( $item->title_clean eq $subtitleClean || $item->subtitle_clean eq $titleClean ) {
                    $title_flag = ISMATCH;
                }
            }
        }

        # If the ISBN and Title match, that's really enough.
        # Let's not worry about the subtitle, which seems to vary a lot anyway.
        if ( ISMATCH == $title_flag && ISMATCH == $isbn_flag ) {

            # Actually, we're fine doing nothing in this case.
        } else {

            # If title AND isbn don't match, let's see if the subtitle can help us out.
            if ( defined($subtitle) && length $subtitle && ISMATCH != $subtitle_flag ) {
                $subtitle_flag = $item->subtitle eq $subtitle ? ISMATCH : NOMATCH;

                #               Common::Log::Print("+++ item->subtitle: ". $item->subtitle . "  eq subtitle: $subtitle == $subtitle_flag");
                if ( NOMATCH == $subtitle_flag and defined $subtitleClean ) {
                    $subtitle_flag = substr( $item->subtitle_clean, 0, length $subtitleClean ) eq $subtitleClean ? ISMATCH : NOMATCH;

#                   Common::Log::Print("+++ item->subtitle_clean: ". substr($item->subtitle_clean,0,length $subtitleClean) . "  eq subtitleClean: $subtitleClean == $subtitle_flag");
                }
            }

            # !!! Let's just consider the subtitle as part of the title, as far as the truth table is concerned.
            #
            if ( NOMATCH == $subtitle_flag ) {
                $title_flag = NOMATCH;

                #               Common::Log::Print("+++  !!! subtitle NOMATCH, setting title NOMATCH");
            }

            # Last try to match on titles that are 'scrambled', but have all the same words.
            # Only do this if the ISBN is a match.
            #
            if ( NOMATCH == $title_flag && ISMATCH == $isbn_flag ) {

                # We'll use the combo-clean titles.
                # But the word containment method uses spaces, not underscores.
                #
                my $containSaleTitle = $comboTitleClean;
                my $containDBTitle   = $itemComboTitleClean;
                $containSaleTitle =~ s/_+/ /g;
                $containDBTitle =~ s/_+/ /g;

                # Let's also try this without the subtitle
                #
                my $containSaleTitleOnly = $titleClean;
                my $containDBTitleOnly   = $itemTitleClean;
                $containSaleTitleOnly =~ s/_+/ /g;
                $containDBTitleOnly =~ s/_+/ /g;

                #               Common::Log::Print("calling word_containment with: $containSaleTitle, $containDBTitle");
                if ( word_containment( $containSaleTitle, $containDBTitle ) ) {

                    #                   Common::Log::Print("   we'll call that a match");
                    $title_flag = ISMATCH;
                } elsif ( word_containment( $containSaleTitleOnly, $containDBTitleOnly ) ) {
                    $title_flag = ISMATCH;
                }
            }

#           Common::Log::Print("  isbn_flag: $isbn_flag,  title_flag: $title_flag, subtitle_flag: $subtitle_flag, combo sale $comboTitleClean, combo db $itemComboTitleClean");

        }

        # !!! avoid autovivification !!!
        next unless exists $tt->{$isbn_flag} && exists $tt->{$isbn_flag}{$title_flag};

        my $match_status = $tt->{$isbn_flag}{$title_flag}{status};

        #        Common::Log::Print("  initial match status: $match_status");
        # override status to SUGGEST if isbn or title are NP but author is and no match
        #
        $match_status = SUGGEST
          if ( $match_status eq MATCH
            && $item->author_name
            && ( $isbn_flag == NP || $title_flag == NP )
            && !word_containment( $author, $item->author_name ) );

        #        Common::Log::Print("  final match status: $match_status");

        # RSD-649
        # set the status to 'suggest' if the item contains the format 'Other' (ONIX)
        if ( $item->{onix_code_value} && $item->{onix_code_value} eq Common::DB::Item::OnixCode->kProductFormOther ) {
            $match_status = SUGGEST;
        }

        $matches{ $item->product_id } = {
            data   => $item,
            status => $match_status,
            weight => $tt->{$isbn_flag}{$title_flag}{weight},
        };
    }

    return \%matches;
}

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

    # !!! I abstracted this so we can subclass to call a different interface.
    #
    return BookPub::DB::Item::BookMatch->GetMatches(%args);
}

sub _searchTitle {
    my ( $self, $keyword ) = @_;

    my @productIDs;
    my $searchCollection = BookPub::DB::Item::BookMatch->GetPartialMatchesByTitle($keyword);

    if ($searchCollection) {
        while ( my $product = $searchCollection->next() ) {
            push @productIDs, $product->product_id;
        }
    }

    return \@productIDs;
}

# !!! Update this!
sub _searchAuthor {
    my ( $self, $keyword ) = @_;

    my @productIDs;
    my $searchCollection = BookPub::DB::Item::BookMatch->GetPartialMatchesByAuthor($keyword);

    if ($searchCollection) {
        while ( my $product = $searchCollection->next() ) {
            push @productIDs, $product->product_id;
        }
    }

    return \@productIDs;
}

sub _searchISBN10 {
    my ( $self, $keyword ) = @_;

    my @productIDs;
    my $searchCollection = BookPub::DB::Item::BookMatch->GetPartialMatchesByISBN10($keyword);

    if ($searchCollection) {
        while ( my $product = $searchCollection->next() ) {
            push @productIDs, $product->product_id;
        }
    }

    return \@productIDs;
}

sub _searchISBN13 {
    my ( $self, $keyword ) = @_;

    my @productIDs;
    my $searchCollection = BookPub::DB::Item::BookMatch->GetPartialMatchesByISBN13($keyword);

    if ($searchCollection) {
        while ( my $product = $searchCollection->next() ) {
            push @productIDs, $product->product_id;
        }
    }

    return \@productIDs;
}

sub _search_catalog {
    my $self   = shift;
    my %params = @_;
    my $limit  = $params{limit} || Sale::Match::LIMIT_SEARCH;

    my %prodIdSeen = ();
    my @result     = ();
    my $i          = 0;
    foreach my $condition ( @{ $params{conditions} } ) {
        my $sql = join( ' ', $params{sql}, $condition, 'LIMIT', $limit );
        my $prodList = $self->_do_list_query( $sql, @{ $params{args}->[$i] } );

        if ( ref($prodList) eq 'ARRAY' ) {
            map {
                push( @result, $_ ) unless ( exists $prodIdSeen{$_} );
                $prodIdSeen{$_} = 1;
            } @$prodList;
        }

        last if ( scalar @result > 10 );
        $i++;
    }

    return scalar @result ? \@result : undef;
}

sub _get_book_tt {
    my $self = shift;
    return $self->{book_tt} if ( keys %{ $self->{book_tt} } );

    my $tt = {};

    #    isbn        title
    $tt->{ NP() }{ ISMATCH() }      = { status => MATCH,   weight => 1 };
    $tt->{ NOMATCH() }{ ISMATCH() } = { status => SUGGEST, weight => 0 };
    $tt->{ ISMATCH() }{ NP() }      = { status => SUGGEST, weight => 0 };
    $tt->{ ISMATCH() }{ NOMATCH() } = { status => SUGGEST, weight => 0 };
    $tt->{ ISMATCH() }{ ISMATCH() } = { status => MATCH,   weight => 1 };

    $self->{book_tt} = $tt;
    return $self->{book_tt};
}

sub _sort_by_weight {
    my ( $self, $matches ) = @_;

    return sort { $matches->{$b}{weight} <=> $matches->{$a}{weight} } keys %$matches;
}

sub _filter_by_author_name {
    my ( $self, $sale_author, $matches ) = @_;

    my @nomatch_list = ();
    while ( my ( $id, $attr ) = each %$matches ) {
        my $book_id      = $attr->{data}{book_id};
        my @author_names = BookPub::DB::Item::BookContributor->GetAuthorNamesByBookID($book_id);
        foreach my $author_name (@author_names) {
            Sale::Match::DEBUG && print STDERR "compare '$sale_author' and '$author_name'\n";
            push( @nomatch_list, $id ) unless ( word_containment( $sale_author, $author_name ) );
        }
    }

    map { delete $matches->{$_} } @nomatch_list;
}

sub _filter_by_release_date {
    my ( $self, $matches ) = @_;

    my $min_date = 99991231;
    while ( my ( $id, $attr ) = each %$matches ) {
        my $date = $attr->{data}{release_date};
        $date =~ s/-//g;    # now it's just a number
        $min_date = $date if ( $date < $min_date );
    }
    Sale::Match::DEBUG && print STDERR "min release date is $min_date\n";

    while ( my ( $id, $attr ) = each %$matches ) {
        my $date = $attr->{data}{release_date};
        $date =~ s/-//g;
        delete $matches->{$id} unless ( $date == $min_date );
    }
}

#my @recommended = keys %$matches ? $self->_sort_by_weight($matches) :
sub _sort_suggestions {
    my ( $self, $saleDBItem, $matches ) = @_;
    my @sorted_list    = ();
    my @author_nomatch = ();

    foreach my $id ( $self->_sort_by_weight($matches) ) {
        unless ( word_containment( $saleDBItem->r_author, $matches->{$id}{data}->author_name ) ) {
            push( @author_nomatch, $id );
            next;
        }
        push( @sorted_list, $id );
    }
    push( @sorted_list, @author_nomatch ) if @author_nomatch;

    return @sorted_list;
}

1;
