package OpenILS::Utils::HoldTargeter;
# ---------------------------------------------------------------
# Copyright (C) 2016 King County Library System
# Author: Bill Erickson <berickxx@gmail.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# ---------------------------------------------------------------
use strict;
use warnings;
use DateTime;
use OpenSRF::AppSession;
use OpenSRF::Utils::Logger qw(:logger);
use OpenSRF::Utils::JSON;
use OpenSRF::Utils qw/:datetime/;
use OpenILS::Application::AppUtils;
use OpenILS::Utils::CStoreEditor qw/:funcs/;

our $U = "OpenILS::Application::AppUtils";
our $dt_parser = DateTime::Format::ISO8601->new;

# See target() for runtime arguments.
sub new {
    my ($class, %args) = @_;
    my $self = {
        editor => new_editor(),
        ou_setting_cache => {},
        %args,
    };
    return bless($self, $class);
}

# Target and retarget holds.
# By default, targets all holds that need targeting, meaning those that
# have either never been targeted or those whose prev_check_time exceeds
# the retarget interval.
#
# Returns an array of targeter response objects, one entry per hold
# targeted.  See also return_count.
#
# Optional parameters:
#
# hold => <id> / [<id>, <id>, ...]
#  (Re)target one or more specific holds.  Specified as a single hold ID
#  or an array ref of hold IDs.
#
# return_count => 1
#   Return the total number of holds processed instead of a result
#   object for every targeted hold.  Ideal for large batch targeting.
#
# retarget_interval => <interval string>
#   Override the 'circ.holds.retarget_interval' global_flag value.
#
# newest_first => 1
#   Target holds in reverse order of create_time. 
#
# skip_viable => 1
#   Avoid retargeting holds whose current_copy is still viable and
#   permitted.  This is useful for repairing holds whose targeted copy
#   has become non-viable for a given hold because its status changed or
#   policies affecting the hold/copy no longer allow it to be targeted.
#   This setting can be used in conjunction with any other settings.
#
# target_all => 1
#   USE WITH CAUTION.  Forces (re)targeting of all active holds.  This
#   is primarily useful or testing.
#
# parallel_count => n
#   Number of parallel targeters running.  This acts as the indication
#   that other targeter instances are running.
#
# parallel_slot => n [starts at 1]
#   Sets the parallel targeter instance position/slot.  Used to determine
#   which holds to process to avoid conflicts with other running instances.
#
sub target {
    my ($self, %args) = @_;

    $self->{$_} = $args{$_} for keys %args;

    $self->init;

    my $count = 0;
    my @responses;

    for my $hold_id ($self->find_holds_to_target) {
        my $single = OpenILS::Utils::HoldTargeter::Single->new(
            parent => $self,
            skip_viable => $args{skip_viable}
        );
        $single->target($hold_id);
        push(@responses, $single->result) unless $self->{return_count};
        $count++;
    }

    return $self->{return_count} ? $count : \@responses;
}

sub find_holds_to_target {
    my $self = shift;

    if ($self->{hold}) {
        # $self->{hold} can be a single hold ID or an array ref of hold IDs
        return @{$self->{hold}} if ref $self->{hold} eq 'ARRAY';
        return ($self->{hold});
    }

    my $query = {
        select => {ahr => ['id']},
        from => 'ahr',
        where => {
            capture_time => undef,
            fulfillment_time => undef,
            cancel_time => undef,
            frozen => 'f'
        },
        order_by => [
            {class => 'ahr', field => 'selection_depth', direction => 'DESC'},
            {class => 'ahr', field => 'request_time'},
            {class => 'ahr', field => 'prev_check_time'}
        ]
    };

    if (!$self->{target_all}) {
        # Unless we're retargeting all holds, limit to holds that have no
        # prev_check_time or those whose prev_check_time occurred
        # before the retarget interval.

        my $date = DateTime->now->subtract(
            seconds => $self->{retarget_interval});

        $query->{where}->{'-or'} = [
            {prev_check_time => undef},
            {prev_check_time => {'<=' => $date->strftime('%F %T%z')}}
        ];
    }

    # parallel < 1 means no parallel
    my $parallel = ($self->{parallel_count} || 0) > 1 ? 
        $self->{parallel_count} : 0;

    if ($parallel) {
        # In parallel mode, we need to also grab the metarecord for each hold.
        $query->{from} = {
            ahr => {
                rhrr => {
                    fkey => 'id',
                    field => 'id',
                    join => {
                        mmrsm => {
                            field => 'source',
                            fkey => 'bib_record'
                        }
                    }
                }
            }
        };

        # In parallel mode, only process holds within the current process
        # whose metarecord ID modulo the parallel targeter count matches
        # our paralell targeting slot.  This ensures that no 2 processes
        # will be operating on the same potential copy sets.
        #
        # E.g. Running 5 parallel and we are slot 3 (0-based slot 2) of 5, 
        # process holds whose metarecord ID's are 2, 7, 12, 17, ...
        # WHERE MOD(mmrsm.id, 5) = 2

        # Slots are 1-based at the API level, but 0-based for modulo.
        my $slot = $self->{parallel_slot} - 1;

        $query->{where}->{'+mmrsm'} = {
            id => {
                '=' => {
                    transform => 'mod',
                    value => $slot,
                    params => [$parallel]
                }
            }
        };
    }

    # Newest-first sorting cares only about hold create_time.
    $query->{order_by} =
        [{class => 'ahr', field => 'request_time', direction => 'DESC'}]
        if $self->{newest_first};

    my $holds = $self->editor->json_query($query, {substream => 1});

    return map {$_->{id}} @$holds;
}

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

# Load startup data required by all targeter actions.
sub init {
    my $self = shift;
    my $e = $self->editor;

    my $closed_orgs_query = {
        close_start => {'<=', 'now'},
        close_end => {'>=', 'now'}
    };

    if (!$self->{target_all}) {

        # See if the caller provided an interval
        my $interval = $self->{retarget_interval};

        if (!$interval) {
            # See if we have a global flag value for the interval

            $interval = $e->search_config_global_flag({
                name => 'circ.holds.retarget_interval',
                enabled => 't'
            })->[0];

            # If no flag is present, default to a 24-hour retarget interval.
            $interval = $interval ? $interval->value : '24h';
        }

        # Convert the interval to seconds for current and future use.
        $self->{retarget_interval} = interval_to_seconds($interval);

        # An org unit is considered closed for retargeting purposes
        # if it's closed both now and at the next re-target date.

        my $next_check_time =
            DateTime->now->add(seconds => $self->{retarget_interval})
            ->strftime('%F %T%z');

        $closed_orgs_query = {
            '-and' => [
                $closed_orgs_query, {
                    close_start => {'<=', $next_check_time},
                    close_end => {'>=', $next_check_time}
                }
            ]
        }
    }

    my $closed =
        $self->editor->search_actor_org_unit_closed_date($closed_orgs_query);

    # Map of org id to 1. Any org in the map is closed.
    $self->{closed_orgs} = {map {$_->org_unit => 1} @$closed};
}

# Org unit setting fetch+cache
sub get_ou_setting {
    my ($self, $org_id, $setting) = @_;
    my $c = $self->{ou_setting_cache};

    $c->{$org_id} = {} unless $c->{$org_id};

    $c->{$org_id}->{$setting} =
        $U->ou_ancestor_setting_value($org_id, $setting, $self->{editor})
        unless exists $c->{$org_id}->{$setting};

    return $c->{$org_id}->{$setting};
}

# -----------------------------------------------------------------------
# Knows how to target a single hold.
# -----------------------------------------------------------------------
package OpenILS::Utils::HoldTargeter::Single;
use strict;
use warnings;
use DateTime;
use OpenSRF::AppSession;
use OpenSRF::Utils qw/:datetime/;
use OpenSRF::Utils::Logger qw(:logger);
use OpenILS::Application::AppUtils;
use OpenILS::Utils::CStoreEditor qw/:funcs/;

sub new {
    my ($class, %args) = @_;
    my $self = {
        %args,
        editor => new_editor(),
        error => 0,
        success => 0
    };
    return bless($self, $class);
}

# Parent targeter object.
sub parent {
    my ($self, $parent) = @_;
    $self->{parent} = $parent if $parent;
    return $self->{parent};
}

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

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

# Debug message
sub message {
    my ($self, $message) = @_;
    $self->{message} = $message if $message;
    return $self->{message} || '';
}

# True if the hold was successfully targeted.
sub success {
    my ($self, $success) = @_;
    $self->{success} = $success if defined $success;
    return $self->{success};
}

# True if targeting exited early on an unrecoverable error.
sub error {
    my ($self, $error) = @_;
    $self->{error} = $error if defined $error;
    return $self->{error};
}

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

sub result {
    my $self = shift;

    return {
        hold    => $self->hold_id,
        error   => $self->error,
        success => $self->success,
        message => $self->message,
        target  => $self->hold ? $self->hold->current_copy : undef,
        old_target => $self->{previous_copy_id},
        found_copy => $self->{found_copy},
        eligible_copies => $self->{eligible_copy_count}
    };
}

# List of potential copies in the form of slim hashes.  This list
# evolves as copies are filtered as they are deemed non-targetable.
sub copies {
    my ($self, $copies) = @_;
    $self->{copies} = $copies if $copies;
    return $self->{copies};
}

# Final set of potential copies, including those that may not be
# currently targetable, that may be eligible for recall processing.
sub recall_copies {
    my ($self, $recall_copies) = @_;
    $self->{recall_copies} = $recall_copies if $recall_copies;
    return $self->{recall_copies};
}

# Maps copy ID's to their hold proximity
sub copy_prox_map {
    my ($self, $copy_prox_map) = @_;
    $self->{copy_prox_map} = $copy_prox_map if $copy_prox_map;
    return $self->{copy_prox_map};
}

sub log_hold {
    my ($self, $msg, $err) = @_;
    my $level = $err ? 'error' : 'info';
    $logger->$level("targeter: [hold ".$self->hold_id."] $msg");
}

# Captures the exit message, rolls back the cstore transaction/connection,
# and returns false.
# is_error : log the final message and editor event at ERR level.
sub exit_targeter {
    my ($self, $msg, $is_error) = @_;

    $self->message($msg);
    my $log = "exiting => $msg";

    if ($is_error) {
        # On error, roll back and capture the last editor event for logging.

        my $evt = $self->editor->die_event;
        $log .= " [".$evt->{textcode}."]" if $evt;

        $self->error(1);
        $self->log_hold($log, 1);

    } else {
        # Attempt a rollback and disconnect when each hold exits
        # to avoid the possibility of leaving cstore's pinned.
        # Note: ->rollback is a no-op when a ->commit has already occured.

        $self->editor->rollback;
        $self->log_hold($log);
    }

    return 0;
}

# Cancel expired holds and kick off the A/T no-target event.  Returns
# true (i.e. keep going) if the hold is not expired.  Returns false if
# the hold is canceled or a non-recoverable error occcurred.
sub handle_expired_hold {
    my $self = shift;
    my $hold = $self->hold;

    return 1 unless $hold->expire_time;

    my $ex_time =
        $dt_parser->parse_datetime(cleanse_ISO8601($hold->expire_time));
    return 1 unless DateTime->compare($ex_time, DateTime->now) < 0;

    # Hold is expired --

    $hold->cancel_time('now');
    $hold->cancel_cause(1); # == un-targeted expiration

    $self->editor->update_action_hold_request($hold)
        or return $self->exit_targeter("Error canceling hold", 1);

    $self->editor->commit;

    # Fire the A/T handler, but don't wait for a response.
    OpenSRF::AppSession->create('open-ils.trigger')->request(
        'open-ils.trigger.event.autocreate',
        'hold_request.cancel.expire_no_target',
        $hold, $hold->pickup_lib
    );

    return $self->exit_targeter("Hold is expired");
}

# Find potential copies for hold mapping and targeting.
sub get_hold_copies {
    my $self = shift;
    my $e = $self->editor;
    my $hold = $self->hold;

    my $hold_target = $hold->target;
    my $hold_type   = $hold->hold_type;
    my $org_unit    = $hold->selection_ou;
    my $org_depth   = $hold->selection_depth || 0;

    my $query = {
        select => {
            acp => ['id', 'status', 'circ_lib'],
            ahr => ['current_copy']
        },
        from => {
            acp => {
                # Tag copies that are in use by other holds so we don't
                # try to target them for our hold.
                ahr => {
                    type => 'left',
                    fkey => 'id', # acp.id
                    field => 'current_copy',
                    filter => {
                        fulfillment_time => undef,
                        cancel_time => undef,
                        id => {'!=' => $self->hold_id}
                    }
                }
            }
        },
        where => {
            '+acp' => {
                deleted => 'f',
                circ_lib => {
                    in => {
                        select => {
                            aou => [{
                                transform => 'actor.org_unit_descendants',
                                column => 'id',
                                result_field => 'id',
                                params => [$org_depth]
                            }],
                            },
                        from => 'aou',
                        where => {id => $org_unit}
                    }
                }
            }
        }
    };

    unless ($hold_type eq 'R' || $hold_type eq 'F') {
        # Add the holdability filters to the copy query, unless
        # we're processing a Recall or Force hold, which bypass most
        # holdability checks.

        $query->{from}->{acp}->{acpl} = {
            field => 'id',
            filter => {holdable => 't', deleted => 'f'},
            fkey => 'location'
        };

        $query->{from}->{acp}->{ccs} = {
            field => 'id',
            filter => {holdable => 't'},
            fkey => 'status'
        };

        $query->{where}->{'+acp'}->{holdable} = 't';
        $query->{where}->{'+acp'}->{mint_condition} = 't'
            if $U->is_true($hold->mint_condition);
    }

    unless ($hold_type eq 'C' || $hold_type eq 'I' || $hold_type eq 'P') {
        # For volume and higher level holds, avoid targeting copies that
        # act as instances of monograph parts.
        $query->{from}->{acp}->{acpm} = {
            type => 'left',
            field => 'target_copy',
            fkey => 'id'
        };

        $query->{where}->{'+acpm'}->{id} = undef;
    }

    if ($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {

        $query->{where}->{'+acp'}->{id} = $hold_target;

    } elsif ($hold_type eq 'V') {

        $query->{where}->{'+acp'}->{call_number} = $hold_target;

    } elsif ($hold_type eq 'P') {

        $query->{from}->{acp}->{acpm} = {
            field  => 'target_copy',
            fkey   => 'id',
            filter => {part => $hold_target},
        };

    } elsif ($hold_type eq 'I') {

        $query->{from}->{acp}->{sitem} = {
            field  => 'unit',
            fkey   => 'id',
            filter => {issuance => $hold_target},
        };

    } elsif ($hold_type eq 'T') {

        $query->{from}->{acp}->{acn} = {
            field  => 'id',
            fkey   => 'call_number',
            'join' => {
                bre => {
                    field  => 'id',
                    filter => {id => $hold_target},
                    fkey   => 'record'
                }
            }
        };

    } else { # Metarecord hold

        $query->{from}->{acp}->{acn} = {
            field => 'id',
            fkey  => 'call_number',
            join  => {
                bre => {
                    field => 'id',
                    fkey  => 'record',
                    join  => {
                        mmrsm => {
                            field  => 'source',
                            fkey   => 'id',
                            filter => {metarecord => $hold_target},
                        }
                    }
                }
            }
        };

        if ($hold->holdable_formats) {
            # Compile the JSON-encoded metarecord holdable formats
            # to an Intarray query_int string.
            my $query_int = $e->json_query({
                from => [
                    'metabib.compile_composite_attr',
                    $hold->holdable_formats
                ]
            })->[0];
            # TODO: ^- any way to add this as a filter in the main query?

            if ($query_int) {
                # Only pull potential copies from records that satisfy
                # the holdable formats query.
                my $qint = $query_int->{'metabib.compile_composite_attr'};
                $query->{from}->{acp}->{acn}->{join}->{bre}->{join}->{mravl} = {
                    field  => 'source',
                    fkey   => 'id',
                    filter => {vlist => {'@@' => $qint}}
                }
            }
        }
    }

    my $copies = $e->json_query($query);
    $self->{eligible_copy_count} = scalar(@$copies);

    $self->log_hold($self->{eligible_copy_count}." potential copies");

    # Let the caller know we encountered the copy they were interested in.
    $self->{found_copy} = 1 if $self->{find_copy}
        && grep {$_->{id} eq $self->{find_copy}} @$copies;

    $self->copies($copies);

    return 1;
}

# Delete and rebuild copy maps
sub update_copy_maps {
    my $self = shift;
    my $e = $self->editor;

    my $resp = $e->json_query({from => [
        'action.hold_request_regen_copy_maps',
        $self->hold_id,
        '{' . join(',', map {$_->{id}} @{$self->copies}) . '}'
    ]});

    # The above call can fail if another process is updating
    # copy maps for this hold at the same time.
    return 1 if $resp && @$resp;

    return $self->exit_targeter("Error creating hold copy maps", 1);
}

# Returns a map of proximity values to arrays of copy hashes.
# The copy hash arrays are weighted consistent with the org unit hold
# target weight, meaning that a given copy may appear more than once
# in its proximity list.
sub compile_weighted_proximity_map {
    my $self = shift;

    # Collect copy proximity info (generated via DB trigger)
    # from our newly create copy maps.
    my $hold_copy_maps = $self->editor->json_query({
        select => {ahcm => ['target_copy', 'proximity']},
        from => 'ahcm',
        where => {hold => $self->hold_id}
    });

    my %copy_prox_map =
        map {$_->{target_copy} => $_->{proximity}} @$hold_copy_maps;

    my %prox_map;
    for my $copy_hash (@{$self->copies}) {
        my $prox = $copy_prox_map{$copy_hash->{id}};
        $prox_map{$prox} ||= [];

        my $weight = $self->parent->get_ou_setting(
            $copy_hash->{circ_lib},
            'circ.holds.org_unit_target_weight') || 1;

        # Each copy is added to the list once per target weight.
        push(@{$prox_map{$prox}}, $copy_hash) foreach (1 .. $weight);
    }

    return $self->{weighted_prox_map} = \%prox_map;
}

# Returns true if filtering completed without error, false otherwise.
sub filter_closed_date_copies {
    my $self = shift;

    my @filtered_copies;
    for my $copy_hash (@{$self->copies}) {
        my $clib = $copy_hash->{circ_lib};

        if ($self->parent->{closed_orgs}->{$clib}) {
            # Org unit is currently closed.  See if it matters.

            my $ous = $self->hold->pickup_lib eq $clib ?
                'circ.holds.target_when_closed_if_at_pickup_lib' :
                'circ.holds.target_when_closed';

            unless ($self->parent->get_ou_setting($clib, $ous)) {
                # Targeting not allowed at this circ lib when its closed

                $self->log_hold("skipping copy ".
                    $copy_hash->{id}."at closed org $clib");

                next;
            }

        }

        push(@filtered_copies, $copy_hash);
    }

    # Update our in-progress list of copies to reflect the filtered set.
    $self->copies(\@filtered_copies);

    return 1;
}

# Limit the set of potential copies to those that are
# in a targetable status.
# Returns true if filtering completes without error, false otherwise.
sub filter_copies_by_status {
    my $self = shift;

    $self->copies([
        grep {$_->{status} == 0 || $_->{status} == 7} @{$self->copies}
    ]);

    # Track checked out copies for later recall
    $self->recall_copies([grep {$_->{status} == 1} @{$self->copies}]);

    return 1;
}

# Remove copies that are currently targeted by other holds.
# Returns true if filtering completes without error, false otherwise.
sub filter_copies_in_use {
    my $self = shift;

    # A copy with a 'current_copy' value means it's in use by another hold.
    $self->copies([
        grep {!$_->{current_copy}} @{$self->copies}
    ]);

    return 1;
}

# Returns true if inspection completed without error, false otherwise.
sub inspect_previous_target {
    my $self = shift;
    my $hold = $self->hold;
    my @copies = @{$self->copies};

    # no previous target
    return 1 unless my $prev_id = $hold->current_copy;

    $self->{previous_copy_id} = $prev_id;

    # See if the previous copy is in our list of valid copies.
    my ($prev) = grep {$_->{id} eq $prev_id} @copies;

    # exit if previous target is no longer valid.
    return 1 unless $prev;

    if ($self->{skip_viable}) {
        # In skip_viable mode, leave the hold as-is if the existing
        # current_copy is still permitted.
        # Note: viability checking is done this late in the process
        # (specifically after other potential copies have been fetched)
        # because we first need to confirm the current_copy is a valid
        # potential copy (e.g. it's holdable, non-deleted, etc.), which
        # copy_is_permitted, which only checks hold matrix policies,
        # does not check.

        return $self->exit_targeter("Skipping with viable target = $prev_id")
            if $self->copy_is_permitted($prev);

        # Previous copy is now confirmed non-viable.

    } else {

        # Previous copy may be targetable.  Keep it around for later
        # in case we need to confirm its viability and re-use it.
        $self->{valid_previous_copy} = $prev;
    }

    # Remove the previous copy from the working set of potential copies.
    # It will be revisited later if needed.
    $self->copies([grep {$_->{id} ne $prev_id} @copies]);

    return 1;
}

# Returns true if we have at least one potential copy remaining, thus
# targeting should continue.  Otherwise, the hold is updated to reflect
# that there is no target and returns false to stop targeting.
sub handle_no_copies {
    my ($self, %args) = @_;

    if (!$args{force}) {
        # If 'force' is set, the caller is saying that all copies have
        # failed.  Otherwise, see if we have any copies left to inspect.
        return 1 if @{$self->copies} || $self->{valid_previous_copy};
    }

    # At this point, all copies have been inspected and none
    # have yielded a targetable item.

    if ($args{process_recalls}) {
        # See if we have any copies/circs to recall.
        return unless $self->process_recalls;
    }

    my $hold = $self->hold;
    $hold->clear_current_copy;
    $hold->prev_check_time('now');

    $self->editor->update_action_hold_request($hold)
        or return $self->exit_targeter("Error updating hold request", 1);

    $self->editor->commit;
    return $self->exit_targeter("Hold has no targetable copies");
}

# Force and recall holds bypass validity tests.  Returns the first
# (and presumably only) copy in our list of valid copies when a
# F or R hold is encountered.  Returns undef otherwise.
sub attempt_force_recall_target {
    my $self = shift;
    return $self->copies->[0] if
        $self->hold->hold_type eq 'R' || $self->hold->hold_type eq 'F';
    return undef;
}

sub attempt_to_find_copy {
    my $self = shift;

    return undef unless @{$self->copies};

    my $max_loops = $self->parent->get_ou_setting(
        $self->hold->pickup_lib,
        'circ.holds.max_org_unit_target_loops'
    );

    return $self->target_by_org_loops($max_loops) if $max_loops;

    # When not using target loops, targeting is based solely on
    # proximity and org unit target weight.
    $self->compile_weighted_proximity_map;

    return $self->find_nearest_copy;
}

# Returns 2 arrays.  The first is a list of copies whose circ lib's
# unfulfilled target count matches the provided $iter value.  The 
# second list is all other copies, returned for convenience.
sub get_copies_at_loop_iter {
    my ($self, $targeted_libs, $iter) = @_;

    my @iter_copies; # copies to try now.
    my @remaining_copies; # copies to try later

    for my $copy (@{$self->copies}) {
        my $match = 0;

        if ($iter == 0) {
            # Start with copies at circ libs that have never been targeted.
            $match = 1 unless grep {
                $copy->{circ_lib} eq $_->{circ_lib}} @$targeted_libs;

        } else {
            # Find copies at branches whose target count
            # matches the current (non-zero) loop depth.

            $match = 1 if grep {
                $_->{count} eq $iter &&
                $_->{circ_lib} eq $copy->{circ_lib}
            } @$targeted_libs;
        }

        if ($match) {
            push(@iter_copies, $copy);
        } else {
            push(@remaining_copies, $copy);
        }
    }

    $self->log_hold(
        sprintf("%d potential copies at max-loops iteration level $iter. ".
            "%d remain to be tested at a higher loop iteration level.",
            scalar(@iter_copies), 
            scalar(@remaining_copies)
        )
    );

    return (\@iter_copies, \@remaining_copies);
}

# Find libs whose unfulfilled target count is less than the maximum
# configured loop count.  Target copies in order of their circ_lib's
# target count (starting at 0) and moving up.  Copies within each
# loop count group are weighted based on configured hold weight.  If
# no copies in a given group are targetable, move up to the next
# unfulfilled target level.  Keep doing this until all potential
# copies have been tried or max targets loops is exceeded.
# Returns a targetable copy if one is found, undef otherwise.
sub target_by_org_loops {
    my ($self, $max_loops) = @_;

    my $targeted_libs = $self->editor->json_query({
        select => {aufhl => ['circ_lib', 'count']},
        from => 'aufhl',
        where => {hold => $self->hold_id},
        order_by => [{class => 'aufhl', field => 'count'}]
    });

    my $max_tried = 0; # Highest per-lib target attempts.
    foreach (@$targeted_libs) {
        $max_tried = $_->{count} if $_->{count} > $max_tried;
    }

    $self->log_hold("Max lib attempts is $max_tried. ".
        scalar(@$targeted_libs)." libs have been targeted at least once.");

    # $loop_iter represents per-lib target attemtps already made.
    # When loop_iter equals max loops, all libs with targetable copies
    # have been targeted the maximum number of times.  loop_iter starts
    # at 0 to pick up libs that have never been targeted.
    my $loop_iter = -1;
    while (++$loop_iter < $max_loops) {

        # Ran out of copies to try before exceeding max target loops.
        # Nothing else to do here.
        return undef unless @{$self->copies};

        my ($iter_copies, $remaining_copies) = 
            $self->get_copies_at_loop_iter($targeted_libs, $loop_iter);

        next unless @$iter_copies;

        $self->copies($iter_copies);

        # Update the proximity map to only include the copies
        # from this loop-depth iteration.
        $self->compile_weighted_proximity_map;

        my $copy = $self->find_nearest_copy;
        return $copy if $copy; # found one!

        # No targetable copy at the current target loop.
        # Update our current copy set to the not-yet-tested copies.
        $self->copies($remaining_copies);
    }

    # Avoid canceling the hold with exceeds-loops unless at least one
    # lib has been targeted max_loops times.  Otherwise, the hold goes
    # back to waiting for another copy (or retargets its current copy).
    return undef if $max_tried < $max_loops;

    # At least one lib has been targeted max-loops times and zero 
    # other copies are targetable.  All options have been exhausted.
    return $self->handle_exceeds_target_loops;
}

# Cancel the hold, fire the no-target A/T event handler, and exit.
sub handle_exceeds_target_loops {
    my $self = shift;
    my $e = $self->editor;
    my $hold = $self->hold;

    $hold->cancel_time('now');
    $hold->cancel_cause(1); # = un-targeted expiration

    $e->update_action_hold_request($hold)
        or return $self->exit_targeter("Error updating hold request", 1);

    $e->commit;

    # Fire the A/T handler, but don't wait for a response.
    OpenSRF::AppSession->create('open-ils.trigger')->request(
        'open-ils.trigger.event.autocreate',
        'hold_request.cancel.expire_no_target',
        $hold, $hold->pickup_lib
    );

    return $self->exit_targeter("Hold exceeded max target loops");
}

# When all else fails, see if we can reuse the previously targeted copy.
sub attempt_prev_copy_retarget {
    my $self = shift;

    # earlier target logic can in some cases cancel the hold.
    return undef if $self->hold->cancel_time;

    my $prev_copy = $self->{valid_previous_copy};
    return undef unless $prev_copy;

    $self->log_hold("attempting to re-target previously ".
        "targeted copy for hold ".$self->hold_id);

    if ($self->copy_is_permitted($prev_copy)) {
        $self->log_hold("retargeting the previously ".
            "targeted copy [".$prev_copy->{id}."]" );
        return $prev_copy;
    }

    return undef;
}

# Returns the closest copy by proximity that is a confirmed valid
# targetable copy.
sub find_nearest_copy {
    my $self = shift;
    my %prox_map = %{$self->{weighted_prox_map}};
    my $hold = $self->hold;
    my %seen;

    # Pick a copy at random from each tier of the proximity map,
    # starting at the lowest proximity and working up, until a
    # copy is found that is suitable for targeting.
    for my $prox (sort {$a <=> $b} keys %prox_map) {
        my @copies = @{$prox_map{$prox}};
        next unless @copies;

        my $rand = int(rand(scalar(@copies)));

        while (my ($c) = splice(@copies, $rand, 1)) {
            $rand = int(rand(scalar(@copies)));
            next if $seen{$c->{id}};

            return $c if $self->copy_is_permitted($c);
            $seen{$c->{id}} = 1;

            last unless(@copies);
        }
    }

    return undef;
}

# Returns true if the provided copy passes the hold permit test for our
# hold and can be used for targeting.
# When a copy fails the test, it is removed from $self->copies.
sub copy_is_permitted {
    my ($self, $copy) = @_;
    return 0 unless $copy;

    my $resp = $self->editor->json_query({
        from => [
            'action.hold_retarget_permit_test',
            $self->hold->pickup_lib,
            $self->hold->request_lib,
            $copy->{id},
            $self->hold->usr,
            $self->hold->requestor
        ]
    });

    return 1 if $U->is_true($resp->[0]->{success});

    # Copy is confirmed non-viable.
    # Remove it from our potentials list.
    $self->copies([
        grep {$_->{id} ne $copy->{id}} @{$self->copies}
    ]);

    return 0;
}

# Sets hold.current_copy to the provided copy.
sub apply_copy_target {
    my ($self, $copy) = @_;
    my $e = $self->editor;
    my $hold = $self->hold;

    $hold->current_copy($copy->{id});
    $hold->prev_check_time('now');

    $e->update_action_hold_request($hold)
        or return $self->exit_targeter("Error updating hold request", 1);

    $e->commit;
    $self->{success} = 1;
    return $self->exit_targeter("successfully targeted copy ".$copy->{id});
}

# Creates a new row in action.unfulfilled_hold_list for our hold.
# Returns 1 if all is OK, false on error.
sub log_unfulfilled_hold {
    my $self = shift;
    return 1 unless my $prev_id = $self->{previous_copy_id};
    my $e = $self->editor;

    $self->log_hold(
        "hold was not fulfilled by previous targeted copy $prev_id");

    my $circ_lib;
    if ($self->{valid_previous_copy}) {
        $circ_lib = $self->{valid_previous_copy}->{circ_lib};

    } else {
        # We don't have a handle on the previous copy to get its
        # circ lib.  Fetch it.
        $circ_lib = $e->retrieve_asset_copy($prev_id)->circ_lib;
    }

    my $unful = Fieldmapper::action::unfulfilled_hold_list->new;
    $unful->hold($self->hold_id);
    $unful->circ_lib($circ_lib);
    $unful->current_copy($prev_id);

    $e->create_action_unfulfilled_hold_list($unful) or
        return $self->exit_targeter("Error creating unfulfilled_hold_list", 1);

    return 1;
}

sub process_recalls {
    my $self = shift;
    my $e = $self->editor;

    my $pu_lib = $self->hold->pickup_lib;

    my $threshold =
        $self->parent->get_ou_setting($pu_lib, 'circ.holds.recall_threshold')
        or return 1;

    my $interval =
        $self->parent->get_ou_setting($pu_lib, 'circ.holds.recall_return_interval')
        or return 1;

    # Give me the ID of every checked out copy living at the hold
    # pickup library.
    my @copy_ids = map {$_->{id}}
        grep {$_->{circ_lib} eq $pu_lib} @{$self->recall_copies};

    return 1 unless @copy_ids;

    my $circ = $e->search_action_circulation([
        {   target_copy => \@copy_ids,
            checkin_time => undef,
            duration => {'>' => $threshold}
        }, {
            order_by => 'due_date',
            limit => 1
        }
    ])->[0];

    return unless $circ;

    $self->log_hold("recalling circ ".$circ->id);

    # Give the user a new due date of either a full recall threshold,
    # or the return interval, whichever is further in the future.
    my $threshold_date = DateTime::Format::ISO8601
        ->parse_datetime(cleanse_ISO8601($circ->xact_start))
        ->add(seconds => interval_to_seconds($threshold))
        ->iso8601();

    my $return_date = DateTime->now(time_zone => 'local')->add(
        seconds => interval_to_seconds($interval))->iso8601();

    if (DateTime->compare(
        DateTime::Format::ISO8601->parse_datetime($threshold_date),
        DateTime::Format::ISO8601->parse_datetime($return_date)) == 1) {
        $return_date = $threshold_date;
    }

    my %update_fields = (
        due_date => $return_date,
        renewal_remaining => 0,
    );

    my $fine_rules =
        $self->parent->get_ou_setting($pu_lib, 'circ.holds.recall_fine_rules');

    # If the OU hasn't defined new fine rules for recalls, keep them
    # as they were
    if ($fine_rules) {
        $self->log_hold("applying recall fine rules: $fine_rules");
        my $rules = OpenSRF::Utils::JSON->JSON2perl($fine_rules);
        $update_fields{recurring_fine} = $rules->[0];
        $update_fields{fine_interval} = $rules->[1];
        $update_fields{max_fine} = $rules->[2];
    }

    # Copy updated fields into circ object.
    $circ->$_($update_fields{$_}) for keys %update_fields;

    $e->update_action_circulation($circ)
        or return $self->exit_targeter(
            "Error updating circulation object in process_recalls", 1);

    # Create trigger event for notifying current user
    my $ses = OpenSRF::AppSession->create('open-ils.trigger');
    $ses->request('open-ils.trigger.event.autocreate',
        'circ.recall.target', $circ, $circ->circ_lib);

    return 1;
}

# Target a single hold request
sub target {
    my ($self, $hold_id) = @_;

    my $e = $self->editor;
    $self->hold_id($hold_id);

    $self->log_hold("processing...");

    $e->xact_begin;

    my $hold = $e->retrieve_action_hold_request($hold_id)
        or return $self->exit_targeter("No hold found", 1);

    return $self->exit_targeter("Hold is not eligible for targeting")
        if $hold->capture_time     ||
           $hold->cancel_time      ||
           $hold->fulfillment_time ||
           $U->is_true($hold->frozen);

    $self->hold($hold);

    return unless $self->handle_expired_hold;
    return unless $self->get_hold_copies;
    return unless $self->update_copy_maps;

    # Confirm that we have something to work on.  If we have no
    # copies at this point, there's also nothing to recall.
    return unless $self->handle_no_copies;

    # Trim the set of working copies down to those that are
    # currently targetable.
    return unless $self->filter_copies_by_status;
    return unless $self->filter_copies_in_use;
    return unless $self->filter_closed_date_copies;

    # Set aside the previously targeted copy for later use as needed.
    # Code may exit here in skip_viable mode if the existing
    # current_copy value is still viable.
    return unless $self->inspect_previous_target;

    # Log that the hold was not captured.
    return unless $self->log_unfulfilled_hold;

    # Confirm again we have something to work on.  If we have no
    # targetable copies now, there may be a copy that can be recalled.
    return unless $self->handle_no_copies(process_recalls => 1);

    # At this point, the working list of copies has been trimmed to
    # those that are currently targetable at a superficial level.  
    # (They are holdable and available).  Now the code steps through 
    # these copies in order of priority and pickup lib proximity to 
    # find a copy that is confirmed targetable by policy.

    my $copy = $self->attempt_force_recall_target ||
               $self->attempt_to_find_copy        ||
               $self->attempt_prev_copy_retarget;

    # See if one of the above attempt* calls canceled the hold as a side
    # effect of looking for a copy to target.
    return if $hold->cancel_time;

    return $self->apply_copy_target($copy) if $copy;

    # No targetable copy was found.  Fire the no-copy handler.
    $self->handle_no_copies(force => 1, process_recalls => 1);
}



