#    Copyright (c) 2011 Raphaël Pinson.
#
#    This library is free software; you can redistribute it and/or
#    modify it under the terms of the GNU Lesser Public License as
#    published by the Free Software Foundation; either version 2.1 of
#    the License, or (at your option) any later version.
#
#    Config-Model 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
#    Lesser Public License for more details.
#
#    You should have received a copy of the GNU Lesser Public License
#    along with Config-Model; if not, write to the Free Software
#    Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
#    02110-1301 USA

package Config::Augeas::Exporter;
use strict;
use warnings;
use base qw(Class::Accessor);

use Config::Augeas qw(get match count_match);
use XML::LibXML;
use Encode qw(encode);
use YAML qw(Dump);
use JSON qw();
use File::Path qw(mkpath);

__PACKAGE__->mk_accessors(qw(to_xml to_hash to_yaml to_json from_xml));

our $VERSION = '0.5';

# Default values
my $PATH = '/files';


=head1 NAME

Config::Augeas::Exporter - Export the Augeas tree to various formats

=head1 SYNOPSIS

  use Config::Augeas::Exporter

  # Initiliaze
  my $aug = Config::Augeas::Exporter->new( root => $aug_root );

  # Export to XML
  my $doc = $aug->to_xml( 
    path => '/files/etc/fstab',
    exclude => ('#comment')
    );

  print $doc->toString;

  # Restore from XML
  open (my $fh, "<$file") 
    or die "E: Could not open $file: $!\n" ;
  my $doc = XML::LibXML->load_xml(IO => $fh);
  close $fh;

  my $aug = Config::Augeas::Exporter->new(root => $root);
  $aug->from_xml(xml => $doc);


=head1 DESCRIPTION

This module allows to export the Augeas tree to various formats and import back from these formats to the configuration files.

=head1 Constructor

=head1 new ( ... )

Creates a new Config::Augeas::Exporter object. Optional parameters are:

=over

=item augeas

A Config::Augeas object. If not provided, a new one will be created.

=item root

Use C<root> as the filesystem root.

=back

=cut


sub new {
   my $class = shift;
   my %options = @_;

   my $root = $options{root};
   $root ||= '';

   $class = ref $class || $class || __PACKAGE__;
   my $self = __PACKAGE__->SUPER::new();

   # Initiliaze Augeas if it wasn't passed
   my $aug = $options{augeas};
   unless($aug) {
      $aug = Config::Augeas->new(root => $root);
   }

   # Associate to object
   $self->{aug} = $aug;

   return $self;
}


=head1 Methods

=head2 to_xml( ... )

Export the Augeas tree to a XML::LibXML::Document object.

=over

=item path

C<path> is the Augeas path to export. If ommitted, it will default to '/files'.

=item exclude

A list of label patterns to exclude from the export.

=back

=cut


sub to_xml {
   my $self = shift;
   my %args = @_;

   my $path = $args{path};
   my @excludes = ($args{exclude});

   # Default path
   $path ||= $PATH;

   # Initialize XML document
   my $doc = XML::LibXML::Document->new('1.0', 'utf-8');

   # Get XML elements from augeas recursively
   my @file_elems = (node_to_xml($self, $path, \@excludes));

   # Add a files node for all file entries
   my $files = XML::LibXML::Element->new('files');
   map { $files->appendChild($_) } @file_elems;

   # Raise warning if no "file" node was found
   my @node_nodes = $files->findnodes("//node");
   my @file_nodes = $files->findnodes("//file");

   if ($#node_nodes >= 0 && $#file_nodes < 0) {
      warn "W: The XML export contains no file nodes.
W: You will not be able to import it back.\n";
   }

   # Add an error node for errors
   my @error_elems = (node_to_xml($self, '/augeas//error'));
   my $errors = XML::LibXML::Element->new('error');
   map { $errors->appendChild($_) } @error_elems;

   # Add an augeas node on top
   my $augeas = XML::LibXML::Element->new('augeas');
   $augeas->appendChild($files);
   $augeas->appendChild($errors);

   # Associate files node with document
   $doc->setDocumentElement($augeas);

   return $doc;
}


sub node_to_xml {
   my ($self, $path, $excludes, $check_is_file) = @_;

   # Default check is_file
   $check_is_file = 1 unless(defined($check_is_file));

   # Get label from path
   my $label = get_label($path);

   # Filter excludes
   return if exclude_match($label, $excludes);

   # Sanitize path for augeas requests
   $path = sanitize_path($path);

   my $aug = $self->{aug};
   my @children = $aug->match("$path/*");

   # Should children check is_file?
   my $children_check_is_file = $check_is_file;
   if ($check_is_file && is_file($self, $path)) {
      $children_check_is_file = 0;
   }

   # Parse children
   my @child_elems;
   for my $child (@children) {
      my @new_child_elems = (node_to_xml($self, $child, $excludes, $children_check_is_file));
      map { push @child_elems, $_ } @new_child_elems;
   }

   # Directories don't get their own node
   return @child_elems if ($check_is_file && $self->is_dir($path));

   # Files and entries get their own nodes
   my $elem;
   if ($check_is_file && is_file($self, $path)) {
      # Files get <file path="$path"> nodes
      $elem = XML::LibXML::Element->new('file');
      my $file_path = get_file_path($path);
      $elem->setAttribute("path", $file_path);
   } else {
      # Entries get <node label="$label"> nodes
      $elem = XML::LibXML::Element->new('node');
      $elem->setAttribute("label", $label);
   }

   # Append children to element
   map { $elem->appendChild($_) } @child_elems;

   # Add value to element
   my $value = $aug->get($path);
   $elem->appendTextNode(encode('utf-8', $value)) if defined($value);

   return $elem;
}


=head2 to_hash( ... )

Export the Augeas tree to a hash.

=over

=item path

C<path> is the Augeas path to export. If ommitted, it will default to '/files'.

=item exclude

A list of label patterns to exclude from the export.

=back

=cut


sub to_hash {
   my $self = shift;
   my %args = @_;

   my $path = $args{path};
   my @excludes = ($args{exclude});

   # Default path
   $path ||= $PATH;

   my @file_elems = (node_to_hash($self, $path, \@excludes));

   my %hash = (
      files => \@file_elems,
      );

   return \%hash;
}


sub node_to_hash {
   my ($self, $path, $excludes) = @_;

   # Get label from path
   my $label = get_label($path);

   # Filter excludes
   return if exclude_match($label, $excludes);

   # Sanitize path for augeas requests
   $path = sanitize_path($path);

   my $aug = $self->{aug};
   my @children = $aug->match("$path/*");

   # Parse children
   my @child_elems;
   for my $child (@children) {
      my @new_child_elems = (node_to_hash($self, $child, $excludes));
      map { push @child_elems, $_ } @new_child_elems;
   }

   # Directories don't get their own node
   return @child_elems if ($self->is_dir($path));

   # Initialize array
   my %hash;
   my $node_hash;

   if (is_file($self, $path)) {
      my $file_path = get_file_path($path);
      $node_hash = \%{$hash{$file_path}};
   } else {
      $node_hash = \%{$hash{$label}};
   }

   # Append children
   map { push @{$node_hash->{children}}, $_ } @child_elems;

   # Add value to element
   my $value = $aug->get($path);
   $node_hash->{value} = $value if defined($value);

   return \%hash;
}


=head2 to_yaml( ... )

Export the Augeas tree to YAML.

=over

=item path

C<path> is the Augeas path to export. If ommitted, it will default to '/files'.

=item exclude

A list of label patterns to exclude from the export.

=back

=cut

sub to_yaml {
   my $self = shift;
   my %args = @_;

   my $hash = $self->to_hash(%args);

   return YAML::Dump($hash);
}


=head2 to_json( ... )

Export the Augeas tree to JSON.

=over

=item path

C<path> is the Augeas path to export. If ommitted, it will default to '/files'.

=item exclude

A list of label patterns to exclude from the export.

=back

=cut

sub to_json {
   my $self = shift;
   my %args = @_;

   my $hash = $self->to_hash(%args);

   my $json = new JSON;
   return $json->encode($hash);
}


=head2 from_xml( ... )

Restore the Augeas tree from an XML::LibXML::Document object.
This method considers the files listed in the XML document,
and replaces the corresponding files in the Augeas tree with
the contents of the XML.

=over

=item xml

The XML::LibXML::Document to use as source for import.

=item create_dirs

Boolean value, whether to create the directories if missing.

=back

=cut


sub from_xml {
   my $self = shift;
   my %args = @_;

   die "E: No XML provided." unless(defined($args{xml}));

   my $xml = $args{xml};
   my $create_dirs = $args{create_dirs};
   my $aug = $self->{aug};

   my @files = $xml->find('/augeas/files/file')->get_nodelist();

   if ($#files < 0) {
      warn "W: The XML document contains no file to restore.";
      return;
   }

   # Get augeas root to create directories
   my $aug_root = $aug->get('/augeas/root');
   die "E: Could not determine Augeas root needed to create directories."
      if ($create_dirs && !defined($aug_root));

   # Add each file to the Augeas tree
   for my $file (@files) {
      my $path = $file->getAttribute('path');

      # Create directories if requested
      if($create_dirs) {
         if ($path =~ m|^(.*)/([^/]+)$|) {
           my $dir = "${aug_root}${1}";
           unless (-d $dir) {
              mkpath($dir) or die "E: Failed to create directory $dir: $!";
           }
         } else {
            die "E: Could not get directory from file path $path.";
         }
      }

      my $aug_path = "/files${path}";
      # Clean the Augeas tree for this file
      $aug->rm($aug_path);

      for my $node ($file->childNodes) {
         $self->xml_to_node($node, $aug_path);
      }
   }

   $aug->save;
   $aug->print('/augeas//error');
}


sub xml_to_node {
   my ($self, $elem, $path) = @_;

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

   my $type = $elem->nodeType;
   my $label = $elem->getAttribute('label');

   my $matchpath = "$path/*[last()]";
   $matchpath = sanitize_path($matchpath);
   my $lastpath = sanitize_path($aug->match("$path/*[last()]"));

   if(defined($lastpath)) {
      # Insert last node
      $aug->insert($label, "after", $lastpath);
   } else {
      $aug->set(sanitize_path("$path/$label"), '');
   }

   $matchpath = sanitize_path("${path}/${label}[last()]");
   my $newpath = sanitize_path($aug->match($matchpath));

   my $value;

   for my $child ($elem->childNodes()) {
      if ($child->nodeType == XML_TEXT_NODE) {
         # Text node
         $value = $child->nodeValue;
      } else {
         $self->xml_to_node($child, $newpath);
      }
   }

   if (defined($value)) {
      $aug->set($newpath, $value);
   }
}


##############
# Useful subs
##############

sub get_file_path {
   my ($path) = @_;

   my $file_path = $path;
   $file_path =~ s|^/files||;
   
   return $file_path;
}


sub get_label {
   my ($path) = @_;

   # Get label from path
   my $label = '';

   if ($path =~ m|.*/([^/\[]+)(\[\d+\])?|) {
      $label = $1;
   } else {
      die "E: Could not parse $path\n";
   }

   return $label;
}


sub exclude_match {
   my ($label, $excludes) = @_;

   # Filter excludes
   for my $exclude (@$excludes) {
      if ($exclude && $label =~ /$exclude/) {
         return 1;
      }
   }

   return 0;
}


sub sanitize_path {
   my ($path) = @_;

   return unless ($path);

   # Sanitize path for augeas requests
   $path =~ s|(?<=[^\\]) |\\ |g;

   return $path;
}


sub is_file {
   my ($self, $path) = @_;

   my $aug_path = "/augeas${path}/path";
   my $aug = $self->{aug};
   my $count = $aug->count_match($aug_path);

   # Not a file if there is no path subnode
   return 0 if ($count == 0);
   # Check that the subnode has the right value
   return 1 if ($aug->get($aug_path) eq $path);
   # Otherwise it's not a file
   return 0;
}


sub is_dir {
   my ($self, $path) = @_;

   my $aug_path = "/augeas${path}";
   my $aug = $self->{aug};
   my $count = $aug->count_match($aug_path);
   my $value = $aug->get($aug_path);

   # A directory is not a file
   # but its path must exist in /augeas
   # and have no value associated to it
   return 1 if (!$self->is_file($path) && $count == 1
                                       && !defined($value));
   # Otherwise it's not a directory
   return 0;
}


__END__


=head1 SEE ALSO

=over

=item *

L<Config::Augeas> : The Config::Augeas module

=item * 

http://augeas.net/ : The Augeas project page

=back

=head1 AUTHOR

RaphaE<euml>l Pinson, E<lt>raphink at cpan dot orgE<gt>

=head1 CONTRIBUTING

This module is developed on Launchpad at:

L<https://launchpad.net/config-augeas-exporter> 

Feel free to fork the repository and submit pull requests

=head1 COPYRIGHT AND LICENSE

Copyright (C) 2011 by RaphaE<euml>l Pinson

This library is free software; you can redistribute it and/or modify
it under the LGPL terms.

=cut


