package Koha::Plugins;

# Copyright 2012 Kyle Hall
#
# 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 <http://www.gnu.org/licenses>.

use Modern::Perl;

use Array::Utils qw( array_minus );
use Class::Inspector;
use List::MoreUtils qw( any );
use Module::Load::Conditional qw( can_load );
use Module::Load;
use Module::Pluggable search_path => ['Koha::Plugin'], except => qr/::Edifact(|::Line|::Message|::Order|::Segment|::Transport)$/;
use Try::Tiny;

use C4::Context;
use C4::Output;

use Koha::Cache::Memory::Lite;
use Koha::Exceptions::Plugin;
use Koha::Plugins::Datas;
use Koha::Plugins::Methods;

use constant ENABLED_PLUGINS_CACHE_KEY => 'enabled_plugins';

BEGIN {
    my $pluginsdir = C4::Context->config("pluginsdir");
    my @pluginsdir = ref($pluginsdir) eq 'ARRAY' ? @$pluginsdir : $pluginsdir;
    push @INC, array_minus(@pluginsdir, @INC) ;
    pop @INC if $INC[-1] eq '.';
}

=head1 NAME

Koha::Plugins - Module for loading and managing plugins.

=head2 new

Constructor

=cut

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

    return unless ( C4::Context->config("enable_plugins") || $args->{'enable_plugins'} );

    $args->{'pluginsdir'} = C4::Context->config("pluginsdir");

    return bless( $args, $class );
}

=head2 call

Calls a plugin method for all enabled plugins

    @responses = Koha::Plugins->call($method, @args)

Note: Pass your arguments as refs, when you want subsequent plugins to use the value
updated by preceding plugins, provided that these plugins support that.

=cut

sub call {
    my ($class, $method, @args) = @_;

    return unless C4::Context->config('enable_plugins');

    my @responses;
    my @plugins = $class->get_enabled_plugins( { verbose => 0 } );
    @plugins = grep { $_->can($method) } @plugins;

    # TODO: Remove warn when after_hold_create is removed from the codebase
    warn "after_hold_create is deprecated and will be removed soon. Contact the following plugin's authors: " . join( ', ', map {$_->{metadata}->{name}} @plugins)
        if $method eq 'after_hold_create' and @plugins;

    foreach my $plugin (@plugins) {
        my $response = eval { $plugin->$method(@args) };
        if ($@) {
            warn sprintf("Plugin error (%s): %s", $plugin->get_metadata->{name}, $@);
            next;
        }

        push @responses, $response;
    }

    return @responses;
}

=head2 get_enabled_plugins

Returns a list of enabled plugins.

    @plugins = Koha::Plugins->get_enabled_plugins( [ verbose => 1 ] );

=cut

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

    return unless C4::Context->config('enable_plugins');

    my $enabled_plugins = Koha::Cache::Memory::Lite->get_from_cache(ENABLED_PLUGINS_CACHE_KEY);
    unless ($enabled_plugins) {
        my $verbose = $params->{verbose} // $class->_verbose;
        $enabled_plugins = [];

        my @plugin_classes;
        try {
            my $rs = Koha::Plugins::Datas->search({ plugin_key => '__ENABLED__', plugin_value => 1 });
            @plugin_classes = $rs->get_column('plugin_class');
        } catch {
            warn "$_";
        };

        foreach my $plugin_class (@plugin_classes) {
            next unless can_load( modules => { $plugin_class => undef }, verbose => $verbose, nocache => 1 );

            my $plugin = eval { $plugin_class->new() };
            if ($@ || !$plugin) {
                warn "Failed to instantiate plugin $plugin_class: $@";
                next;
            }

            push @$enabled_plugins, $plugin;
        }
        Koha::Cache::Memory::Lite->set_in_cache(ENABLED_PLUGINS_CACHE_KEY, $enabled_plugins);
    }

    return @$enabled_plugins;
}

sub _verbose {
    my $class = shift;
    # Return false when running unit tests
    return exists $ENV{_} && $ENV{_} =~ /\/prove(\s|$)|\/koha-qa\.pl$|\.t$/ ? 0 : 1;
}

=head2 feature_enabled

Returns a boolean denoting whether a plugin based feature is enabled or not.

    $enabled = Koha::Plugins->feature_enabled('method_name');

=cut

sub feature_enabled {
    my ( $class, $method ) = @_;

    return 0 unless C4::Context->config('enable_plugins');

    my $key     = "ENABLED_PLUGIN_FEATURE_" . $method;
    my $feature = Koha::Cache::Memory::Lite->get_from_cache($key);
    unless ( defined($feature) ) {
        my @plugins = $class->get_enabled_plugins( { verbose => 0 } );
        my $enabled = any { $_->can($method) } @plugins;
        Koha::Cache::Memory::Lite->set_in_cache( $key, $enabled );
    }
    return $feature;
}

=head2 GetPlugins

This will return a list of all available plugins, optionally limited by
method or metadata value.

    my @plugins = Koha::Plugins::GetPlugins({
        method => 'some_method',
        metadata => { some_key => 'some_value' },
        [ all => 1, errors => 1, verbose => 1 ],
    });

The method and metadata parameters are optional.
If you pass multiple keys in the metadata hash, all keys must match.

If you pass errors (only used in plugins-home), we return two arrayrefs:

    ( $good, $bad ) = Koha::Plugins::GetPlugins( { errors => 1 } );

If you pass verbose, you can enable or disable explicitly warnings
from Module::Load::Conditional. Disabled by default to not flood
the logs.

=cut

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

    my $method       = $params->{method};
    my $req_metadata = $params->{metadata} // {};
    my $errors       = $params->{errors};

    # By default dont warn here unless asked to do so.
    my $verbose      = $params->{verbose} // 0;

    my $filter = ( $method ) ? { plugin_method => $method } : undef;

    my $plugin_classes = Koha::Plugins::Methods->search(
        $filter,
        {   columns  => 'plugin_class',
            distinct => 1
        }
    )->_resultset->get_column('plugin_class');


    # Loop through all plugins that implement at least a method
    my ( @plugins, @failing );
    while ( my $plugin_class = $plugin_classes->next ) {
        if ( can_load( modules => { $plugin_class => undef }, verbose => $verbose, nocache => 1 ) ) {

            my $plugin;
            my $failed_instantiation;

            try {
                $plugin = $plugin_class->new({
                    enable_plugins => $self->{'enable_plugins'}
                        # loads even if plugins are disabled
                        # FIXME: is this for testing without bothering to mock config?
                });
            }
            catch {
                warn "$_";
                $failed_instantiation = 1;
            };

            next if $failed_instantiation;

            next unless $plugin->is_enabled or
                        defined($params->{all}) && $params->{all};

            # filter the plugin out by metadata
            my $plugin_metadata = $plugin->get_metadata;
            next
                if $plugin_metadata
                and %$req_metadata
                and any { !$plugin_metadata->{$_} || $plugin_metadata->{$_} ne $req_metadata->{$_} } keys %$req_metadata;

            push @plugins, $plugin;
        } elsif( $errors ) {
            push @failing, { error => 1, name => $plugin_class };
        }
    }

    return $errors ? ( \@plugins, \@failing ) : @plugins;
}

=head2 InstallPlugins

Koha::Plugins::InstallPlugins( [ verbose => 1 ] )

This method iterates through all plugins physically present on a system.
For each plugin module found, it will test that the plugin can be loaded,
and if it can, will store its available methods in the plugin_methods table.

NOTE: We reload all plugins here as a protective measure in case someone
has removed a plugin directly from the system without using the UI

=cut

sub InstallPlugins {
    my ( $self, $params ) = @_;
    my $verbose = $params->{verbose} // $self->_verbose;

    my @plugin_classes = $self->plugins();
    my @plugins;

    foreach my $plugin_class (@plugin_classes) {
        if ( can_load( modules => { $plugin_class => undef }, verbose => $verbose, nocache => 1 ) ) {
            next unless $plugin_class->isa('Koha::Plugins::Base');

            my $plugin;
            my $failed_instantiation;

            try {
                $plugin = $plugin_class->new({ enable_plugins => $self->{'enable_plugins'} });
            }
            catch {
                warn "$_";
                $failed_instantiation = 1;
            };

            next if $failed_instantiation;

            Koha::Plugins::Methods->search({ plugin_class => $plugin_class })->delete();

            foreach my $method ( @{ Class::Inspector->methods( $plugin_class, 'public' ) } ) {
                Koha::Plugins::Method->new(
                    {
                        plugin_class  => $plugin_class,
                        plugin_method => $method,
                    }
                )->store();
            }

            push @plugins, $plugin;
        }
    }

    Koha::Cache::Memory::Lite->clear_from_cache(ENABLED_PLUGINS_CACHE_KEY);

    return @plugins;
}

=head2 RemovePlugins

    Koha::Plugins->RemovePlugins( {
        [ plugin_class => MODULE_NAME, destructive => 1, disable => 1 ],
    } );

    This is primarily for unit testing. Take care when you pass the
    destructive flag (know what you are doing)!

    The method removes records from plugin_methods for one or all plugins.

    If you pass the destructive flag, it will remove records too from
    plugin_data for one or all plugins. Destructive overrules disable.

    If you pass disable, it will disable one or all plugins (in plugin_data).

    If you do not pass destructive or disable, this method does not touch
    records in plugin_data. The cache key for enabled plugins will be cleared
    only if you pass disabled or destructive.

=cut

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

    my $cond = {
        $params->{plugin_class}
        ? ( plugin_class => $params->{plugin_class} )
        : ()
    };
    Koha::Plugins::Methods->search($cond)->delete;
    if ( $params->{destructive} ) {
        Koha::Plugins::Datas->search($cond)->delete;
        Koha::Cache::Memory::Lite->clear_from_cache( Koha::Plugins->ENABLED_PLUGINS_CACHE_KEY );
    } elsif ( $params->{disable} ) {
        $cond->{plugin_key} = '__ENABLED__';
        Koha::Plugins::Datas->search($cond)->update( { plugin_value => 0 } );
        Koha::Cache::Memory::Lite->clear_from_cache( Koha::Plugins->ENABLED_PLUGINS_CACHE_KEY );
    }
}

1;
__END__

=head1 AUTHOR

Kyle M Hall <kyle.m.hall@gmail.com>

=cut
