#!/usr/bin/perl
#
# This file is part of Config-Model
#
# This software is Copyright (c) 2013 by Dominique Dumont, Krzysztof Tyszecki.
#
# This is free software, licensed under:
#
#   The GNU Lesser General Public License, Version 2.1, February 1999
#

use strict;
use warnings;
use 5.10.1;

use Config::Model;
use Config::Model::Lister;
use Config::Model::ObjTreeScanner;
use Getopt::Long;
use Pod::Usage;
use Log::Log4perl qw(get_logger :levels);
use File::Slurp qw/slurp/;
use Path::Class ;
use POSIX qw/setsid/;
use AnyEvent ; # why AnyEvent ? See comment below around Tk UI

my $log4perl_syst_conf_file = '/etc/log4config-model.conf';
my $log4perl_user_conf_file = $ENV{HOME} . '/.log4config-model';
my $fallback_conf           = << 'EOC';
log4perl.logger=WARN, Screen
log4perl.appender.Screen        = Log::Log4perl::Appender::Screen
log4perl.appender.Screen.stderr = 0
log4perl.appender.Screen.layout = Log::Log4perl::Layout::PatternLayout
log4perl.appender.Screen.layout.ConversionPattern = %d %m %n
EOC

my $log4perl_conf =
    -e $log4perl_user_conf_file ? $log4perl_user_conf_file
  : -e $log4perl_syst_conf_file ? $log4perl_syst_conf_file
  :                               \$fallback_conf;

Log::Log4perl::init($log4perl_conf);

my $ui_type;

my $model_dir;
my $trace = 0;
my $root_dir;

my $man        = 0;
my $help       = 0;
my $force_load = 0;
my $dev        = 0;
my $backend;
my $experience = 'beginner';
my $dumptype;
my $load;
my @fix_from;
my $fix_filter ;
my $force_save  = 0;
my $open_item   = '';
my $fuse_dir;
my $fuse_debug  = 0;
my $apply_fixes = 0;
my $search;
my $search_type = 'all';
my $dir_char_mockup ;
my $try_application_as_model = 0; # means search a model instead of an application name


my %command_option = (
    list    => [],
    check   => [],
    migrate => [],
    fix     => [
        "from=s" => \@fix_from ,
        "filter=s" => \$fix_filter ,
    ],
    modify  => [],
    search  => [ 
        "search=s"                      => \$search,
        "narrow-search=s"               => \$search_type, 
    ],
    edit    => [
        "ui|if=s"               => \$ui_type,
        "open_item|open-item=s" => \$open_item,
    ],
    dump   => [ "dumptype:s" => \$dumptype, ],
    fusefs => [
        "fuse_dir|fuse-dir=s" => \$fuse_dir,
        "dfuse!"              => \$fuse_debug,
        "dir-char=s"          => \$dir_char_mockup ,
    ],
);

my @global_options = (
    "model_dir|model-dir=s"         => \$model_dir,
    "try-app-as-model!"             => \$try_application_as_model,
    "experience=s"                  => \$experience,
    "dev!"                          => \$dev,
    "force_load|force-load!"        => \$force_load,
    "root_dir|root-dir=s"           => \$root_dir,
    "backend=s"                     => \$backend,
    "stack-trace|trace!"            => \$trace,
    );

# retrieve the main command, i.e. the first arg without leading dash
my ($command) = grep { ! /^-/ } @ARGV ;

pod2usage(-message => 'no command specified', -verbose => 0) 
    unless defined $command ;

pod2usage( -verbose => 1 ) if $command =~ /help/;
pod2usage( -verbose => 2 ) if $command =~ /man/;

# scan lib/Config/Model/extensions dir to find cme-* subcommands 
my @dir_to_scan = @INC ;
my %extension_path;
foreach my $inc ( @dir_to_scan ) {
    my $dir = dir("$inc/Config/Model/extensions/") ;
    next unless -d $dir ;
    foreach my $ext ($dir ->children ) {
        my $cmd = substr $ext->basename, 4 ;

        # don't clobber commands found before in @INC
        next if $extension_path{$cmd} ; 

        $command_option{$cmd} = [] ;
        $extension_path{$cmd} = $ext ;
    }
}

# run extention if the command matches one extension
if (my $path = $extension_path{$command}){
    say "doing $path";
    do $path; # thus @INC and @ARGV  are still valid
    exit ;
}

my $cmd_options =  $command_option{$command} 
    || pod2usage(-message => "unknown command: $command", -verbose => 0) ;

my $result = GetOptions( @global_options, @$cmd_options);

pod2usage(-verbose => 0) if not $result;

# now @ARGV should be $command, $application, [ $config_file ]
shift @ARGV;
my $application = shift @ARGV ;

# ignore $dev if run as root
if ( $> and $dev ) {
    unshift @INC, 'lib';
    $model_dir = 'lib/Config/Model/models/';
    warn "-dev option is ignored when run as root\n";
}

Config::Model::Exception::Any->Trace(1) if $trace;

if ( defined $root_dir && !-e $root_dir ) {
    mkdir $root_dir, 0755 || die "can't create $root_dir:$!";
}

my $model = Config::Model->new( model_dir => $model_dir );

my ( $categories, $appli_info, $appli_map ) =
  Config::Model::Lister::available_models;

my $root_model = $appli_map->{$application};
$root_model ||= $application if $try_application_as_model ;

if (not defined $root_model) {
  warn "Unknown application: $application\n";
}

if ( not defined $root_model or $command eq 'list') {
    print "The following applications are available:\n";
    foreach my $cat ( keys %$categories ) {
        my $names = $categories->{$cat} || [];
        next unless @$names;
        print "$cat:\n\t", join( "\n\t", @$names ), "\n";
    }
    exit 1;
}

my $config_file ;
$config_file = shift @ARGV if $appli_info->{$application}{require_config_file} ;

my $inst = $model->instance(
    root_class_name => $root_model,
    instance_name   => $application,
    root_dir        => $root_dir,
    check           => $force_load ? 'no' : 'yes',
    skip_read       => $load ? 1 : 0,
    backend         => $backend,
    config_file     => $config_file,
);

my $root = $inst->config_root;

if ( $command eq  'dump' ) {
    my $dump_string = $root->dump_tree( mode => $dumptype || 'custom' );
    print $dump_string ;
}
elsif ( $command eq 'check' ) {
    say "loading data";
    Config::Model::ObjTreeScanner->new(leaf_cb => sub{})->scan_node(undef, $root) ;
    say "checking data";
    $root->dump_tree( mode => 'full' );
    say "check done";
}
elsif ( $command eq 'search' ) {
    pod2usage(-message => "missing -search option with search command" )
        unless defined $search;
    my @res = $root->tree_searcher( type => $search_type )->search($search);
    foreach my $path ( @res ) {
        print "$path";
        my $obj = $root->grab($path);
        if ( $obj->get_type =~ /leaf|check_list/ ) {
            my $v = $obj->fetch;
            $v = defined $v ? $v : '<undef>';
            print " -> '$v'";
        }
        print "\n";
    }
}
elsif ($command eq 'migrate') {
    $force_save = 1;
    $root->migrate;
    
}
elsif ($command eq 'fix') {
    @fix_from = ('') unless @fix_from ;
    foreach my $path (@fix_from) {
        my $node_to_fix = $inst->config_root->grab($path) ;
        say "Fixing from ",$node_to_fix->name,"..." ;
        $node_to_fix->apply_fixes($fix_filter);
    }
    $force_save = 1;
}
elsif ( $command eq 'modify' ) {
    $root->load("@ARGV");
    $force_save = 1;
}
elsif ( $command =~ /^fuse/ ) {
    eval { require Config::Model::FuseUI; };
    my $has_fuse = $@ ? 0 : 1;

    die "could not load Config::Model::FuseUI. Is Fuse installed ?\n"
      unless $has_fuse;
    die "Missing -fuse_dir option\n" unless defined $fuse_dir;
    die "Directory $fuse_dir does not exists\n" unless -d $fuse_dir;

    my @extra ;
    push @extra, dir_char_mockup => $dir_char_mockup if  $dir_char_mockup ;
    my $ui = Config::Model::FuseUI->new(
        root       => $root,
        mountpoint => "$fuse_dir",
        @extra ,
    );

    print "Mounting config on $fuse_dir in background.\n",
        "Use command 'fusermount -u $fuse_dir' to unmount\n";

    # now fork
    my $pid = fork;

    if ( defined $pid and $pid == 0 ) {

        # child process, just run fuse and wait for exit
        $ui->run_loop( debug => $fuse_debug );
        $force_save = 1;
    }
    else {
        exit;    # don't save data in parent process
    }
}
elsif ( $command eq 'edit' ) {
    eval { require Config::Model::TkUI; };
    my $has_tk = $@ ? 0 : 1;

    eval { require Config::Model::CursesUI; };
    my $has_curses = $@ ? 0 : 1;

    if (not defined $ui_type ) {
        if ($has_tk) {
            $ui_type = 'tk';
        }
        elsif ($has_curses) {
            warn "You should install Config::Model::TkUI for a ",
                "more friendly user interface\n";
            $ui_type = 'curses';
        }
        else {
            warn "You should install Config::Model::TkUI or ",
                "Config::Model::CursesUI ",
                "for a more friendly user interface\n";
            $ui_type = 'shell';
        }
    }

    if ( $ui_type eq 'simple' ) {

        # experience not yet implemented
        require Config::Model::SimpleUI;
        my $shell_ui = Config::Model::SimpleUI->new(
            root   => $root,
            title  => $root_model . ' configuration',
            prompt => ' >',
        );

        # engage in user interaction
        $shell_ui->run_loop;
    }
    elsif ( $ui_type eq 'shell' ) {

        # experience not yet implemented
        require Config::Model::TermUI;
        my $shell_ui = Config::Model::TermUI->new(
            root   => $root,
            title  => $root_model . ' configuration',
            prompt => ' >',
        );

        # engage in user interaction
        $shell_ui->run_loop;
    }
    elsif ( $ui_type eq 'curses' ) {
        die "cannot run curses interface: ",
            "Config::Model::CursesUI is not installed\n"
          unless $has_curses;
        my $err_file = '/tmp/cme-error.log';

        print "In case of error, check $err_file\n";

        open( FH, "> $err_file" ) || die "Can't open $err_file: $!";
        open STDERR, ">&FH";

        my $dialog = Config::Model::CursesUI->new( experience => $experience, );

        # engage in user interaction
        $dialog->start($model);

        close FH;
    }
    elsif ( $ui_type eq 'tk' ) {
        die "cannot run Tk interface: Config::Model::CursesUI is not installed\n"
          unless $has_tk;

        require Tk;
        require Tk::ErrorDialog;
        Tk->import;
        
        # BLEEUAARGHHH. 
        # Currently AnyEvent is loaded and called only within Debian::Dpkg backend
        # and C::M::Debian::Dependency. When called in a Tk environment
        # AnyEvent creates its own TK mw which may be along the one created below
        # this require make sure that AnyEvent Tk mw is created and used for Tk application 
        require AnyEvent::Impl::Tk ;
        # without that, C::M::TkUI::quit (which just destroys one $mw, not 2) is not enough
        # to end the program :-( 

        no warnings 'once' ;
        my $mw = $AnyEvent::Impl::Tk::mw ;#|| MainWindow->new;
        # $mw->withdraw;

        # Thanks to Jerome Quelin for the tip
        $mw->optionAdd( '*BorderWidth' => 1 );

        my $cmu = $mw->ConfigModelUI( 
            -root => $root, 
            -experience => $experience 
        );

        if ($open_item) {
            my $obj = $root->grab($open_item);
            $cmu->force_element_display($obj);
        }

        &MainLoop;    # Tk's
    }
    elsif ( $ui_type =~ /^no/i ) {

        # trigger a dump to load all sub-models
        my $dump = $root->dump_tree;
        $force_save = 1 if $apply_fixes or $load or @ARGV;
    }
    else {
        die "Unsupported user interface: $ui_type";
    }
}
else {
    die "Looks like the author forgot to implement $command. Bad author, bad.";
}

if ($force_save) {
    my @changes = $inst->list_changes ;
    say "\n",join( "\n- ","Changes:", @changes ) if @changes;
    $inst->write_back  ;
}
exit 0;

__END__

=pod

=head1 NAME

cme - Edit data of configuration managed by Config::Model

=head1 SYNOPSIS

  # general synopsis
  cme [ global_options ] command application [ options ] arguments

  # edit dpkg config with GUI
  cme edit dpkg 

  # edit /etc/sshd_config (requires Config::Model::OpenSsh)
  sudo cme edit sshd
  
  # edit ~/.ssh/config (requires Config::Model::OpenSsh)
  cme edit ssh
  
  # just check the validity of a file 
  cme check multistrap file.conf 
  
  # check dpkg files, update deprecated parameters and save
  cme migrate dpkg 
  
  # like migrate, but also apply all suggested fixes
  cme fix dpkg 
  
  # modify configuration with command line
  cme modify dpkg source 'format="quilt (3.0)"'

  # edit a file (file name specification is mandatory here)
  cme edit multistrap my.conf 

  # map conf data to a fuse file system
  cme fusefs multistrap my.conf -d fuse_dir
  
  # likewise for dpkg data
  cme fusefs dpkg -d fuse_dir
  
  # list all available applications (depends on your installation)
  cme list 
  

=head1 DESCRIPTION

C<cme> program will use Config::Model configuration
descriptions to check or modify or fix configuration files.

=head1 Commands

=head2 list

Show a list all applications where a model is available. This list depends on 
installed Config::Model modules.

=head2 edit

Edit a configuration. By default, a Tk GUI will be opened If L<Config::Model::TkUI> is
installed. You can choose another user interface with the C<-ui> option:

=over

=item *

C<tk>: provides a Tk graphical interface (If L<Config::Model::TkUI> is
installed).

=item *

C<curses>: provides a curses user interface (If
L<Config::Model::CursesUI> is installed).

=item *

C<shell>: provides a shell like interface.  See L<Config::Model::TermUI>
for details.

=back

=head2 check

Checks the content of the configuration file of an application. Prints warnings
and errors on STDOUT.

Example:

 cme check fstab

=head2 migrate

Checks the content of the configuration file of an application, update deprecated
(old value are saved to new parameters) and save the new configuration.  

For more details, see L<Config::Model::Value/Upgrade>

=head2 fix

Like C<migrate> and, when possible, fix the warnings of a configuration. Options are:

=over 

=item from

Use option C<-from> to fix only a subset of a configuration tree. Example:

 cme fix dpkg -from 'control binary:foo Depends'

This option can be repeated:

 cme fix dpkg -from 'control binary:foo Depends' -from 'control source Build-Depends'

=item filter

Filter the leaf according to a pattern. The pattern is applied to the element name to be fixed
Example:

 cme fix dpkg -from control -filter Build # will fix all Build-Depends and Build-Depend-Indep

or 

 cme fix dpkg -filter Depend 

=back

=head2 modify

Modify a configuration file with the values passed on the command line.
These command must follow the syntax defined in L<Config::Model::Loader>.

Example:

   cme modify dpkg source format="quilt (3.0)"
   cme modify multistrap my_mstrap.conf sections:base source="http://ftp.fr.debian.org"

=head2 search

You can search the configuration with the following options

=over 

=item -search

Specifies a string or pattern to search. C<cme> will a list of path pointing 
to the matching tree element and their value. 
See L<Config::Model::AnyThing/grab(...)> for details
on the path syntax.

=item -narrow-search

Narrows down the search to:

=over 

=item element 

=item value 

=item key 

=item summary 

Summary text

=item description

description text 

=item help

value help text

=back 

=back 

Example:

 $ cme search multistrap my_mstrap.conf -s http -narrow value
 sections:base source -> 'http://ftp.fr.debian.org'
 sections:debian source -> 'http://ftp.uk.debian.org/debian'
 sections:toolchains source -> 'http://www.emdebian.org/debian'

=head2 dump

Dump configuration content on STDOUT with Config::Model syntax.

By default, dump only custom values, i.e. different from application
built-in values or model default values. You can use the C<-dumptype> option for
other types of dump:

 -dumptype [ full | preset | custom ]

Choose to dump every values (full), only preset values or only
customized values (default)

=head2 fusefs 

Map the configuration file content to a FUSE virtual file system on a
directory specified with option C<-fuse-dir>. To stop (and write the
configuration data back to the configuration file), run C<< fusermount
-u <mounted_fuse_dir> >>.

Options:

=over

=item -fuse-dir

Mandatory. Directory where the virtual file system will be mounted.

=item -dfuse

Use this option to debug fuse problems.

=item -dir-char

Fuse will fail if an element name or key name contains '/'. You can specify a 
subsitution string to replace '/' in the fused dir. Default is C<< <slash> >>.

=back

=head1 Global options

The following options are available for all commands:

=over

=item -experience

Change the experience level. By default only parameters with "beginner" experience
are shown or modifiable. You can choose C<advanced> or C<master> level to get 
access to more parameters. Note that experience level can be changed in the GUI with the 
I<options> menu.

=item -force-load

Load file even if error are found in data. Bad data are discarded

=item -dev

Use this option if you want to test a model under development. This
option will add C<lib> in C<@INC> and use C<lib/Config/Model/models>
as model directory. This option is ignored when run as root.

=item -model-dir

Specify an alternate directory to find model files. Mostly useful for
tests.

=item -root-dir

Specify a pseudo root directory to read and write the configuration
files. (Actual default directory and file names depends on the model
(See C<-model> option). For instance, if you specify C<~/mytest>, the
C</etc/ssh/sshd_config> files will be written in C<~/mytest/etc/ssh/>
directory.

=item -stack-trace

Provides a full stack trace when exiting on error.

=item -backend

Specify a read/write backend. The actual backend name depends on the model
passed to C<-model> option. See L<Config::Model::BackendMgr> for details.

=item -try-app-as-model

When set, try to load a model using directly the application name specified as 3rd parameter
on the command line. Experimental.

=back

=head1 Embedding cme

You can use cme from another program by using C<-ui simple>
option. This way you will be able to send command on the standard input
of C<cme> and get the results from the standard output.

=head1 cme extensions

New C<cme> subcommands can be placed in extensions directory (i.e. in
C<Config/Model/extensions>. When a cme command matched one of this extension,
the extension will be run. For instance, if you have:

 .../Config/Model/extensions/cme-foo
 
Running C<cme foo> will run this program (who must be written in Perl) with 
the same arguments as C<cme>.

=head1 Logging

All Config::Model logging is now based on L<Log::Log4perl>. 
Logging can be configured in the following files:

=over

=item *

 ~/.log4config-model

=item * 

 /etc/log4config-model.conf

=back

Without these files, the following Log4perl config is used:

 log4perl.logger=WARN, Screen
 log4perl.appender.Screen        = Log::Log4perl::Appender::Screen
 log4perl.appender.Screen.stderr = 0
 log4perl.appender.Screen.layout = Log::Log4perl::Layout::PatternLayout
 log4perl.appender.Screen.layout.ConversionPattern = %d %m %n

Log4perl uses the following categories:

=over

=item Anything

=item Anything::Change

Trace change notification through configuration tree and instance.

=item Backend

=item Backend::Debian::Dpkg

=item Backend::Debian::Dpkg::Control

=item Backend::Debian::Dpkg::Copyright

=item Backend::Fstab

=item Backend::IniFile

=item Backend::PlainFile

=item Backend::ShellVar

=item Backend::Yaml

=item FuseUI

=item Instance

=item Loader

=item Model::Searcher

=item Tree::Element::CheckList

=item Tree::Element::Id

=item Tree::Element::Id::Hash

=item Tree::Element::Id::List

=item Tree::Element::Value

=item Tree::Element::Value::Dependency

=item Tree::Node

=item Tree::Node::Warped

=item ValueComputer

=item Warper

=item Wizard::Helper

=item Model


=back

More categories will come.

=head1 BUGS

=head2 Configuration models can lag behind the target application

If a configuration model is not up-to-date, you will get errors complaining about
unknown parameters. In such a case, please file a bug on 
L<request tracked|http://rt.cpan.org/> or fix the model and send a pull request. 
You can see this 
L<example from OpenSsh|https://github.com/dod38fr/config-model/wiki/New-parameter-for-openssh-example>
to learn how to fix a model.

=head1 SUPPORT

For support, please check the following resources:

=over

=item *

The config-model users mailing list:

 config-model-users at lists.sourceforge.net

=item *

The config-model wiki: L<http://github.com/dod38fr/config-model/wiki>

=back

=head1 FEEDBACKS

Feedback from users are highly desired. If you find this module useful, please
share your use cases, success stories with the author or with the config-model-
users mailing list. 

=head1 AUTHOR

Dominique Dumont, ddumont at cpan dot org

=head1 SEE ALSO

L<Config::Model::Model>, 
L<Config::Model::Instance>, 
L<Config::Model::Node>, 
L<Config::Model::HashId>,
L<Config::Model::ListId>,
L<Config::Model::WarpedNode>,
L<Config::Model::Value>

=cut
