package FusionInventory::Agent;

use strict;
use warnings;

use English qw(-no_match_vars);
use UNIVERSAL::require;
use File::Glob;
use IO::Handle;
use Storable 'dclone';

use FusionInventory::Agent::Version;
use FusionInventory::Agent::Config;
use FusionInventory::Agent::Logger;
use FusionInventory::Agent::Storage;
use FusionInventory::Agent::Target::Local;
use FusionInventory::Agent::Target::Server;
use FusionInventory::Agent::Tools;
use FusionInventory::Agent::Tools::Hostname;

our $VERSION = $FusionInventory::Agent::Version::VERSION;
my $PROVIDER = $FusionInventory::Agent::Version::PROVIDER;
our $COMMENTS = $FusionInventory::Agent::Version::COMMENTS || [];
our $VERSION_STRING = _versionString($VERSION);
our $AGENT_STRING = "$PROVIDER-Agent_v$VERSION";
our $CONTINUE_WORD = "...";

sub _versionString {
    my ($VERSION) = @_;

    my $string = "$PROVIDER Agent ($VERSION)";
    if ($VERSION =~ /^\d+\.\d+\.(99\d\d|\d+-dev|.*-build-?\d+)$/) {
        unshift @{$COMMENTS}, "** THIS IS A DEVELOPMENT RELEASE **";
    }

    return $string;
}

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

    my $self = {
        status  => 'unknown',
        datadir => $params{datadir},
        libdir  => $params{libdir},
        vardir  => $params{vardir},
        targets => [],
    };
    bless $self, $class;

    return $self;
}

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

    # Skip create object if still defined (re-init case)
    my $config = $self->{config} || FusionInventory::Agent::Config->new(
        options => $params{options},
        vardir  => $self->{vardir},
    );
    $self->{config} = $config;

    my $logger = FusionInventory::Agent::Logger->new(config => $config);
    $self->{logger} = $logger;

    $logger->debug("Configuration directory: ".$config->confdir());
    $logger->debug("Data directory: $self->{datadir}");
    $logger->debug("Storage directory: $self->{vardir}");
    $logger->debug("Lib directory: $self->{libdir}");

    $self->_handlePersistentState();

    # Persistent data can store a set "forcerun" value to be handled during start
    # Mainly used by win32 service installer
    my $forced_run = delete $self->{_forced_run};

    # Always reset targets to handle re-init case
    $self->{targets} = $config->getTargets(
        logger      => $self->{logger},
        deviceid    => $self->{deviceid},
        vardir      => $self->{vardir}
    );

    if (!$self->getTargets()) {
        $logger->error("No target defined, aborting");
        exit 1;
    }

    # Keep program name for Provider inventory as it will be reset in setStatus()
    FusionInventory::Agent::Task::Inventory::Provider->require();
    $FusionInventory::Agent::Task::Inventory::Provider::PROGRAM = "$PROGRAM_NAME";

    # compute list of allowed tasks
    my %available = $self->getAvailableTasks(disabledTasks => $config->{'no-task'});
    my @tasks = keys %available;
    my @plannedTasks = $self->computeTaskExecutionPlan(\@tasks);

    my %available_lc = map { (lc $_) => $_ } keys %available;
    if (!@tasks) {
        $logger->error("No tasks available, aborting");
        exit 1;
    }

    $logger->debug("Available tasks:");
    foreach my $task (keys %available) {
        $logger->debug("- $task: $available{$task}");
    }

    foreach my $target ($self->getTargets()) {
        if ($target->isType('local') || $target->isType('server')) {
            $logger->debug("target $target->{id}: " . $target->getType() . " " . $target->getName());

            # Handle forced run request
            $target->setNextRunDateFromNow() if $forced_run;
        } else {
            $logger->debug("target $target->{id}: " . $target->getType());
        }

        # Register planned tasks by target
        my @planned = $target->plannedTasks(@plannedTasks);

        if (@planned) {
            $logger->debug("Planned tasks for $target->{id}:");
            foreach my $task (@planned) {
                my $task_lc = lc $task;
                $logger->debug("- $task: " . $available{$available_lc{$task_lc}});
            }
        } else {
            $logger->debug("No planned task");
        }
    }

    $logger->info("Options 'no-task' and 'tasks' are both used. Be careful that 'no-task' always excludes tasks.")
        if ($self->{config}->isParamArrayAndFilled('no-task') && $self->{config}->isParamArrayAndFilled('tasks'));

    # install signal handler to handle graceful exit
    $SIG{INT}  = sub { $self->terminate(); exit 0; };
    $SIG{TERM} = sub { $self->terminate(); exit 0; };

    if ($params{options}) {
        foreach my $comment (@{$COMMENTS}) {
            $self->{logger}->debug($comment);
        }
    }
}

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

    # API overrided in daemon or service mode

    $self->setStatus('waiting');

    my @targets = $self->getTargets();

    $self->{logger}->debug("Running in foreground mode");

    # foreground mode: check each targets once
    my $time = time();
    while ($self->getTargets() && @targets) {
        my $target = shift @targets;
        if ($self->{config}->{lazy} && $time < $target->getNextRunDate()) {
            $self->{logger}->info(
                "$target->{id} is not ready yet, next server contact " .
                "planned for " . localtime($target->getNextRunDate())
            );
            next;
        }

        eval {
            $self->runTarget($target);
        };
        $self->{logger}->error($EVAL_ERROR) if $EVAL_ERROR;

        # Reset next run date to support --lazy option with foreground mode
        $target->resetNextRunDate();
    }
}

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

    # Forget our targets
    $self->{targets} = [];

    # Abort realtask running in that forked process or thread
    $self->{current_task}->abort()
        if ($self->{current_task});
}

sub runTarget {
    my ($self, $target) = @_;

    if ($target->isType('local') || $target->isType('server')) {
        $self->{logger}->info("target $target->{id}: " . $target->getType() . " " . $target->getName());
    }

    # the prolog dialog must be done once for all tasks,
    # but only for server targets
    my $response;
    if ($target->isType('server')) {

        return unless FusionInventory::Agent::HTTP::Client::OCS->require();

        my $client = FusionInventory::Agent::HTTP::Client::OCS->new(
            logger       => $self->{logger},
            timeout      => $self->{config}->{timeout},
            user         => $self->{config}->{user},
            password     => $self->{config}->{password},
            proxy        => $self->{config}->{proxy},
            ca_cert_file => $self->{config}->{'ca-cert-file'},
            ca_cert_dir  => $self->{config}->{'ca-cert-dir'},
            no_ssl_check => $self->{config}->{'no-ssl-check'},
            no_compress  => $self->{config}->{'no-compression'},
        );

        return unless FusionInventory::Agent::XML::Query::Prolog->require();

        my $prolog = FusionInventory::Agent::XML::Query::Prolog->new(
            deviceid => $self->{deviceid},
        );

        $self->{logger}->info("sending prolog request to $target->{id}");
        $response = $client->send(
            url     => $target->getUrl(),
            message => $prolog
        );
        unless ($response) {
            $self->{logger}->error("No answer from server at ".$target->getUrl());
            # Return true on net error
            return 1;
        }

        # update target
        my $content = $response->getContent();
        if (defined($content->{PROLOG_FREQ})) {
            $target->setMaxDelay($content->{PROLOG_FREQ} * 3600);
        }
    }

    foreach my $name ($target->plannedTasks()) {
        eval {
            $self->runTask($target, $name, $response);
        };
        $self->{logger}->error($EVAL_ERROR) if $EVAL_ERROR;
        $self->setStatus($target->paused() ? 'paused' : 'waiting');

        # Leave earlier while requested
        last unless $self->getTargets();
        last if $target->paused();
    }

    return 0;
}

sub runTask {
    my ($self, $target, $name, $response) = @_;

    # API overrided in daemon or service mode

    $self->setStatus("running task $name");

    # standalone mode: run each task directly
    $self->runTaskReal($target, $name, $response);
}

sub runTaskReal {
    my ($self, $target, $name, $response) = @_;

    my $class = "FusionInventory::Agent::Task::$name";

    if (!$class->require()) {
        $self->{logger}->debug2("$name task module does not compile: $@")
            if $self->{logger};
        return;
    }

    my $task = $class->new(
        config       => $self->{config},
        datadir      => $self->{datadir},
        logger       => $self->{logger},
        target       => $target,
        deviceid     => $self->{deviceid},
    );

    return if !$task->isEnabled($response);

    $self->{logger}->info("running task $name");
    $self->{current_task} = $task;

    $task->run(
        user         => $self->{config}->{user},
        password     => $self->{config}->{password},
        proxy        => $self->{config}->{proxy},
        ca_cert_file => $self->{config}->{'ca-cert-file'},
        ca_cert_dir  => $self->{config}->{'ca-cert-dir'},
        no_ssl_check => $self->{config}->{'no-ssl-check'},
        no_compress  => $self->{config}->{'no-compression'},
    );
    delete $self->{current_task};
}

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

sub setStatus {
    my ($self, $status) = @_;

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

    # Rename process including status, for unix platforms
    $0 = lc($PROVIDER) . "-agent";
    $0 .= " (tag $config->{tag})" if $config->{tag};

    if ($status) {
        $self->{status} = $status;

        # Show set status in process name on unix platforms
        $0 .= ": $status";
    }
}

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

    return @{$self->{targets}};
}

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

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

    my %tasks;
    my %disabled  = map { lc($_) => 1 } @{$params{disabledTasks}};

    # tasks may be located only in agent libdir
    my $directory = $self->{libdir};
    $directory =~ s,\\,/,g;
    my $subdirectory = "FusionInventory/Agent/Task";
    # look for all Version perl modules around here
    foreach my $file (File::Glob::bsd_glob("$directory/$subdirectory/*/Version.pm")) {
        next unless $file =~ m{($subdirectory/(\S+)/Version\.pm)$};
        my $module = file2module($1);
        my $name = file2module($2);

        next if $disabled{lc($name)};

        my $version;
        if (!$module->require()) {
            $logger->debug2("module $module does not compile: $@") if $logger;

            # Don't keep trace of module, only really needed to fix perl 5.8 issue
            delete $INC{module2file($module)};

            next;
        }

        {
            no strict 'refs';  ## no critic
            $version = &{$module . '::VERSION'};
        }

        # no version means non-functionning task
        next unless $version;

        $tasks{$name} = $version;
        $logger->debug2("getAvailableTasks() : add of task $name version $version")
            if $logger;
    }

    return %tasks;
}

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

    # Only create storage at first call
    unless ($self->{storage}) {
        $self->{storage} = FusionInventory::Agent::Storage->new(
            logger    => $self->{logger},
            directory => $self->{vardir}
        );
    }

    # Load current agent state
    my $data = $self->{storage}->restore(name => "$PROVIDER-Agent");

    if (!$self->{deviceid} && !$data->{deviceid}) {
        # compute an unique agent identifier, based on host name and current time
        my $hostname = getHostname();

        my ($year, $month , $day, $hour, $min, $sec) =
            (localtime (time))[5, 4, 3, 2, 1, 0];

        $data->{deviceid} = sprintf "%s-%02d-%02d-%02d-%02d-%02d-%02d",
            $hostname, $year + 1900, $month + 1, $day, $hour, $min, $sec;
    } elsif (!$data->{deviceid}) {
        $data->{deviceid} = $self->{deviceid};
    }

    $self->{deviceid} = $data->{deviceid};

    # Handle the option to force a run during start/init/reinit if "forcerun" has
    # been set in storage datas
    $self->{_forced_run} = delete $data->{forcerun} || 0;

    # Always save agent state
    $self->{storage}->save(
        name => "$PROVIDER-Agent",
        data => $data
    );
}

sub setForceRun {
    my ($self, $forcerun) = @_;

    my $storage = FusionInventory::Agent::Storage->new(
        logger    => $self->{logger},
        directory => $self->{vardir}
    );

    my $data = $storage->restore(name => "$PROVIDER-Agent");

    $data->{forcerun} = defined($forcerun) ? $forcerun : 1;

    $storage->save(
        name => "$PROVIDER-Agent",
        data => $data
    );
}

sub _appendElementsNotAlreadyInList {
    my ($list, $elements, $logger) = @_;

    if (! UNIVERSAL::isa($list, 'ARRAY')) {
        $logger->error('_appendElementsNotAlreadyInList(): first argument is not an ARRAY ref') if defined $logger;
        return $list;
    }
    if (UNIVERSAL::isa($elements, 'HASH')) {
        @$elements = keys %$elements;
    } elsif (! UNIVERSAL::isa($elements, 'ARRAY')) {
        $logger->error('_appendElementsNotAlreadyInList(): second argument is neither an ARRAY ref nor a HASH ref') if defined $logger;
        return $list;
    }

    my %list = map { $_ => $_ } @$list;
    # we want to add elements only once, so we ensure that there are no duplicates
    my %elements = map { $_ => 1 } @$elements;
    @$elements = keys %elements;

    # union of list AND elements which are NOT in list
    my @newList = (@$list, grep( !defined($list{$_}), @$elements));

    return @newList;
}

sub computeTaskExecutionPlan {
    my ($self, $availableTasksNames) = @_;

    if (! defined($self->{config}) || !(UNIVERSAL::isa($self->{config}, 'FusionInventory::Agent::Config'))) {
        $self->{logger}->error( "no config found in agent. Can't compute tasks execution plan" ) if (defined $self->{logger});
        return;
    }

    my @executionPlan = ();
    if ($self->{config}->isParamArrayAndFilled('tasks')) {
        $self->{logger}->debug2('isParamArrayAndFilled(\'tasks\') : true') if (defined $self->{logger});
        @executionPlan = _makeExecutionPlan($self->{config}->{'tasks'}, $availableTasksNames, $self->{logger});
    } else {
        $self->{logger}->debug2('isParamArrayAndFilled(\'tasks\') : false') if (defined $self->{logger});
        @executionPlan = @$availableTasksNames;
    }

    return @executionPlan;
}

sub _makeExecutionPlan {
    my ($sortedTasks, $availableTasksNames, $logger) = @_;

    my $sortedTasksCloned = dclone $sortedTasks;
    my @executionPlan = ();
    my %available = map { (lc $_) => $_ } @$availableTasksNames;

    my $task = shift @$sortedTasksCloned;
    while (defined $task) {
        if ($task eq $CONTINUE_WORD) {
            last;
        }
        $task = lc $task;
        if ( defined($available{$task})) {
            push @executionPlan, $available{$task};
        }
        $task = shift @$sortedTasksCloned;
    }
    if ( defined($task) && $task eq $CONTINUE_WORD) {
        # we append all other available tasks
        @executionPlan = _appendElementsNotAlreadyInList(\@executionPlan, $availableTasksNames, $logger);
    }

    return @executionPlan;
}

1;
__END__

=head1 NAME

FusionInventory::Agent - FusionInventory agent

=head1 DESCRIPTION

This is the agent object.

=head1 METHODS

=head2 new(%params)

The constructor. The following parameters are allowed, as keys of the %params
hash:

=over

=item I<datadir>

the read-only data directory.

=item I<vardir>

the read-write data directory.

=item I<options>

the options to use.

=back

=head2 init()

Initialize the agent.

=head2 run()

Run the agent.

=head2 terminate()

Terminate the agent.

=head2 getStatus()

Get the current agent status.

=head2 setStatus()

Set new agent status, also updates process name on unix platforms.

=head2 getTargets()

Get all targets.

=head2 getAvailableTasks()

Get all available tasks found on the system, as a list of module / version
pairs:

%tasks = (
    'Foo' => x,
    'Bar' => y,
);

=head2 setForceRun($forcerun)

Set "forcerun" option to 1 (by default) in persistent state storage. This option
is only read during start, init or reinit. If set to true, the next run is planned
to be started as soon as possible.

=head1 LICENSE

This is free software, licensed under:

  The GNU General Public License, Version 2, June 1991

See LICENSE file for details.
