#---------------------------------------------------------------
# ____                   _ _         ____  _
#|  _ \ ___  _   _  __ _| | |_ _   _/ ___|| |__   __ _ _ __ ___
#| |_) / _ \| | | |/ _` | | __| | | \___ \| '_ \ / _` | '__/ _ \
#|  _ < (_) | |_| | (_| | | |_| |_| |___) | | | | (_| | | |  __/
#|_| \_\___/ \__, |\__,_|_|\__|\__, |____/|_| |_|\__,_|_|  \___|
#            |___/             |___/
#
# Copyright (C) 2009 RoyaltyShare, Inc.   All Rights Reserved
#---------------------------------------------------------------
package BookPub::Catalog::Import::Process::Analyzer::Base;
use strict;
use warnings;
use Data::Dumper;

use lib '/app/tools/common/lib';
use lib '/app/tools/bookpub/lib';
use Common::RSApp;
use Common::Util;
use Common::Assert;
use Common::Process;
use Common::Locale;
use BookPub::DB::Item::ContributorRole;
use BookPub::DB::Item::Contributor;
use BookPub::DB::Item::ProductType;
use BookPub::DB::Item::Product;
use BookPub::DB::Item::Book;
use BookPub::DB::Item::BookFormat;
use BookPub::DB::Item::Imprint;
use BookPub::DB::Item::Publisher;
use BookPub::DB::Item::BookContributor;
use BookPub::DB::Item::BookProductContributor;
use BookPub::DB::Item::BookSubject;
use BookPub::DB::Item::Market;
use BookPub::DB::Item::Region;
use BookPub::DB::Item::Supplier;
use BookPub::DB::Item::ProductMarket;
use BookPub::DB::Item::ProductMarketPrice;
use BookPub::DB::Item::CatalogImport;
use BookPub::DB::Item::CatalogImportItem::Book;
use BookPub::DB::Item::CatalogImportItem::BookContributor;
use BookPub::DB::Item::CatalogImportItem::BookProductContributor;
use BookPub::DB::Item::CatalogImportItem::BookProduct;
use BookPub::DB::Item::CatalogImportItem::BookSubject;
use BookPub::DB::Item::CatalogImportItem::Contributor;
use BookPub::DB::Item::CatalogImportItem::Discount;
use BookPub::DB::Item::CatalogImportItem::Imprint;
use BookPub::DB::Item::CatalogImportItem::Publisher;
use BookPub::DB::Item::CatalogImportItem::Product;
use BookPub::DB::Item::CatalogImportItem::Market;
use BookPub::DB::Item::CatalogImportItem::Region;
use BookPub::DB::Item::CatalogImportItem::Supplier;
use BookPub::DB::Item::CatalogImportItem::ProductMarket;
use BookPub::DB::Item::CatalogImportItem::ProductMarketPrice;

use BookPub::DB::Item::Discount;
use BookPub::DB::Item::BookProduct;
use BookPub::DB::Item::Sale;
use BookPub::Sale::Match::Staged;

use base 'BookPub::Catalog::Import::Process';

use constant kValid              => 0;
use constant kUnknownProductType => 1;

# The purpose of this class is to examine staged imported catalog data, to determine
# whether it's "safe" to slurp it into the real catalog tables.

my %gProductIDsToCheck;

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

    $self->{_importRecord}->status(BookPub::DB::Item::CatalogImport::kImportStatusAnalysisRun);
    $self->{_importRecord}->save();

    # Set the catalogImportID in the Application singleton, so that we'll have
    # that ID available in the table_change_log.
    #
    Common::RSApp::GetRAMCache()->{catalogImportID} = $self->_catalogImportID();
    my $invalidCount = 0;

    #   $invalidCount += $self->_validateContributors();
    $invalidCount += $self->_validateSuppliers();
    $invalidCount += $self->_validateRegions();
    $invalidCount += $self->_validateMarkets();
    $invalidCount += $self->_validateBookProducts();
    $invalidCount += $self->_validateDiscounts();
    $invalidCount += $self->_validateProductMarkets();
    $invalidCount += $self->_validateProductMarketPrices();

    #   $invalidCount += $self->_validateMatches();

    # We'll just return the number of 'problem' books.  So, 0 means all is well.
    #
    return $invalidCount;
}

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

    my $invalidCount = 0;

    if ( keys %gProductIDsToCheck > 0 ) {
        $self->_report( "... re-validating existing matches", 3 );

        my $matcher = BookPub::Sale::Match::Staged->new( client_id => Common::RSApp::GetClientID() );

        $self->_report( "... number of ids to check:" . scalar keys %gProductIDsToCheck, 3 );

        foreach my $stagedBookProductID ( keys %gProductIDsToCheck ) {
            my $stagedBookProduct = $gProductIDsToCheck{$stagedBookProductID};
            my $liveProductID     = $stagedBookProduct->original_product_id;
            if ($liveProductID) {

                # How many sales have we matched to this live product?
                #
                my $sales = BookPub::DB::Item::Sale->GetAllByProductID($liveProductID);
                next unless $sales;
                $self->_report( "... liveProductID: $liveProductID. sales count: " . $sales->size(), 3 );
                while ( my $sale = $sales->next() ) {
                    $self->_report( "-- looking at sale:", 4 );
                    $self->_report( $sale,                 4 );

                    my $matchResult = $matcher->FindBestMatch( data => $sale, skip_regexp_search => 1 );
                    $self->_report( "---> here's what it matched:", 4 );
                    $self->_report( $matchResult,                   4 );

                    # !!! Not sure why the $matchResult record has a {num_products} value.
                    # Not sure I should trust it..
                    #                    if ($matchResult->{num_products})
                    my $numMatches = ( $matchResult->{products} && scalar $matchResult->{products} > 0 );
                    if ($numMatches) {
                        $self->_report( "--- matches $numMatches products...\n", 3 );

                        if ( 1 == $numMatches ) {

                            # If there's one, I guess we're done?
                            # Assuming the product ids didn't change - otherwise we're hosed.
                            #
                            if ( $liveProductID != $matchResult->{products}[0] ) {
                                $self->_report( "sale doesn't match anymore...", 3 );
                                $invalidCount++;
                                $stagedBookProduct->import_status(BookPub::DB::Item::CatalogImport::kErrorBadMatch);
                            } else {

                                # ==> All is well
                                $self->_report( "good match", 3 );
                            }
                        } else {

                            # !!! ... why would we have more than 1 'true' match?
                            #
                            $self->_report( "sale matches multiple products, which is weird...", 3 );
                            $invalidCount++;
                            $stagedBookProduct->import_status(BookPub::DB::Item::CatalogImport::kErrorBadMatch);

                        }
                    } else {
                        $self->_report( "sale matches multiple products, which is weird...", 3 );
                        $invalidCount++;
                        $stagedBookProduct->import_status(BookPub::DB::Item::CatalogImport::kErrorBadMatch);
                    }
                }

            }
        }
    }

    return $invalidCount;
}

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

    my $invalidCount = 0;

    $self->_report( "analyzing book products", 3 );
    my $allBookProducts = BookPub::DB::Item::CatalogImportItem::BookProduct->GetAllByCatalogImport( $self->_catalogImportID() );
    while ( my $bookProduct = $allBookProducts->next() ) {
        eval { $self->_validateBookProduct($bookProduct); };
        if ($@) {
            if ( ref $@ && $@->isa('BookPub::Catalog::Import::Process::Analyzer::Error') ) {
                Common::Log::Print( $@->errorMessage() );
            } else {

                # re-throw the exception.
                #
                die $@;
            }

            $invalidCount++;
        }
    }

    return $invalidCount;
}

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

    my $invalidCount = 0;

    $self->_report( "mapping suppliers", 3 );
    my $allSuppliers = BookPub::DB::Item::CatalogImportItem::Supplier->GetAllByCatalogImport( $self->_catalogImportID() );
    while ( my $supplier = $allSuppliers->next() ) {
        $self->_report( "examining supplier:", 4 );
        $self->_report( $supplier,             4 );

        # Suppliers are identified by name.
        #
        my $matchingLiveSupplier = BookPub::DB::Item::Supplier->Lookup( name => $supplier->name() );
        if ($matchingLiveSupplier) {
            $self->_report( " supplier matches this live supplier:", 4 );
            $self->_report( $matchingLiveSupplier,                   4 );
            $supplier->original_supplier_id( $matchingLiveSupplier->supplier_id() );

            # Did the 'san' change?
            #
            if ( ( $supplier->san // '' ) eq ( $matchingLiveSupplier->san // '' ) ) {
                $self->_report( "   not changing:", 4 );
                $supplier->import_status(BookPub::DB::Item::CatalogImport::kUnchanged);
            } else {
                $self->_report( "   flagging for update:", 4 );
                $supplier->import_status(BookPub::DB::Item::CatalogImport::kUpdate);
            }
        } else {
            $self->_report( "   flagging as new:", 4 );
            $supplier->import_status(BookPub::DB::Item::CatalogImport::kNew);
        }

        $supplier->save();
    }

    return $invalidCount;
}

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

    my $invalidCount = 0;

    $self->_report( "mapping regions", 3 );
    my $oAllRegions = BookPub::DB::Item::CatalogImportItem::Region->GetAllByCatalogImport( $self->_catalogImportID() );
    while ( my $oRegion = $oAllRegions->next() ) {
        $self->_report( "examining region:", 4 );
        $self->_report( $oRegion,            4 );

        my $hCountriesTerritories = $oRegion->getCountryAndTerritoryHash( caller_obj => $self );

        my $oMatchedLiveRegion = BookPub::DB::Item::Region->FindMatchingRegion( %$hCountriesTerritories, caller_obj => $self );
        if ($oMatchedLiveRegion) {
            $self->_report( " region matches this live region:", 4 );
            $self->_report( $oMatchedLiveRegion,                 4 );
            $oRegion->original_region_id( $oMatchedLiveRegion->region_id() );
            $oRegion->import_status(BookPub::DB::Item::CatalogImport::kUnchanged);
        } else {
            $self->_report( " region is new:", 4 );
            $oRegion->import_status(BookPub::DB::Item::CatalogImport::kNew);
        }
        $oRegion->save();
    }

    return $invalidCount;
}

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

    my $invalidCount = 0;

    $self->_report( "mapping markets", 3 );
    my $allMarkets = BookPub::DB::Item::CatalogImportItem::Market->GetAllByCatalogImport( $self->_catalogImportID() );
    while ( my $market = $allMarkets->next() ) {
        $self->_report( "examining market:", 4 );
        $self->_report( $market,             4 );

        # Markets are _identified_ by supplier and region.
        # At this point, we either are creating a new market, or doing nothing.

        my $stagedSupplier =
          BookPub::DB::Item::CatalogImportItem::Supplier->Lookup( catalog_import_supplier_id => $market->catalog_import_supplier_id() );
        my $stagedRegion =
          BookPub::DB::Item::CatalogImportItem::Region->Lookup( catalog_import_region_id => $market->catalog_import_region_id() );

        if ( !$stagedSupplier->original_supplier_id() || !$stagedRegion->original_region_id() ) {
            $market->import_status(BookPub::DB::Item::CatalogImport::kNew);
        } else {

            # It's possible, I suppose, that we may need to create a new market with _existing_ supplier and region.
            #
            my $matchingLiveMarket = BookPub::DB::Item::Market->Lookup(
                region_id   => $stagedRegion->original_region_id(),
                supplier_id => $stagedSupplier->original_supplier_id(),
            );
            if ($matchingLiveMarket) {
                $market->original_market_id( $matchingLiveMarket->market_id() );
                $market->import_status(BookPub::DB::Item::CatalogImport::kUnchanged);
            } else {
                $market->import_status(BookPub::DB::Item::CatalogImport::kNew);
            }
        }

        $market->save();
    }

    return $invalidCount;
}

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

    my $invalidCount = 0;

    $self->_report( "mapping product_markets", 3 );
    my $allProductMarkets = BookPub::DB::Item::CatalogImportItem::ProductMarket->GetAllByCatalogImport( $self->_catalogImportID() );
    while ( my $productMarket = $allProductMarkets->next() ) {
        $self->_report( "examining product_market:", 4 );
        $self->_report( $productMarket,              4 );

        my $stagedProduct =
          BookPub::DB::Item::CatalogImportItem::Product->Lookup( catalog_import_product_id => $productMarket->catalog_import_product_id );
        my $stagedMarket =
          BookPub::DB::Item::CatalogImportItem::Market->Lookup( catalog_import_market_id => $productMarket->catalog_import_market_id );

        # If we don't have 'original' ids, then this is obviously a new product_market.
        # If we do have those ids, then we may or may not have this product_market.
        # It is possible for a product_market to be 'updated' - the dates could change.
        #
        $productMarket->original_product_id( $stagedProduct->original_product_id() );
        $productMarket->original_market_id( $stagedMarket->original_market_id() );
        if ( $productMarket->original_product_id() && $productMarket->original_market_id() ) {
            my $liveProductMarket = BookPub::DB::Item::ProductMarket->Lookup(
                product_id => $productMarket->original_product_id(),
                market_id  => $productMarket->original_market_id(),
            );
            if ($liveProductMarket) {
                $productMarket->original_product_market_id( $liveProductMarket->product_market_id() );

                no warnings;
                if (   $liveProductMarket->expected_availability_date() ne $productMarket->expected_availability_date()
                    || $liveProductMarket->on_sale_date() ne $productMarket->on_sale_date()
                    || $liveProductMarket->availability_status() ne $productMarket->availability_status() ) {
                    $self->_report( "  updating", 4 );
                    $productMarket->import_status(BookPub::DB::Item::CatalogImport::kUpdate);
                } else {
                    $self->_report( "  unchanged", 4 );
                    $productMarket->import_status(BookPub::DB::Item::CatalogImport::kUnchanged);
                }
            } else {
                $self->_report( "  this is new (product and market already exist, though)", 4 );
                $productMarket->import_status(BookPub::DB::Item::CatalogImport::kNew);
            }
        } else {
            $self->_report( "  this is new", 4 );
            $productMarket->import_status(BookPub::DB::Item::CatalogImport::kNew);
        }

        $productMarket->save();
    }

    return $invalidCount;
}

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

    my $invalidCount = 0;

    $self->_report( "mapping product_market_prices", 3 );
    my $allProductMarketPrices =
      BookPub::DB::Item::CatalogImportItem::ProductMarketPrice->GetAllByCatalogImport( $self->_catalogImportID() );
    while ( my $productMarketPrice = $allProductMarketPrices->next() ) {
        $self->_report( " examining price:", 4 );
        $self->_report( $productMarketPrice, 4 );

        # First, if this is a 'new' product or market, then we know this is a new price!
        #
        my $productMarket =
          BookPub::DB::Item::CatalogImportItem::ProductMarket->Lookup(
            catalog_import_product_market_id => $productMarketPrice->catalog_import_product_market_id() );

        my $stagedProduct =
          BookPub::DB::Item::CatalogImportItem::Product->Lookup( catalog_import_product_id => $productMarket->catalog_import_product_id );
        my $stagedMarket =
          BookPub::DB::Item::CatalogImportItem::Market->Lookup( catalog_import_market_id => $productMarket->catalog_import_market_id );
        if ( $stagedProduct && $stagedMarket ) {
            my $liveRegionID;
            my $stagedRegion =
              BookPub::DB::Item::CatalogImportItem::Region->Lookup(
                catalog_import_region_id => $productMarketPrice->catalog_import_region_id() );
            if ($stagedRegion) {
                $liveRegionID = $stagedRegion->original_region_id;
            }

            my $liveDiscountID;
            if ( $productMarketPrice->catalog_import_discount_id() ) {
                my $stagedDiscount =
                  BookPub::DB::Item::CatalogImportItem::Discount->Lookup(
                    catalog_import_discount_id => $productMarketPrice->catalog_import_discount_id() );
                if ($stagedDiscount) {
                    $liveDiscountID = $stagedDiscount->original_discount_id;
                }
            }

            # So... How far to take this in this stage?
            # An incoming price will be either:
            # - A current price, or
            # - A future price.
            # (we don't parse 'historical' prices here).
            #
            # Future prices will be relatively easy to handle: We're just going to _drop_ all existing
            # 'future prices' when we commit this import, and re-build.  So it would seem to make sense
            # to tag all detected future prices here in some fashion so we can easily take that step later.
            #
            # There will never be more than 1 'current' price.   What needs to be determined is whether:
            # - This is a brand-new price. Meaning, there isn't already a 'current' price to worry about.
            # - This new price _replaces_ the existing current price, which gets transformed into a past price.
            # - We're simply _updating_ the exiting current price.
            # -->  Now, this could also mean we _drop_ the existing current price, rather than turn that existing
            #      price into a 'historical' price.
            #
            # So, what distinguishes a current price _update_ vs a _replace_ action?
            #
            # If the 'end_date' is the ONLY change from the current price, that's an update.
            # - otherwise, we're doing a replacement.
            # 'start_date' may be somewhat pointless, honestly, unless it's in the future (in which case
            #  this is a future price anyway).

            # If this price is a 'current' price, we'll need to find the current price.
            # If instead this is a 'future' date, well... that will be interesting.
            #

            # !!! I _think_ I can rely on 'start_date' as an indicator of present or future price for the incoming price.
            #
            if ( $productMarketPrice->start_date()
                && 1 == Common::Util::dateStringCompare( $productMarketPrice->start_date(), $self->_catalogImportDate() ) ) {
                $self->_report(
                    "productMarketPrice start_date = "
                      . $productMarketPrice->start_date()
                      . ", catalog import date = "
                      . $self->_catalogImportDate()
                      . ", dateStringCompare returned: "
                      . Common::Util::dateStringCompare( $productMarketPrice->start_date(), $self->_catalogImportDate() )
                      . ", FLAGGING AS FUTR",
                    4
                );

                # Just use a distinct status code for this.
                #
                $productMarketPrice->import_status(BookPub::DB::Item::CatalogImport::kNewFuture);
            } else {

                # First, we need to _find_ the current product_market.  If there is one.  Which there may not be.
                # !!! So, this is failing if the current price (i.e. the last price we see) had an end_date specified that
                # is either equal to or less than the import date.
                # This is screwing things up.
                # !!! Need to update the 'current' price's end_date to be consistant with the new price's start date, to avoid gaps.
                #
                # !!! I think I need a different accessor.
                #
                my $currentPrice;
                my $liveProductMarketID = $productMarket->original_product_market_id;
                if ($liveProductMarketID) {
                    $currentPrice = BookPub::DB::Item::ProductMarketPrice->GetLastPrice(
                        product_market_id       => $liveProductMarketID,
                        currency_code           => $productMarketPrice->currency_code(),
                        date                    => $self->_catalogImportDate(),
                        price_type_id           => $productMarketPrice->price_type_id(),
                        price_type_qualifier_id => $productMarketPrice->price_type_qualifier_id(),
                        region_id               => $liveRegionID,
                        discount_id             => $liveDiscountID,
                    );
                }

                if ($currentPrice) {
                    $self->_report( "  comparing to current price:", 4 );
                    $self->_report( $currentPrice,                   4 );
                    $productMarketPrice->original_product_market_price_id( $currentPrice->product_market_price_id );

                    # Check to see whether we're updating the existing current price,
                    # simply replacing it, or leaving it alone.
                    #
                    if ( $currentPrice->price() == $productMarketPrice->price() ) {
                        if ( ($currentPrice->end_date() // '') ne ($productMarketPrice->end_date() // '') ) {
                            $self->_report(
                                "  flagging for update : current date: "
                                  . $currentPrice->end_date()
                                  . ", new end date:"
                                  . $productMarketPrice->end_date(),
                                4
                            );
                            $productMarketPrice->import_status(BookPub::DB::Item::CatalogImport::kUpdate);
                        } else {
                            $self->_report( "  flagging as unchanged", 4 );
                            $productMarketPrice->import_status(BookPub::DB::Item::CatalogImport::kUnchanged);
                        }
                    } else {
                        $self->_report( "  flagging for replacement", 4 );
                        $productMarketPrice->import_status(BookPub::DB::Item::CatalogImport::kReplace);
                    }
                } else {
                    $self->_report( "  no current price, flagging as new", 4 );
                    $productMarketPrice->import_status(BookPub::DB::Item::CatalogImport::kNew);
                }
            }
        } else {
            $self->_report( "  new market or product, flagging as new", 4 );
            $productMarketPrice->import_status(BookPub::DB::Item::CatalogImport::kNew);
        }

        $productMarketPrice->save();
    }

    return $invalidCount;
}

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

    my $invalidCount = 0;

    $self->_report( "mapping discounts", 3 );
    my $allDiscounts = BookPub::DB::Item::CatalogImportItem::Discount->GetAllByCatalogImport( $self->_catalogImportID() );
    while ( my $discount = $allDiscounts->next() ) {
        $self->_report( " examining discount:", 4 );
        $self->_report( $discount,              4 );

        # Discounts will either be new or old - I don't anticipate 'updating' them.
        # !!! Well, if we were receiving the actual _value_, then perhaps.
        # !!! But for now, we don't get that data via ONIX.
        #
        my $liveDiscount = BookPub::DB::Item::Discount->Lookup(
            type        => $discount->type(),
            client_code => $discount->client_code(),
        );

        if ($liveDiscount) {
            $discount->original_discount_id( $liveDiscount->discount_id() );
            $discount->import_status(BookPub::DB::Item::CatalogImport::kUnchanged);
        } else {
            $discount->import_status(BookPub::DB::Item::CatalogImport::kNew);
        }

        $discount->save();
    }

    return $invalidCount;
}

sub _validateBookProduct {
    my ( $self, $bookProduct ) = @_;
    $self->_report( "_validateBookProduct:", 3 );
    $self->_report( $bookProduct,            4 );

    my $reducedStagedTitle;
    my $reducedPossibleTitle;

    # Should we validate the book first?
    # This will allow us to determine the original book id.
    #
    my $stagedBook = BookPub::DB::Item::CatalogImportItem::Book->Lookup( catalog_import_book_id => $bookProduct->catalog_import_book_id );
    $self->_validateBook($stagedBook);

    #    $stagedBook = BookPub::DB::Item::CatalogImportItem::Book->Lookup(catalog_import_book_id => $bookProduct->catalog_import_book_id);

    # Validate the imprint next.
    # We'll want to determine the live id.
    #
    my $stagedImprint =
      BookPub::DB::Item::CatalogImportItem::Imprint->Lookup( catalog_import_imprint_id => $bookProduct->catalog_import_imprint_id );
    $self->_validateImprint($stagedImprint);

    my $stagedPublisher =
      BookPub::DB::Item::CatalogImportItem::Publisher->Lookup( catalog_import_publisher_id => $bookProduct->catalog_import_publisher_id );
    $self->_validatePublisher($stagedPublisher);

    # Validate the book_product_contributors
    # !!! What we really want to validate is the underlying 'contributor'.
    # !!! We're always going to re-build the book_product_contributor table after every import, since
    # !!! it's just a linking table.
    #
    my $bookProductContributors = BookPub::DB::Item::CatalogImportItem::BookProductContributor->GetAllByProductIDCatalogImportID(
        $bookProduct->catalog_import_product_id(),
        $self->_catalogImportID(),
    );
    while ( my $bookProductContributor = $bookProductContributors->next() ) {
        $self->_report( "bookProductContributor", 4 );
        $self->_report( $bookProductContributor,  4 );
        my $contributor = $self->_validateContributor( $bookProductContributor->catalog_import_contributor_id() );

        # I don't think I really care if a contributor is 'new' or not.  That doesn't really effect the state of the book_contributor.
    }

    # We'll be validating the 'product' record as well.
    #
    my $stagedProduct =
      BookPub::DB::Item::CatalogImportItem::Product->Lookup( catalog_import_product_id => $bookProduct->catalog_import_product_id );

    # Find, if possible, the live version of this book_product.
    #
    $self->_report( "   looking up live book product using both isbn values", 4 );
    my %args;
    $args{isbn10} = $bookProduct->isbn10() if $bookProduct->isbn10();
    $args{isbn13} = $bookProduct->isbn13() if $bookProduct->isbn13();
    my $liveProduct = BookPub::DB::Item::BookProduct->Lookup(%args);
    $self->_report( $liveProduct, 4 );

    if ( !$liveProduct && ( $bookProduct->isbn10() && $bookProduct->isbn13() ) ) {

        # Perhaps one of the ISBN codes changed?
        #
        my $possibleLiveProducts = BookPub::DB::Item::BookProduct->GetAllWithEitherISBN( $bookProduct->isbn10, $bookProduct->isbn13 );
        while ( my $possibleLiveProduct = $possibleLiveProducts->next() ) {
            $self->_report( "  possible live book product:", 4 );
            $self->_report( $possibleLiveProduct,            4 );

            my $possibleLiveBook = BookPub::DB::Item::Book->Lookup( book_id => $possibleLiveProduct->book_id() );
            $self->_report( "   possible live book:", 4 );
            $self->_report( $possibleLiveBook,        4 );

            # so... if the titles are the same, we'll call this the right product.
            #
            no warnings;
            if (   $stagedBook->title() eq $possibleLiveBook->title()
                && $stagedBook->subtitle() eq $possibleLiveBook->subtitle() ) {
                $liveProduct = $possibleLiveProduct;
                last;
            } else {
                $self->_report( "!!! the live book didn't match, so we're not using that product...", 4 );
            }
        }
    }

    my $foundISBN13Match = 0;
    if ( !$liveProduct && $bookProduct->isbn13() ) {

        # We'll look for exact matches on ISBN13, and then do some fuzzy matching on title.
        #
        my $possibleLiveProducts = BookPub::DB::Item::BookProduct->GetAllByISBN13( $bookProduct->isbn13 );
        my $possibleProductCount = $possibleLiveProducts->size();
        while ( my $possibleLiveProduct = $possibleLiveProducts->next() ) {
            $foundISBN13Match = 1;
            $self->_report( "  possible live book product:", 4 );
            $self->_report( $possibleLiveProduct,            4 );

            my $possibleLiveBook = BookPub::DB::Item::Book->Lookup( book_id => $possibleLiveProduct->book_id() );
            $self->_report( "   possible live book:", 4 );
            $self->_report( $possibleLiveBook,        4 );

            # If the titles are close, then that's good enough.
            # What seems to be happening a lot is subtitle or edition are being included in the title.
            # So if the ISBN13 matches and of the titles contains the other, this should be the right product.
            no warnings;

            my $stagedTitle   = $stagedBook->title();
            my $possibleTitle = $possibleLiveBook->title();

            $reducedStagedTitle   = $self->_reduceTitle( lc($stagedTitle) );
            $reducedPossibleTitle = $self->_reduceTitle( lc($possibleTitle) );

            # check to see if either title is contained within the other
            if ( $stagedTitle =~ /\Q$possibleTitle\E/i || $possibleTitle =~ /\Q$stagedTitle\E/i ) {
                $liveProduct = $possibleLiveProduct;
                last;
            }

            # check to see if either *reduced* title is contained within the other
            elsif ( $reducedStagedTitle =~ /\Q$reducedPossibleTitle\E/i || $reducedPossibleTitle =~ /\Q$reducedStagedTitle\E/i ) {
                $liveProduct = $possibleLiveProduct;
                last;
            } else {

                # If there's only one ISBN13 match, we'll just let it win.
                if ( $possibleProductCount == 1 ) {
                    $self->_report( "This is the only product that matches on ISBN13, so let's use it", 4 );
                    $liveProduct = $possibleLiveProduct;
                    last;
                } else {
                    $self->_report( "!!! the live book didn't match, so we're not using that product...", 4 );
                }
            }
        }
    }

    if ( !$liveProduct && $foundISBN13Match ) {

        # If we found an ISBN13 exact match but couldn't match on the title,
        # something is up so throw an error.
        die BookPub::Catalog::Import::Process::Analyzer::Error->new(
            "ERROR - found product with matching ISBN13 " . $bookProduct->isbn13 . " but different title" );
    }

    if ( !$liveProduct ) {

        # This is a new product.
        #
        $self->_report( "  flagging staged book product as new", 4 );

        $bookProduct->import_status(BookPub::DB::Item::CatalogImport::kNew);
        $stagedProduct->import_status(BookPub::DB::Item::CatalogImport::kNew);
        $bookProduct->save();
        $stagedProduct->save();
    } else {

        # We're either updating this product, or leaving it alone.
        #
        # So, we'll compare them and see.
        #
        no warnings;
        if (   $bookProduct->book_format_id() != $liveProduct->book_format_id()
            || $bookProduct->onix_code_publishing_status() ne $liveProduct->onix_code_publishing_status()
            || $bookProduct->language_code() ne $liveProduct->language_code()
            || $bookProduct->num_pages() != $liveProduct->num_pages()
            || $bookProduct->edition() != $liveProduct->edition()
            || $bookProduct->publication_country() ne $liveProduct->publication_country()
            || $stagedImprint->original_imprint_id() != $liveProduct->imprint_id()
            || $stagedPublisher->original_publisher_id() != $liveProduct->publisher_id()
            || $stagedBook->original_book_id() != $liveProduct->book_id()
            || $bookProduct->isbn10() ne $liveProduct->isbn10()
            || $bookProduct->isbn13() ne $liveProduct->isbn13() ) {
            $self->_report( "  flagging staged book product as an update", 4 );

            # !!! Changing these fields _might_ invalidate matches...
            #
            $bookProduct->original_book_id( $stagedBook->original_book_id() );
            $bookProduct->import_status(BookPub::DB::Item::CatalogImport::kUpdate);
            $stagedProduct->import_status(BookPub::DB::Item::CatalogImport::kUpdate);
        } else {
            $self->_report( "  flagging staged book product as unchanged", 4 );
            $bookProduct->import_status(BookPub::DB::Item::CatalogImport::kUnchanged);
            $stagedProduct->import_status(BookPub::DB::Item::CatalogImport::kUnchanged);
        }
        $bookProduct->original_product_id( $liveProduct->product_id() );
        $stagedProduct->original_product_id( $liveProduct->product_id() );

        $bookProduct->save();
        $stagedProduct->save();
    }
}

sub _reduceTitle {
    my ( $self, $title ) = @_;

    # strip out the five XML character references, the string " and ", lone ampersands, and
    # all but character code 33 to 126 (bang through tilde), leave just printable ascii characters
    $title =~ s/\&amp;//ig;
    $title =~ s/\&lt;//ig;
    $title =~ s/\&gt;//ig;
    $title =~ s/\&quot;//ig;
    $title =~ s/\&apos;//ig;
    $title =~ s/ and //ig;
    $title =~ s/\&//g;
    $title =~ s/[^\!-~]//g;

    return $title;

}

sub _validateBook {
    my ( $self, $book ) = @_;
    $self->_report( "_validateBook:", 3 );
    $self->_report( $book,            4 );

    # We may have already VALIDATED this book.
    # We'll keep track of books we have seen with an internal hash table.
    #
    if ( $self->{_validatedBookIDs}{ $book->catalog_import_book_id } ) {
        $self->_report( " already validated, returning", 4 );
        return;
    }

    # So, a book is wholy defined by its title, subtitle, and list of contributors.
    # This is the tricky part.
    # How persistant do we want book ids to be?
    # We know we want to preserve product_ids.
    # But what about books?  Is it so terrible to eliminate a book?
    # If we strictly limit ourselves to finding an _exact_ match in the live data,
    # and go through and remove books that no longer have products (if we even want to bother with that),
    # doesn't that suffice?
    # - In other words, we NEVER 'update' a book.  We simply create new books when required.
    # This would seem to make MY life a hell of a lot simpler...
    # It does mean, interestingly, that an existing book_product may be updated to use a new book_id - But that was always
    # possible.  Ok, I like this approach.
    #

    # So, I'll look for any books with exactly the same title, subtitle, and contributors/roles (sequence is mutable).
    # If I can't find such a book, we'll flag this book as new.  And all the book contributors are new.
    # If I CAN find such a book, then we'll see if the sequence number of the book contributors changed, and
    # flag those for update if necessary.
    # Finally, we'll validate the _contributors_ (not the book_contributors), in any case.

    # Ok, so first things first.  We want to validate the contributors that back the book contributors.
    # If any of these are new, well, then we know we have a new book.
    # Otherwise, we want to record their roles to compare with other books.
    #
    my $bookContributors = BookPub::DB::Item::CatalogImportItem::BookContributor->GetAllByBookIDCatalogImportID(
        $book->catalog_import_book_id(),
        $self->_catalogImportID(),
    );

    my @idsAndRoles;
    my $newFlag;
    while ( my $bookContributor = $bookContributors->next() ) {
        $self->_report( "bookContributor", 4 );
        $self->_report( $bookContributor,  4 );
        my $contributor = $self->_validateContributor( $bookContributor->catalog_import_contributor_id() );

        if ( BookPub::DB::Item::CatalogImport::kNew eq $contributor->import_status() ) {
            $newFlag = 1;
            $self->_report( "  new contributor found", 4 );

            # Keep going... we still want to validate all the book contributors.
            #
        }

        push @idsAndRoles, [ $contributor->original_contributor_id(), $bookContributor->contributor_role_id() ];
    }

    my $matchingBook;
    if ( !$newFlag ) {

        # Create a string from the @idsAndRoles array that we can use to compare with live books.
        # Sort of a contributor signature.
        #
        my $roleString = '';
        foreach my $idAndRole ( sort { 0 == ( $a->[0] <=> $b->[0] ) ? $a->[1] <=> $b->[1] : $a->[0] <=> $b->[0]; } @idsAndRoles ) {
            $roleString .= $idAndRole->[0] . ':' . $idAndRole->[1] . ',';
        }

        $self->_report( "  roleString: $roleString", 4 );

        # Now find any books with exactly the same title and subtitle.
        #
        my $possibleLiveBooks = BookPub::DB::Item::Book->GetAllByTitleAndSubtitle( $book->title, $book->subtitle );
        $self->_report(
            " possible live books found with title of '"
              . ( $book->title // '' )
              . "' and subtitle of '"
              . ( $book->subtitle // '' ) . "' = "
              . ( $possibleLiveBooks->size // '' ),
            4
        );

        while ( my $liveBook = $possibleLiveBooks->next() ) {

            # Fetch the set of contributors, and build a role signature for the book.
            #
            my $bookContributors = BookPub::DB::Item::BookContributor->GetAllByBookID( $liveBook->book_id() );
            $self->_report( "number of live book contributors: " . $bookContributors->size(), 4 );
            my @liveIDsAndRoles;
            while ( my $bc = $bookContributors->next() ) {
                push @liveIDsAndRoles, [ $bc->contributor_id(), $bc->contributor_role_id() ];
            }
            $self->_report( " live idsAndRoles: ", 4 );
            $self->_report( \@liveIDsAndRoles,     4 );

            my $liveRoleString = '';
            foreach my $idAndRole ( sort { 0 == ( $a->[0] <=> $b->[0] ) ? $a->[1] <=> $b->[1] : $a->[0] <=> $b->[0]; } @liveIDsAndRoles ) {
                $liveRoleString .= $idAndRole->[0] . ':' . $idAndRole->[1] . ',';
            }
            $self->_report( " comparing with live role string: $liveRoleString", 4 );
            if ( ( $liveRoleString // '' ) eq $roleString ) {

                # winnah winnah chicken dinnah
                #
                $self->_report( "  found matching live book:", 4 );
                $self->_report( $liveBook,                     4 );

                $matchingBook = $liveBook;
                last;
            }
        }

        if ( !$matchingBook ) {
            $self->_report( "no matching book", 4 );
            $newFlag = 1;
        }
    }

    # So now we know if we have a new book or not.
    #
    if ( !$newFlag ) {
        $self->_report( "flagging as unchanged", 4 );
        $book->original_book_id( $matchingBook->book_id() );
        $book->import_status(BookPub::DB::Item::CatalogImport::kUnchanged);
    } else {
        $self->_report( "flagging as NEW", 4 );
        $book->import_status(BookPub::DB::Item::CatalogImport::kNew);
    }

    $book->save();

    $self->{_validatedBookIDs}{ $book->catalog_import_book_id } = 1;

    # !!! Remember - we will _always_ delete and re-build the book_contributor list.  It's just a linking table.
    #

    # Again, do I really _care_ about 'validating' the book_subjects?   Can't we just re-build that each time?
    #
}

sub _validateContributor {
    my ( $self, $contributorID ) = @_;
    $self->_report( "_validateContributor: id=$contributorID", 4 );

    my $contributor = BookPub::DB::Item::CatalogImportItem::Contributor->Lookup( catalog_import_contributor_id => $contributorID );
    $self->_report( $contributor, 4 );
    if ( !$contributor->import_status() ) {
        my $liveContributor = BookPub::DB::Item::Contributor->Lookup( name_inverted => $contributor->name_inverted() );
        if ($liveContributor) {
            $self->_report( "matches live contributor", 4 );
            $self->_report( $liveContributor,           4 );

            $contributor->original_contributor_id( $liveContributor->contributor_id() );
            $contributor->import_status(BookPub::DB::Item::CatalogImport::kUnchanged);
        } else {
            $self->_report( " is new", 4 );
            $contributor->import_status(BookPub::DB::Item::CatalogImport::kNew);
        }
        $contributor->save();
    }

    return $contributor;
}

sub _validateImprint {
    my ( $self, $imprint ) = @_;

    if ( !$imprint->import_status() ) {
        my $liveImprint = BookPub::DB::Item::Imprint->Lookup( name => $imprint->name() );
        if ($liveImprint) {
            $self->_report( "  flagging this imprint as unchanged: ", 4 );
            $self->_report( $imprint,                                 4 );

            $imprint->original_imprint_id( $liveImprint->imprint_id() );
            $imprint->import_status(BookPub::DB::Item::CatalogImport::kUnchanged);
        } else {
            $self->_report( "  flagging this imprint as new: ", 4 );
            $self->_report( $imprint,                           4 );
            $imprint->import_status(BookPub::DB::Item::CatalogImport::kNew);
        }
        $imprint->save();
    }
}

sub _validatePublisher {
    my ( $self, $publisher ) = @_;

    if ( !$publisher->import_status() ) {
        my $livePublisher = BookPub::DB::Item::Publisher->Lookup( name => $publisher->name() );
        if ($livePublisher) {
            $self->_report( "  flagging this publisher as unchanged: ", 4 );
            $self->_report( $publisher,                                 4 );

            $publisher->original_publisher_id( $livePublisher->publisher_id() );
            $publisher->import_status(BookPub::DB::Item::CatalogImport::kUnchanged);
        } else {
            $self->_report( "  flagging this publisher as new: ", 4 );
            $self->_report( $publisher,                           4 );
            $publisher->import_status(BookPub::DB::Item::CatalogImport::kNew);
        }
        $publisher->save();
    }
}

sub _validateBookProductContributor {
    my ( $self, $bookProductContributor ) = @_;
}

###
1;    #
###

# By having a distinct Error class, we will be able to easily tell when
# an exception is 'ours'.
#
package BookPub::Catalog::Import::Process::Analyzer::Error;

sub new {
    my ( $class, $details ) = @_;

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

sub _init {
    my ( $self, $details ) = @_;
    $self->{details} = $details;

    return $self;
}

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

# This method returns a 'formatted' error message.
#
sub errorMessage {
    my ($self) = @_;

    my $details = $self->{details};

    my $msg = "Error: $details";

    return $msg;
}

###
1;    #
###
