package Koha::CirculationRules;

# Copyright ByWater Solutions 2017
#
# This file is part of Koha.
#
# Koha 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 3 of the License, or
# (at your option) any later version.
#
# Koha 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.
#
# You should have received a copy of the GNU General Public License
# along with Koha; if not, see <https://www.gnu.org/licenses>.

use Modern::Perl;
use Carp qw( croak );

use Koha::Exceptions;
use Koha::Exceptions::CirculationRule;
use Koha::CirculationRule;
use Koha::Caches;
use Koha::Cache::Memory::Lite;
use Koha::Number::Price;

use base qw(Koha::Objects);

use constant GUESSED_ITEMTYPES_KEY => 'Koha_CirculationRules_last_guess';

=head1 NAME

Koha::CirculationRules - Koha CirculationRule Object set class

=head1 API

=head2 Class Methods

=cut

=head3 rule_kinds

This structure describes the possible rules that may be set, and what scopes they can be set at.

Any attempt to set a rule with a nonsensical scope (for instance, setting the C<patron_maxissueqty> for a branchcode and itemtype), is an error.

=cut

our $RULE_KINDS = {
    lostreturn => {
        scope => ['branchcode'],
    },
    processingreturn => {
        scope => ['branchcode'],
    },
    patron_maxissueqty => {
        scope => [ 'branchcode', 'categorycode' ],
    },
    patron_maxonsiteissueqty => {
        scope => [ 'branchcode', 'categorycode' ],
    },
    max_holds => {
        scope => [ 'branchcode', 'categorycode' ],
    },

    holdallowed => {
        scope        => [ 'branchcode', 'itemtype' ],
        can_be_blank => 0,
    },
    hold_fulfillment_policy => {
        scope        => [ 'branchcode', 'itemtype' ],
        can_be_blank => 0,
    },
    returnbranch => {
        scope        => [ 'branchcode', 'itemtype' ],
        can_be_blank => 0,
    },

    article_requests => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    article_request_fee => {
        scope       => [ 'branchcode', 'categorycode' ],
        is_monetary => 1,
    },
    open_article_requests_limit => {
        scope => [ 'branchcode', 'categorycode' ],
    },

    auto_renew => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    cap_fine_to_replacement_price => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    expire_reserves_charge => {
        scope        => [ 'branchcode', 'categorycode', 'itemtype' ],
        can_be_blank => 0,
    },

    chargeperiod => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    chargeperiod_charge_at => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    fine => {
        scope       => [ 'branchcode', 'categorycode', 'itemtype' ],
        is_monetary => 1,
    },
    finedays => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    firstremind => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    hardduedate => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    hardduedatecompare => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    waiting_hold_cancellation => {
        scope        => [ 'branchcode', 'categorycode', 'itemtype' ],
        can_be_blank => 0,
    },
    holds_per_day => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    holds_per_record => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    issuelength => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    daysmode => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    lengthunit => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    maxissueqty => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    maxonsiteissueqty => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    maxsuspensiondays => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    no_auto_renewal_after => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    no_auto_renewal_after_hard_limit => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    norenewalbefore => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    noautorenewalbefore => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    onshelfholds => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    opacitemholds => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    overduefinescap => {
        scope        => [ 'branchcode', 'categorycode', 'itemtype' ],
        is_monetary  => 1,
        can_be_blank => 1,
    },
    renewalperiod => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    renewalsallowed => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    unseen_renewals_allowed => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    rentaldiscount => {
        scope        => [ 'branchcode', 'categorycode', 'itemtype' ],
        can_be_blank => 0,
    },
    reservesallowed => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    suspension_chargeperiod => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    note => {    # This is not really a rule. Maybe we will want to separate this later.
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    decreaseloanholds => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    recalls_allowed => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    recalls_per_record => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    on_shelf_recalls => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    recall_due_date_interval => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    recall_overdue_fine => {
        scope       => [ 'branchcode', 'categorycode', 'itemtype' ],
        is_monetary => 1,
    },
    recall_shelf_time => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    holds_pickup_period => {
        scope => [ 'branchcode', 'categorycode', 'itemtype' ],
    },
    bookings_lead_period => {
        scope => [ 'branchcode', 'itemtype' ],
    },
    bookings_trail_period => {
        scope => [ 'branchcode', 'itemtype' ],
    },

    # Not included (deprecated?):
    #   * accountsent
    #   * reservecharge
    #   * restrictedtype
};

sub rule_kinds {
    return $RULE_KINDS;
}

=head3 get_effective_rule

  my $effective_rule = Koha::CirculationRules->get_effective_rule(
    {
        rule_name    => $name,
        categorycode => $categorycode,
        itemtype     => $itemtype,
        branchcode   => $branchcode
    }
  );

Return the effective rule object for the rule associated with the criteria passed.


=cut

sub get_effective_rule {
    my ( $self, $params ) = @_;

    $params->{categorycode} //= undef;
    $params->{branchcode}   //= undef;
    $params->{itemtype}     //= undef;

    my $rule_name    = $params->{rule_name};
    my $categorycode = $params->{categorycode};
    my $itemtype     = $params->{itemtype};
    my $branchcode   = $params->{branchcode};

    Koha::Exceptions::MissingParameter->throw("Required parameter 'rule_name' missing")
        unless $rule_name;

    for my $v ( $branchcode, $categorycode, $itemtype ) {
        $v = undef if $v and $v eq '*';
    }

    my $order_by = $params->{order_by} // { -desc => [ 'branchcode', 'categorycode', 'itemtype' ] };

    my $search_params;
    $search_params->{rule_name} = $rule_name;

    $search_params->{categorycode} = defined $categorycode ? [ $categorycode, undef ] : undef;
    $search_params->{itemtype}     = defined $itemtype     ? [ $itemtype,     undef ] : undef;
    $search_params->{branchcode}   = defined $branchcode   ? [ $branchcode,   undef ] : undef;

    my $rule = $self->search(
        $search_params,
        {
            order_by => $order_by,
            rows     => 1,
        }
    )->single;

    return $rule;
}

=head3 get_effective_rule_value

  my $effective_rule_value = Koha::CirculationRules->get_effective_rule_value(
    {
        rule_name    => $name,
        categorycode => $categorycode,
        itemtype     => $itemtype,
        branchcode   => $branchcode
    }
  );

Return the effective value for the rule associated with the criteria passed.

This is a cached method so should be used in preference to get_effective_rule where possible
to aid performance.

=cut

sub get_effective_rule_value {
    my ( $self, $params ) = @_;

    my $rule_name    = $params->{rule_name};
    my $categorycode = $params->{categorycode};
    my $itemtype     = $params->{itemtype};
    my $branchcode   = $params->{branchcode};

    my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
    my $cache_key    = sprintf "CircRules:%s:%s:%s:%s", $rule_name // q{},
        $categorycode // q{}, $branchcode // q{}, $itemtype // q{};

    my $cached = $memory_cache->get_from_cache($cache_key);
    return $cached if $cached;

    my $rule = $self->get_effective_rule($params);

    my $value = $rule ? $rule->rule_value : undef;
    $memory_cache->set_in_cache( $cache_key, $value );
    return $value;
}

=head3 get_effective_rules

=cut

sub get_effective_rules {
    my ( $self, $params ) = @_;

    my $rules        = $params->{rules};
    my $categorycode = $params->{categorycode};
    my $itemtype     = $params->{itemtype};
    my $branchcode   = $params->{branchcode};

    my $r;
    foreach my $rule (@$rules) {
        my $effective_rule = $self->get_effective_rule_value(
            {
                rule_name    => $rule,
                categorycode => $categorycode,
                itemtype     => $itemtype,
                branchcode   => $branchcode,
            }
        );

        $r->{$rule} = $effective_rule if defined $effective_rule;
    }

    return $r;
}

=head3 set_rule

=cut

sub set_rule {
    my ( $self, $params ) = @_;

    for my $mandatory_parameter (qw( rule_name rule_value )) {
        Koha::Exceptions::MissingParameter->throw("Required parameter '$mandatory_parameter' missing")
            unless exists $params->{$mandatory_parameter};
    }

    my $kind_info = $RULE_KINDS->{ $params->{rule_name} };
    Koha::Exceptions::MissingParameter->throw("set_rule given unknown rule '$params->{rule_name}'!")
        unless defined $kind_info;

    # Enforce scope; a rule should be set for its defined scope, no more, no less.
    foreach my $scope_level (qw( branchcode categorycode itemtype )) {
        if ( grep /$scope_level/, @{ $kind_info->{scope} } ) {
            croak "set_rule needs '$scope_level' to set '$params->{rule_name}'!"
                unless exists $params->{$scope_level};
        } else {
            croak "set_rule cannot set '$params->{rule_name}' for a '$scope_level'!"
                if exists $params->{$scope_level};
        }
    }

    my $branchcode   = $params->{branchcode};
    my $categorycode = $params->{categorycode};
    my $itemtype     = $params->{itemtype};
    my $rule_name    = $params->{rule_name};
    my $rule_value   = $params->{rule_value};
    my $can_be_blank = defined $kind_info->{can_be_blank} ? $kind_info->{can_be_blank} : 1;
    $rule_value = undef if defined $rule_value && $rule_value eq "" && !$can_be_blank;
    my $is_monetary = defined $kind_info->{is_monetary} ? $kind_info->{is_monetary} : 0;
    Koha::Exceptions::CirculationRule::NotDecimal->throw( name => $rule_name, value => $rule_value )
        if ( $is_monetary && defined($rule_value) && $rule_value ne '' && $rule_value !~ /^\d+(\.\d+)?$/ );

    for my $v ( $branchcode, $categorycode, $itemtype ) {
        $v = undef if $v and $v eq '*';
    }
    my $rule = $self->search(
        {
            rule_name    => $rule_name,
            branchcode   => $branchcode,
            categorycode => $categorycode,
            itemtype     => $itemtype,
        }
    )->next();

    if ($rule) {
        if ( defined $rule_value ) {
            $rule->rule_value($rule_value);
            $rule->update();
        } else {
            $rule->rule_value(undef);
            $rule->delete();
        }
    } else {
        if ( defined $rule_value ) {
            $rule = Koha::CirculationRule->new(
                {
                    branchcode   => $branchcode,
                    categorycode => $categorycode,
                    itemtype     => $itemtype,
                    rule_name    => $rule_name,
                    rule_value   => $rule_value,
                }
            );
            $rule->store();
        }
    }

    my $memory_cache = Koha::Cache::Memory::Lite->get_instance;
    for my $k ( $memory_cache->all_keys ) {
        $memory_cache->clear_from_cache($k) if $k =~ m{^CircRules:};
    }

    $rule = Koha::CirculationRule->new(
        { rule_name => $rule_name, branchcode => $branchcode, categorycode => $categorycode, itemtype => $itemtype } )
        unless $rule;

    return $rule;
}

=head3 set_rules

=cut

sub set_rules {
    my ( $self, $params ) = @_;

    my %set_params;
    $set_params{branchcode}   = $params->{branchcode}   if exists $params->{branchcode};
    $set_params{categorycode} = $params->{categorycode} if exists $params->{categorycode};
    $set_params{itemtype}     = $params->{itemtype}     if exists $params->{itemtype};
    my $rules = $params->{rules};

    my $rule_objects = [];
    while ( my ( $rule_name, $rule_value ) = each %$rules ) {
        my $rule_object = Koha::CirculationRules->set_rule(
            {
                %set_params,
                rule_name  => $rule_name,
                rule_value => $rule_value,
            }
        );
        push( @$rule_objects, $rule_object );
    }

    return $rule_objects;
}

=head3 delete

Delete a set of circulation rules, needed for cleaning up when deleting issuingrules

=cut

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

    while ( my $rule = $self->next ) {
        $rule->delete;
    }
}

=head3 clone

Clone a set of circulation rules to another branch

=cut

sub clone {
    my ( $self, $to_branch ) = @_;

    while ( my $rule = $self->next ) {
        $rule->clone($to_branch);
    }
}

=head2 get_return_branch_policy

  my $returnbranch = Koha::CirculationRules->get_return_branch_policy($item);

Returns the branch to use for returning the item based on the
item type, and a branch selected via CircControlReturnsBranch.

The return value is the branch to which to return the item. Possible values:
  noreturn: do not return, let item remain where checked in (floating collections)
  homebranch: return to item's home branch
  holdingbranch: return to issuer branch

This searches branchitemrules in the following order:
  * Same branchcode and itemtype
  * Same branchcode, itemtype '*'
  * branchcode '*', same itemtype
  * branchcode '*' and itemtype '*'

=cut

sub get_return_branch_policy {
    my ( $self, $item ) = @_;

    my $pref = C4::Context->preference('CircControlReturnsBranch');

    my $branchcode =
          $pref eq 'ItemHomeLibrary'    ? $item->homebranch
        : $pref eq 'ItemHoldingLibrary' ? $item->holdingbranch
        : $pref eq 'CheckInLibrary'     ? C4::Context->userenv
            ? C4::Context->userenv->{branch}
            : $item->homebranch
        : $item->homebranch;

    my $itemtype = $item->effective_itemtype;

    my $rule = Koha::CirculationRules->get_effective_rule(
        {
            rule_name  => 'returnbranch',
            itemtype   => $itemtype,
            branchcode => $branchcode,
        }
    );

    return $rule ? $rule->rule_value : 'homebranch';
}

=head3 get_opacitemholds_policy

my $can_place_a_hold_at_item_level = Koha::CirculationRules->get_opacitemholds_policy( { patron => $patron, item => $item } );

Return 'Y' or 'F' if the patron can place a hold on this item according to the issuing rules
and the "Item level holds" (opacitemholds).
Can be 'N' - Don't allow, 'Y' - Allow, and 'F' - Force

=cut

sub get_opacitemholds_policy {
    my ( $class, $params ) = @_;

    my $item   = $params->{item};
    my $patron = $params->{patron};

    return unless $item or $patron;

    my $rule = Koha::CirculationRules->get_effective_rule(
        {
            categorycode => $patron->categorycode,
            itemtype     => $item->effective_itemtype,
            branchcode   => $item->homebranch,
            rule_name    => 'opacitemholds',
        }
    );

    return $rule ? $rule->rule_value : undef;
}

=head3 get_onshelfholds_policy

    my $on_shelf_holds = Koha::CirculationRules->get_onshelfholds_policy({ item => $item, patron => $patron });

=cut

sub get_onshelfholds_policy {
    my ( $class, $params ) = @_;
    my $item     = $params->{item};
    my $itemtype = $item->effective_itemtype;
    my $patron   = $params->{patron};
    my $rule     = Koha::CirculationRules->get_effective_rule(
        {
            categorycode => ( $patron ? $patron->categorycode : undef ),
            itemtype     => $itemtype,
            branchcode   => $item->holdingbranch,
            rule_name    => 'onshelfholds',
        }
    );
    return $rule ? $rule->rule_value : 0;
}

=head3 get_lostreturn_policy

  my $lost_proc_refund_policy = Koha::CirculationRules->get_lostreturn_policy( { return_branch => $return_branch, item => $item } );

lostreturn return values are:

=over 2

=item '0' - Do not refund

=item 'refund' - Refund the lost item charge

=item 'restore' - Refund the lost item charge and restore the original overdue fine

=item 'charge' - Refund the lost item charge and charge a new overdue fine

=back

processing return return values are:

=over 2

=item '0' - Do not refund

=item 'refund' - Refund the lost item processing charge

=item 'restore' - Refund the lost item processing charge and restore the original overdue fine

=item 'charge' - Refund the lost item processing charge and charge a new overdue fine

=back


=cut

sub get_lostreturn_policy {
    my ( $class, $params ) = @_;

    my $item = $params->{item};

    my $behaviour         = C4::Context->preference('RefundLostOnReturnControl') // 'CheckinLibrary';
    my $behaviour_mapping = {
        CheckinLibrary    => $params->{'return_branch'} // $item->homebranch,
        ItemHomeBranch    => $item->homebranch,
        ItemHoldingBranch => $item->holdingbranch
    };

    my $branch = $behaviour_mapping->{$behaviour};

    my $rules = Koha::CirculationRules->get_effective_rules(
        {
            branchcode => $branch,
            rules      => [ 'lostreturn', 'processingreturn' ]
        }
    );

    $rules->{lostreturn}       //= 'refund';
    $rules->{processingreturn} //= 'refund';
    return $rules;
}

=head3 article_requestable_rules

    Return rules that allow article requests, optionally filtered by
    patron categorycode.

    Use with care; see guess_article_requestable_itemtypes.

=cut

sub article_requestable_rules {
    my ( $class, $params ) = @_;
    my $category = $params->{categorycode};

    return if !C4::Context->preference('ArticleRequests');
    return $class->search(
        {
            $category ? ( categorycode => [ $category, undef ] ) : (),
            rule_name  => 'article_requests',
            rule_value => { '!=' => 'no' },
        }
    );
}

=head3 guess_article_requestable_itemtypes

    Return item types in a hashref that are likely possible to be
    'article requested'. Constructed by an intelligent guess in the
    issuing rules (see article_requestable_rules).

    Note: pref ArticleRequestsLinkControl overrides the algorithm.

    Optional parameters: categorycode.

    Note: the routine is used in opac-search to obtain a reasonable
    estimate within performance borders (not looking at all items but
    just using default itemtype). Also we are not looking at the
    branchcode here, since home or holding branch of the item is
    leading and branch may be unknown too (anonymous opac session).

=cut

sub guess_article_requestable_itemtypes {
    my ( $class, $params ) = @_;
    my $category = $params->{categorycode};
    return {}           if !C4::Context->preference('ArticleRequests');
    return { '*' => 1 } if C4::Context->preference('ArticleRequestsLinkControl') eq 'always';

    my $cache                            = Koha::Caches->get_instance;
    my $last_article_requestable_guesses = $cache->get_from_cache(GUESSED_ITEMTYPES_KEY);
    my $key                              = $category || '*';
    return $last_article_requestable_guesses->{$key}
        if $last_article_requestable_guesses && exists $last_article_requestable_guesses->{$key};

    my $res   = {};
    my $rules = $class->article_requestable_rules(
        {
            $category ? ( categorycode => $category ) : (),
        }
    );
    return $res if !$rules;
    foreach my $rule ( $rules->as_list ) {
        $res->{ $rule->itemtype // '*' } = 1;
    }
    $last_article_requestable_guesses->{$key} = $res;
    $cache->set_in_cache( GUESSED_ITEMTYPES_KEY, $last_article_requestable_guesses );
    return $res;
}

=head3 get_effective_daysmode

Return the value for daysmode defined in the circulation rules.
If not defined (or empty string), the value of the system preference useDaysMode is returned

=cut

sub get_effective_daysmode {
    my ( $class, $params ) = @_;

    my $categorycode = $params->{categorycode};
    my $itemtype     = $params->{itemtype};
    my $branchcode   = $params->{branchcode};

    my $daysmode_rule = $class->get_effective_rule(
        {
            categorycode => $categorycode,
            itemtype     => $itemtype,
            branchcode   => $branchcode,
            rule_name    => 'daysmode',
        }
    );

    return ( defined($daysmode_rule) and $daysmode_rule->rule_value ne '' )
        ? $daysmode_rule->rule_value
        : C4::Context->preference('useDaysMode');

}

=head3 get_effective_expire_reserves_charge

Return the value for expire_reserves_charge defined in the circulation rules.
If not defined (or empty string), the value of the system preference ExpireReservesMaxPickUpDelayCharge is returned

=cut

sub get_effective_expire_reserves_charge {
    my ( $class, $params ) = @_;

    my $itemtype     = $params->{itemtype};
    my $branchcode   = $params->{branchcode};
    my $categorycode = $params->{categorycode};

    my $expire_reserves_charge_rule = $class->get_effective_rule(
        {
            itemtype     => $itemtype,
            branchcode   => $branchcode,
            categorycode => $categorycode,
            rule_name    => 'expire_reserves_charge',
        }
    );

    # return the rule value (incl. 0) if rule found so there's an object in the variable,
    # or return default value from sysprefs when rule wasn't found and there's undef
    return $expire_reserves_charge_rule
        ? $expire_reserves_charge_rule->rule_value
        : C4::Context->preference("ExpireReservesMaxPickUpDelayCharge");

}

=head3 type

=cut

sub _type {
    return 'CirculationRule';
}

=head3 object_class

=cut

sub object_class {
    return 'Koha::CirculationRule';
}

1;
