package Slim::Utils::PluginManager;

# Squeezebox Server Copyright 2001-2009 Logitech.
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License,
# version 2.
#
# $Id: PluginManager.pm 30663 2010-04-17 16:36:14Z agrundman $

use strict;

use File::Basename qw(dirname);
use File::Spec::Functions qw(:ALL);
use File::Next;
use File::Path;
use FindBin qw($Bin);
use Path::Class qw(dir);
use XML::Simple;
use YAML::Syck;
use Config;

use Slim::Utils::Log;
use Slim::Utils::Misc;
use Slim::Utils::OSDetect;
use Slim::Utils::Prefs;
use Slim::Utils::Strings;
use Slim::Utils::Versions;

my $log   = logger('server.plugins');

my $prefs = preferences('plugin.state'); # per plugin state or pending state
# valid states: disabled, enabled, needs-enable, needs-disable, needs-install, needs-uninstall

my $plugins   = {};
my $loaded    = {};
my $disabled  = {};
my $cacheInfo = {};
my $message;
my $downloader;

use constant CACHE_VERSION => 4;

# Skip unwanted plugins
my %SKIP = ();

sub init {
	my $class = shift;

	# migrate state info to new pref format
	$prefs->migrate(1, sub {
		for my $old (keys %{$prefs->all}) {
			my $new;
			if    ($old =~ /^Plugins::(.*)::/)                { $new = $1 }
			elsif ($old =~ /^Slim::Plugin::(.*)::/)           { $new = $1 }
			elsif ($old =~ /.*[\/|\\](.*)[\/|\\]install.xml/) { $new = $1 }
			if ($new) {
				$prefs->set($new, $prefs->get($old) ? 'enabled' : 'disabled');
			}
			$prefs->remove($old);
		}
		1;
	});

	if ( main::WEBUI ) {
		eval {
			require Slim::Utils::PluginDownloader;
			$downloader = 'Slim::Utils::PluginDownloader';
			$downloader->init;
		};
	}

	my $pendingOps;
	my $cacheInvalid;
	my $osDetails = Slim::Utils::OSDetect::details();
	
	%SKIP = map {$_ => 1} Slim::Utils::OSDetect::skipPlugins();

	# load the manifest cache
	$class->_loadPluginCache;

	# process any pending operations (using cache which will potentially become stale)
	for my $plugin (keys %{$prefs->all}) {

		my $val = $prefs->get($plugin);

		if ($val =~ /needs/) {

			$pendingOps = 1;

			if       ($val eq 'needs-enable') {

				$class->_needsEnable($plugin);

			} elsif ($val eq 'needs-disable') {

				$class->_needsDisable($plugin);

			} elsif ($val eq 'needs-uninstall') { 

				$class->_needsUninstall($plugin); 

			} elsif ($val eq 'needs-install') {

				$class->_needsInstall($plugin);
			}
		}
	}

	# validate the cache
	my ($manifestFiles, $sum) = $class->_findInstallManifests;

	if ($main::failsafe) {
	
		$cacheInvalid = 'starting in failsafe mode - do not load optional plugins';
	
	} elsif (!scalar keys %{$prefs->all}) {

		$cacheInvalid = 'plugins states are not defined';

	} elsif ($pendingOps) {

		$cacheInvalid = 'processed pending operations';

	} elsif ($cacheInfo && $cacheInfo->{'version'}) {
		
		if ($cacheInfo->{'version'} != CACHE_VERSION) {

			$cacheInvalid = 'cache version does not match';

		} elsif ($cacheInfo->{'bin'} ne $Bin) {

			$cacheInvalid = 'binary location does not match';

		} elsif ($cacheInfo->{'count'} != scalar @{$manifestFiles}) {

			$cacheInvalid = 'different number of plugins in cache';

		} elsif ($cacheInfo->{'mtimesum'} != $sum) {

			$cacheInvalid = 'manifest checksum differs';

		} elsif ($cacheInfo->{'server'} ne $::VERSION) {

			$cacheInvalid = 'server version changed';

		} elsif ($cacheInfo->{'revision'} ne $::REVISION) {

			$cacheInvalid = 'server revision changed';

		} elsif ($cacheInfo->{'osType'} ne $osDetails->{'os'} || $cacheInfo->{'osArch'} ne $osDetails->{'osArch'}) {

			$cacheInvalid = 'os or os architecture changed';
		}

	} else {

		$cacheInvalid = 'no plugin cache';
	}

	if ($cacheInvalid) {

		$log->warn("Reparsing plugin manifests - $cacheInvalid");

		$class->_readInstallManifests($manifestFiles);

		$cacheInfo = {
			'version' => CACHE_VERSION,
			'bin'     => $Bin,
			'count'   => scalar @{$manifestFiles},
			'mtimesum'=> $sum,
			'server'  => $::VERSION,
			'revision'=> $::REVISION,
			'osType'  => $osDetails->{'os'},
			'osArch'  => $osDetails->{'osArch'},
		};
		
		$class->_writePluginCache unless $main::failsafe;
	}
} 

sub load {
	my $class = shift;

	for my $name (sort keys %$plugins) {
		
		my $state = $prefs->get($name);
		
		my $manifest = $plugins->{$name};

		# Skip plugins with no perl module.
		next unless $manifest->{'module'};
		
		my $baseDir    = $manifest->{'basedir'};
		my $module     = $manifest->{'module'};
		
		# Initialize all plugins into the disabled list, they are removed below
		# if they are loaded
		$disabled->{$module} = $plugins->{$name};

		if ( main::SLIM_SERVICE && $name =~ /^Plugins/ ) {
			# Skip 3rd party plugins on SN
			next;
		}

		# in failsafe mode skip all plugins which aren't required
		next if ($main::failsafe && !$plugins->{$name}->{'enforce'});

		if (defined $state && $state !~ /enabled|disabled/) {
			$log->error("Skipping plugin: $name - in erroneous state: $state");
			next;
		}

		if (defined $state && $state eq 'disabled') {
			$log->warn("Skipping plugin: $name - disabled");
			next;
		}

		# Skip plugins that can't be loaded.
		if ($manifest->{'error'} ne 'INSTALLERROR_SUCCESS') {
			$log->error(sprintf("Couldn't load $name. Error: %s\n", $class->getErrorString($name)));
			next;
		}

		main::INFOLOG && $log->info("Loading plugin: $name");

		my $loadModule = 0;

		# Look for a lib dir that has a PAR file or otherwise.
		if (-d (my $lib = catdir($baseDir, 'lib')) ) {

			my $dir = dir( catdir($baseDir, 'lib') );

			for my $file ($dir->children) {

				if ($file =~ /\.par$/) {

					$loadModule = 1;

					require PAR;
					PAR->import({ file => $file->stringify });

					last;
				}

				if ($file =~ /\.pm$/) {
					$loadModule = 1;
					last;
				}

				if ($file =~ /.*Plugins$/ && -d $file) {
					$loadModule = 1;
					last;
				}
			}

			unshift @INC, $lib;

			# allow plugins to include architecture specific modules
 			my $arch = $Config::Config{'archname'};
			$arch =~ s/^i[3456]86-/i386-/;
			$arch =~ s/gnu-//;

			my $perlmajorversion = $Config{'version'};
			$perlmajorversion =~ s/\.\d+$//;

			unshift @INC, catdir($lib, $perlmajorversion, $arch, 'auto') if -d catdir($lib, $perlmajorversion, $arch, 'auto');
			unshift @INC, catdir($lib, $perlmajorversion, $arch)         if -d catdir($lib, $perlmajorversion, $arch);
		}

		if (-f catdir($baseDir, 'Plugin.pm')) {

			$loadModule = 1;
		}

		# Pull in the module
		if ($loadModule && $module) {

			if (Slim::bootstrap::tryModuleLoad($module)) {

				logError("Couldn't load $module");

				$plugins->{$name}->{error} = 'INSTALLERROR_FAILED_TO_LOAD';

			} else {

				$loaded->{$module} = $plugins->{$name};
				
				delete $disabled->{$module};
			}
		}
		
		if ( main::SLIM_SERVICE ) {
			# no web stuff for SN
			next;
		}

		# Add any Bin dirs to findbin search path
		my $binDir = catdir($baseDir, 'Bin');

		if (-d $binDir) {

			main::DEBUGLOG && $log->debug("Adding Bin directory: [$binDir]");

			Slim::Utils::Misc::addFindBinPaths( catdir($binDir, Slim::Utils::OSDetect::details()->{'binArch'}), $binDir );
		}

		# Add any available HTML to TT's INCLUDE_PATH
		my $htmlDir = catdir($baseDir, 'HTML');

		if (-d $htmlDir) {

			main::DEBUGLOG && $log->debug("Adding HTML directory: [$htmlDir]");

			Slim::Web::HTTP::addTemplateDirectory($htmlDir);
		}
	}

	# Call init functions for all loaded plugins - multiple passes allows plugins to offer services to each other
	# - plugins offering service to other plugins use preinitPlugin to init themselves and postinitPlugin to start the service
	# - normal plugins use initPlugin and register with services offered by other plugins at this time

	for my $initFunction (qw(preinitPlugin initPlugin postinitPlugin)) {

		for my $module (sort keys %$loaded) {

			if ($module->can($initFunction)) {
				
				eval { $module->$initFunction };
				
				if ($@) {

					logWarning("Couldn't call $module->$initFunction: $@");
				}
			}
		}
	}

	# check for plugin updates
	if ($downloader) {
		$downloader->periodicCheckForUpdates;
	}
}

sub shutdownPlugins {
	my $class = shift;

	main::INFOLOG && $log->info("Shutting down plugins...");

	for my $module (sort keys %$loaded) {

		if ($module->can('shutdownPlugin')) {

			eval { $module->shutdownPlugin };

			if ($@) {
				logWarning("error running ${module}->shutdownPlugin: $@");
			}
		}
	}
}

sub dirsFor {
	my $class = shift;
	my $type  = shift;
	
	my @dirs = ();

	for my $name (keys %$plugins) {

		# FIXME: for the moment include strings for disabled plugins so the settings page works
		if ($type eq 'strings' || $prefs->get($name) eq 'enabled') {
			push @dirs, $plugins->{$name}->{'basedir'};
		}
	}
	
	return @dirs;
}

sub allPlugins {
	my $class = shift;

	return $plugins;
}

sub getErrorString {
	my $class = shift;
	my $plugin = shift;

	unless ($plugins->{$plugin}->{error} =~ /INSTALLERROR_SUCCESS|INSTALLERROR_NO_MODULE/) {

		return Slim::Utils::Strings::getString($plugins->{$plugin}->{error});
	}

	return '';
}

sub dataForPlugin {
	my $class  = shift;
	my $module = shift;

	if ($loaded->{$module}) {

		return $loaded->{$module};
	}

	return undef;
}

sub installedPlugins {
	my $class = shift;

	my @found = ();

	for my $plugin ( keys %{$plugins} ) {

		if ($plugins->{$plugin}->{error} =~ /INSTALLERROR_SUCCESS|INSTALLERROR_NO_MODULE/) {

			push @found, $plugin;
		}
	}

	return @found;
}

# this returns all plugins modules which are loaded (i.e. enabled and successfully loaded)
sub enabledPlugins {
	my $class = shift;

	return keys %$loaded;
}

# this returns all plugins which are disabled
sub disabledPlugins {
	my $class = shift;
	
	return $disabled;
}

# this returns plugins if a plugin module is currently loaded (i.e. enabled and successfully loaded)
sub isEnabled {
	my $class  = shift;
	my $module = shift || return;
	
	return $loaded->{$module};
}

sub isConfiguredEnabled {
	my $class = shift;
	my $plugin = shift;

    return $prefs->get($plugin) =~ /needs-enable|enabled/;
}

sub enablePlugin {
	my $class  = shift;
	my $plugin = shift;

	if ($prefs->get($plugin) ne 'enabled') {

		main::INFOLOG && $log->info("Setting plugin $plugin to state: needs-enable");

		$prefs->set($plugin, 'needs-enable');
	}
}

sub disablePlugin {
	my $class  = shift;
	my $plugin = shift;

	if ($plugins->{$plugin}->{enforce}) {

		$log->warn("Can't disable plugin: $plugin - 'enforce' set in install.xml");
		return;
	}

	if ($prefs->get($plugin) ne 'disabled') {

		main::INFOLOG && $log->info("Setting plugin $plugin to state: needs-disable");

		$prefs->set($plugin, 'needs-disable');
	}
}

sub needsRestart {
	return grep { /needs/ } values %{$prefs->all};
}

sub message {
	my $class = shift;

	$message = shift if @_;

	return ($class->needsRestart && Slim::Utils::Strings::string('PLUGINS_RESTART_MSG')) || $message;
}

sub _pluginCacheFile {
	my $class = shift;

	return catdir( preferences('server')->get('cachedir'), 'plugin-data.yaml' );
}

sub _writePluginCache {
	my $class = shift;
	
	if ( main::SLIM_SERVICE ) {
		# Don't bother with cache, assume all plugins are OK
		return;
	}

	main::INFOLOG && $log->info("Writing out plugin cache file.");

	# add the cacheinfo data
	$plugins->{'__cacheinfo'} = $cacheInfo;

	YAML::Syck::DumpFile($class->_pluginCacheFile, $plugins);

	delete $plugins->{'__cacheinfo'};
}

sub _loadPluginCache {
	my $class = shift;
	
	if ( main::SLIM_SERVICE ) {
		# Don't bother with cache, assume all plugins are OK
		return;
	}

	my $file = $class->_pluginCacheFile;

	if (!-r $file) {
		main::INFOLOG && $log->info("No plugin cache file.");
		return;
	}

	main::INFOLOG && $log->info("Loading plugin cache file.");

	$plugins = YAML::Syck::LoadFile($file);

	$cacheInfo = delete $plugins->{'__cacheinfo'} || { 
		'version' => -1,
	};

	if (main::DEBUGLOG && $log->is_debug) {
		$log->debug("Cache Info: " . Data::Dump::dump($cacheInfo) );
		for my $plugin (sort keys %{$plugins}){
			$log->debug("$plugin");
		}
	}
}

sub _findInstallManifests {
	my $class = shift;

	my $mtimesum = 0;
	my @files;

	# Only find plugins that have been installed.
	my $iter = File::Next::files({

		'file_filter' => sub {
			return 1 if /^install\.xml$/;
			return 0;
		},

	}, Slim::Utils::OSDetect::dirsFor('Plugins'));

	while ( my $file = $iter->() ) {

		$mtimesum += (stat($file))[9];
		push @files, $file;
	}

	return (\@files, $mtimesum);
}

sub _readInstallManifests {
	my $class = shift;
	my $files = shift;

	$plugins = {};

	for my $file (@{$files}) {

		my ($pluginName, $installManifest) = $class->_parseInstallManifest($file);

		if (!defined $pluginName) {
			next;
		}

		$plugins->{$pluginName} = $installManifest;
	}
}

sub _parseInstallManifest {
	my $class = shift;
	my $file  = shift;

	my $installManifest = eval { XMLin($file, SuppressEmpty => undef) };

	if ($@) {

		logWarning("Unable to parse XML in file [$file]: [$@]");

		return undef;
	}

	my $pluginName;

	my $module = $installManifest->{'module'};

	if ($module && $module =~ /^Plugins::(.*)::/) {

		$pluginName = $1;

	} elsif ($module && $module =~ /^Slim::Plugin::(.*)::/) {

		$pluginName = $1;

	} else {

		($pluginName) = $file =~ /.*[\/|\\](.*)[\/|\\]install.xml/;
	}

	if ( exists $SKIP{$pluginName} ) {
		# Disabled on SN
		return;
	}

	if (!defined $prefs->get($pluginName)) {

		my $state = delete $installManifest->{'defaultState'};

		if (ref $state eq 'HASH') {

			$state = $state->{ Slim::Utils::OSDetect::OS() } || $state->{ 'other' };
		}

		if ($state && $state eq 'disabled') {

			$prefs->set($pluginName, 'disabled');

		} else {

			$prefs->set($pluginName, 'enabled');
		}
	}

	$installManifest->{'basedir'} = dirname($file);

	if (!defined $module) {
		
		$installManifest->{'error'} = 'INSTALLERROR_NO_MODULE';

	} elsif (!$class->_checkPluginVersion($installManifest)) {

		$installManifest->{'error'} = 'INSTALLERROR_INVALID_VERSION';

	} else {

		# Check the OS matches
		my $osDetails    = Slim::Utils::OSDetect::details();
		my $osType       = $osDetails->{'os'};
		my $osArch       = $osDetails->{'osArch'};
		
		my $requireOS    = 0;
		my $matchingOS   = 0;
		my $requireArch  = 0;
		my $matchingArch = 0;
		my @platforms    = $installManifest->{'targetPlatform'} || ();
		
		if (ref($installManifest->{'targetPlatform'}) eq 'ARRAY') {
			
			@platforms = @{ $installManifest->{'targetPlatform'} };
		}
		
		for my $platform (@platforms) {
			
			$requireOS = 1;
			
			my ($targetOS, $targetArch) = split /-/, $platform;
			
			if ($osType =~ /$targetOS/i) {
				
				$matchingOS = 1;
				
				if ($targetArch) {
					
					$requireArch = 1;
					
					if ($osArch =~ /$targetArch/i) {
						
						$matchingArch = 1;
						last;
					}
				}
			}
		}
		
		if ($requireOS && (!$matchingOS || ($requireArch && !$matchingArch))) {
			
			$installManifest->{'error'} = 'INSTALLERROR_INCOMPATIBLE_PLATFORM';

			$log->warn("plugin $pluginName incompatible with system - disabling");

			$prefs->set($pluginName, 'disabled');
		}
	}

	$installManifest->{'error'} ||= 'INSTALLERROR_SUCCESS';

	main::DEBUGLOG && $log->debug("$pluginName [" . ($module || '') . "] " . Slim::Utils::Strings::getString($installManifest->{error}));

	return ($pluginName, $installManifest);
}

sub _checkPluginVersion {
	my ($class, $manifest) = @_;

	if (!$manifest->{'targetApplication'} || ref($manifest->{'targetApplication'}) ne 'HASH') {

		return 0;
	}

	my $min = $manifest->{'targetApplication'}->{'minVersion'};
	my $max = $manifest->{'targetApplication'}->{'maxVersion'};

	# Didn't match the version? Next..
	if (!Slim::Utils::Versions->checkVersion($::VERSION, $min, $max)) {

		return 0;
	}

	return 1;
}

sub _needsEnable {
	my $class = shift;
	my $plugin = shift;

	main::INFOLOG && $log->info("enabling $plugin");

	$prefs->set($plugin, 'enabled');
}

sub _needsDisable {
	my $class = shift;
	my $plugin = shift;

	main::INFOLOG && $log->info("disabling $plugin");

	$prefs->set($plugin, 'disabled');
}

sub _needsUninstall {
	my $class = shift;
	my $plugin = shift;

	if ($plugins->{$plugin}) {

		my $dir = $plugins->{$plugin}->{'basedir'};

		if (-d $dir && $dir =~ /InstalledPlugins/) {

			rmtree $dir;

			main::INFOLOG && $log->info("uninstalling $plugin from $dir");

		} else {

			$log->error("unable to uninstall plugin as not in InstalledPlugins dir - $dir");
		}

	} else {

		$log->error("unable to uninstall plugin $plugin - not in the cache");
	}

	$prefs->remove($plugin);
}

sub _needsInstall {
	my $class = shift;
	my $plugin = shift;

	if ($downloader) {

		$downloader->extract($plugin);

	} else {

		$log->error("unable to install $plugin - downloads disabled");
	}

	$prefs->remove($plugin);
}

1;

