# This program 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 2
# of the License, or (at your option) any later version. For
# more details read LICENSE in the root of this distribution.
#
# This program 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.
#
# As per the GPL, removal of this notice is prohibited.

=pod

---+ package Foswiki::Templates

Support for the Foswiki template language.

=cut

=pod

The following tokens are supported by this language:

| %<nop>TMPL:P% | Instantiates a previously defined template |
| %<nop>TMPL:DEF% | Opens a template definition |
| %<nop>TMPL:END% | Closes a template definition |
| %<nop>TMPL:INCLUDE% | Includes another file of templates |

Note; the template cache does not get reset during initialisation, so
the haveTemplate test will return true if a template was loaded during
a previous run when used with mod_perl or speedycgi. Frustrating for
the template author, but they just have to switch off
the accelerators during development.

This is to all intents and purposes a singleton object. It could
easily be coverted into a true singleton (template manager).

=cut

package Foswiki::Templates;

use strict;
use Assert;

require Foswiki::Attrs;

=pod

---++ ClassMethod new ( $session )

Constructor. Creates a new template database object.
   * $session - session (Foswiki) object

=cut

sub new {
    my ( $class, $session ) = @_;
    my $this = bless( { session => $session }, $class );

    $this->{VARS} = { sep => ' | ' };

    return $this;
}

=begin TML

---++ ObjectMethod finish()
Break circular references.

=cut

# Note to developers; please undef *all* fields in the object explicitly,
# whether they are references or not. That way this method is "golden
# documentation" of the live fields in the object.
sub finish {
    my $this = shift;
    undef $this->{VARS};
	undef $this->{EXPANDED};
	undef $this->{TEMPLATES};
    undef $this->{session};
}

=pod

---++ ObjectMethod haveTemplate( $name ) -> $boolean

Return true if the template exists and is loaded into the cache

=cut

sub haveTemplate {
    my ( $this, $template ) = @_;

    return exists( $this->{VARS}->{$template} );
}

# Expand only simple templates that can be expanded statically.
# Templates with conditions can only be expanded after the
# context is fully known.
sub _expandTrivialTemplate {
    my( $this, $text ) = @_;

    $text =~ /%TMPL\:P{(.*)}%/;
    my $attrs = new Foswiki::Attrs( $1 );
    # Can't expand context-dependant templates
    return $text if ( $attrs->{context} );
    return $this->tmplP( $attrs );
}

=pod

---++ ObjectMethod expandTemplate( $params ) -> $string

Expand the template specified in the parameter string using =tmplP=.

Examples:
<verbatim>
$tmpls->expandTemplate('"blah");
$tmpls->expandTemplate('context="view" then="sigh" else="humph"');

=cut

sub expandTemplate {
    my( $this, $params ) = @_;

    my $attrs = new Foswiki::Attrs( $params );
    my $value = $this->tmplP( $attrs );
    return $value;
}

=pod

---+ ObjectMethod tmplP( $attrs ) -> $string

Return value expanded text of the template, as found from looking
in the register of template definitions. The attrs can contain a template
name in _DEFAULT, and / or =context=, =then= and =else= values.

Recursively expands any contained TMPL:P tags.

Note that it would be trivial to add template parameters to this,
simply by iterating over the other parameters (other than _DEFAULT, context,
then and else) and doing a s/// in the template for that parameter value. This
would add considerably to the power of templates. There is already code
to do this in the MacrosPlugin.

=cut

sub tmplP {
    my( $this, $params ) = @_;

    my $template = $params->remove('_DEFAULT') || '';
    my $context = $params->remove('context');
    my $then = $params->remove('then');
    my $else = $params->remove('else');
    if( $context ) {
        $template = $then if defined( $then );
        foreach my $id ( split( /\,\s*/, $context )) {
            unless( $this->{session}->{context}->{$id} ) {
                $template = ( $else || '' );
                last;
            }
        }
    }

    return '' unless $template;

    my $val = '';
    if( exists($this->{TEMPLATES}->{$template} )) {
        $val = $this->{TEMPLATES}->{$template};
        foreach my $p ( keys %$params ) {
            if( $p eq 'then' || $p eq 'else' ) {
                $val =~ s/%$p%/$this->expandTemplate($1)/ge;
            } elsif( defined( $params->{$p} )) {
                $val =~ s/%$p%/$params->{$p}/ge;
            }
        }
        $val =~ s/%TMPL:P{(.*?)}%/$this->expandTemplate($1)/ge;
		#my $expanded = 0;
		$this->{EXPANDED}->{$template} = 1;
		#print STDERR $template." expanded\n";
    }

    return $val;
}

sub saveTemplateToCache {
    my( $this, $name, $skins, $web, $tmplText ) = @_;
    $skins = '' unless (defined($skins));
    $web = '' unless (defined($web));
    
    my $tmpl_cachedir = $Foswiki::cfg{TemplateDir}.'_filecache';
    mkdir($tmpl_cachedir) unless (-e $tmpl_cachedir);
    my $filename =  Foswiki::Sandbox::untaintUnchecked($tmpl_cachedir.'/'.$name.'__'.$skins.'__'.$web.'.tmpl');
    
    unless ( open( FILE, ">$filename" ) )  {
        die "Can't create file $filename - $!\n";
    }
    print FILE $tmplText;
    close( FILE);
}

sub getTemplateFromCache {
    my( $this, $name, $skins, $web ) = @_;
    $skins = '' unless (defined($skins));
    $web = '' unless (defined($web));

    my $tmpl_cachedir = $Foswiki::cfg{TemplateDir}.'_filecache';
    mkdir($tmpl_cachedir) unless (-e $tmpl_cachedir);
    my $filename =  Foswiki::Sandbox::untaintUnchecked($tmpl_cachedir.'/'.$name.'__'.$skins.'__'.$web.'.tmpl');
    
    if (-e $filename) {
        open( IN_FILE, "<$filename" ) || return;
        local $/ = undef; # set to read to EOF
        my $data = <IN_FILE>;
        close( IN_FILE );
        return $data;
    }
}

sub parseTemplate {
	my ($this, $text, $fromCache) = @_;
    my $result = '';
    my $key  = '';
    my $val  = '';
    my $delim = '';
	$fromCache ||= 0;
	
    foreach( split( /(%TMPL\:)/, $text ) ) {
        if( /^(%TMPL\:)$/ ) {
            $delim = $1;
        } elsif( ( /^DEF{[\s\"]*(.*?)[\"\s]*}%(.*)/s ) && ( $1 ) ) {
            # handle %TMPL:DEF{key}%
            if( $key ) {
                $this->{TEMPLATES}->{$key} = $val;
				$this->{EXPANDED}->{$key} = $fromCache;
            }
            $key = $1;
            $val = $2;

        } elsif( /^END%[\n\r]*(.*)/s ) {
            # handle %TMPL:END%
            $this->{TEMPLATES}->{$key} = $val;
			$this->{EXPANDED}->{$key} = $fromCache;
            $key = '';
            $val = '';
            $result .= $1;

        } elsif( $key ) {
            $val    .= "$delim$_";

        } else {
            $result .= "$delim$_";
        }
    }

    # handle %TMPL:P{"..."}% recursively
    $result =~ s/(%TMPL\:P{.*?}%)/_expandTrivialTemplate( $this, $1)/geo;

    $result =~ s|^(( {3})+)|"\t" x (length($1)/3)|geom;  # leading spaces to tabs

	return $result;
}

sub checkUserTemplateAccessRights {
	my ( $session, $skins, $templatePath, $web, $name ) = @_;
	my @skinList = @{$skins};
	my $nrskins = $#skinList;
    my $store = $session->{store};

	$nrskins = 0 if $nrskins < 0;
	foreach my $template ( @{$templatePath} ) {
		next if ( $template =~ m/.tmpl$/ );
		for ( my $idx=0; $idx<=$nrskins; $idx++ ) {
			my $file = $template;
			# push the first time even if not modified
			my $skin = $skinList[$idx] || '';
			$skin = ucfirst($skin);
			$file =~ s/\$skin/$skin/geo;
			$file =~ s/\$web/$web/geo;
			$file =~ s/\$name/$name/geo;
		 
			my ($web1, $name1) = 
				$session->normalizeWebTopicName($web, $file);

			if( validateTopic($session,$store,$session->{user},$name1,$web1) ) {
				return $web1;
			}
		}
	}
	return '';
}

=pod

---++ ObjectMethod readTemplate ( $name, $skins, $web ) -> $text

Return value: expanded template text

Reads a template, constructing a candidate name for the template thus
   0 looks for file =$name.$skin.tmpl= (for each skin)
      0 in =templates/$web=
      0 in =templates=, look for
   0 looks for file =$name.tmpl=
      0 in =templates/$web=
      0 in =templates=, look for
   0 if a template is not found, tries in this order
      0 parse =$name= into a web name (default to $web) and a topic name and looks for this topic
      0 looks for topic =${skin}Skin${name}Template= 
         0 in $web (for each skin)
         0 in =Foswiki::cfg{SystemWebName}= (for each skin)
      0 looks for topic =${name}Template=
         0 in $web (for each skin)
         0 in =Foswiki::cfg{SystemWebName}= (for each skin)
In the event that the read fails (template not found, access permissions fail)
returns the empty string ''.

=$skin=, =$web= and =$name= are forced to an upper-case first character
when composing user topic names.

If template text is found, extracts include statements and fully expands them.
Also extracts template definitions and adds them to the
list of loaded templates, overwriting any previous definition.

=cut

sub readTemplate {
    my( $this, $name, $skins, $web ) = @_;

    my $session = $this->{session};
	$web ||= $session->{webName};
    $skins = $session->getSkin() unless defined( $skins );
	$skins =~ s/\s*//go;

	if ($Foswiki::cfg{CacheTemplates}) {
		my $cache = $this->getTemplateFromCache($name, $skins, '');
		unless ($cache) {
			my @skinList = split( /\,\s*/, $skins );
		    my @templatePath = split (/\s*,\s*/, $Foswiki::cfg{TemplatePath});
			my $w = checkUserTemplateAccessRights($session, \@skinList, \@templatePath, $web, $name);
			$cache = $this->getTemplateFromCache($name, $skins, $w);
		}
	    if ($cache) {
			if ($cache =~ /%TMPL\:DEF{\"cachedtemplate:single\"}%(.*?)%TMPL\:END%/s) {
				return $1;
			}
			else { 
				return $this->parseTemplate($cache,1);
			}
		}
		# preparation: template is being requested for the first time and so is not yet cached
		# SMELL: could this be handled better than with a flag? 
		# Using a web name in '$name.$skins.$web'  to safely identify a cached user template 
		# (when using caching). As the _readTemplateFile algorithm allows the inclusion of a user 
		# defined template from a skin template, need to use a toplevel flag to govern when the 
		# $this->{VARS}->{'web'} variable can be set.
		$this->{VARS}->{'toplevel'} = 1;
		$this->{VARS}->{'web'} = $web;
	}
#    print STDERR "template file cache missing - recreating ($name, $skins, $web)";

    $this->{files} = ();

    # recursively read template file(s)
    my $text = _readTemplateFile( $this, $name, $skins, $web );

    while( $text =~ /%TMPL\:INCLUDE{[\s\"]*(.*?)[\"\s]*}%/s ) {
        $text =~ s/%TMPL\:INCLUDE{[\s\"]*(.*?)[\"\s]*}%/_readTemplateFile( $this, $1, $skins, $web )/geo;
    }

    # Kill comments, marked by %{ ... }%
    $text =~ s/%{.*?}%//sg;

    if( ! ( $text =~ /%TMPL\:/s ) ) {
        # no template processing
        $text =~ s|^(( {3})+)|"\t" x (length($1)/3)|geom;  # leading spaces to tabs
		if ($Foswiki::cfg{CacheTemplates}) {
			$web = $this->{VARS}->{'web'};
			$text = "%TMPL:DEF{\"cachedtemplate:single\"}%$text%TMPL:END%";
			$this->saveTemplateToCache($name, $skins, $web, $text);
		}
        return $text;
    }

	my $result = $this->parseTemplate($text);

	if ($Foswiki::cfg{CacheTemplates}) {
		$web = $this->{VARS}->{'web'};
		my $cachetext = '';
		my $cachecounter = 0;
		# write out all unexpanded TMPL:DEFs to the cache file incase they're needed in an %IF{'test' then='TMPL:P'}% later on
		foreach my $template ( keys %{$this->{TEMPLATES}} ) { 
			#print STDERR "template $template is $this->{EXPANDED}->{$template}\n";
			if ($template && !($this->{EXPANDED}->{$template})) {
				$cachetext .= "%TMPL:DEF{\"$template\"}%$this->{TEMPLATES}->{$template}%TMPL:END%\n";
				$cachecounter++;
				$this->{EXPANDED}->{$template} = 1;
				#print STDERR "template $template is now $this->{EXPANDED}->{$template}\n$cachetext\n";
			}
		}
		$cachetext .= $cachecounter ? "%TMPL:DEF{\"cachedtemplate\"}%$result%TMPL:END%\n%TMPL:P{\"cachedtemplate\"}%" :  "%TMPL:DEF{\"cachedtemplate:single\"}%$result%TMPL:END%";
		$this->saveTemplateToCache($name, $skins, $web, $cachetext);
	}
    return $result;
}

# STATIC: Return value: raw template text, or '' if read fails
sub _readTemplateFile {
    my( $this, $name, $skins, $web ) = @_;
    my $session = $this->{session};
    my $store = $session->{store};

    $skins = $session->getSkin() unless defined( $skins );
    #print STDERR "SKIN path is $skins\n";
    $web ||= $session->{webName};
    $name ||= '';

    # SMELL: not i18n-friendly (can't have accented characters in skin name)
    # zap anything suspicious
    $name =~ s/[^A-Za-z0-9_,.]//go;
    $skins =~ s/[^A-Za-z0-9_,.]//go;

    # if the name ends in .tmpl, then this is an explicit include from
    # the templates directory. No further searching required.
    if( $name =~ /\.tmpl$/ ) {
		if ($Foswiki::cfg{CacheTemplates} && $this->{VARS}->{'toplevel'}) { 
		# not a user template, we don't want to set the web name in the name of the template-cache file
			$this->{VARS}->{'web'} = '';
			$this->{VARS}->{'toplevel'} = 0;
		}
        return Foswiki::readFile( $Foswiki::cfg{TemplateDir}.'/'.$name );
    }

    my $userdirweb = $web;
    my $userdirname = $name;
    if( $name =~ /^(.+)\.(.+?)$/ ) {
       $userdirweb = ucfirst($1);
       $userdirname = ucfirst($2);

       # if the name can be parsed into $web.$name, then this is an attempt
       # to explicit include that topic. No further searching required.
       if( validateTopic($session,$store,$session->{user},$userdirname,$userdirweb )) {
			if ($Foswiki::cfg{CacheTemplates} && $this->{VARS}->{'toplevel'}) { 
			# a user template, we want to set the web name in the name of the template-cache file
				$this->{VARS}->{'web'} = $userdirweb;
				$this->{VARS}->{'toplevel'} = 0;
			}
			return retrieveTopic( $store, $userdirweb, $userdirname );
		}
    }
    else {
        $userdirweb = ucfirst($userdirweb);
        $userdirname = ucfirst($userdirname);
    }

    my @skinList = split( /\,\s*/, $skins );
    my $nrskins = $#skinList;
    
    my @templatePath = split (/\s*,\s*/, $Foswiki::cfg{TemplatePath});

    # Search the $Foswiki::cfg{TemplatePath} for the skinned versions
    my @candidates;

    $nrskins = 0 if $nrskins < 0;
    foreach my $template ( @templatePath ) {
      for ( my $idx=0; $idx<=$nrskins; $idx++ ) {
	  my $file = $template;
	  my $userdir = 0;
	  # also need to do %PUBURL% etc.?
	  # push the first time even if not modified
	  my $skin = $skinList[$idx] || '';
	  my $webName = $web || '';
	  my $tmplName = $name || '';
	  unless ( $file =~ m/.tmpl$/ ) {
	    # Could also use $Skin, $Web, $Name to indicate uppercase
	    $userdir = 1;
	    $skin = ucfirst($skin);
	    $webName = $userdirweb;
	    $tmplName = $userdirname;
	  }
	  $file =~ s/\$skin/$skin/geo;
	  $file =~ s/\$web/$webName/geo;
	  $file =~ s/\$name/$tmplName/geo;
 
          my ($candidatename, $candidatevalidate, $candidateretrieve);

	  if ( $userdir ) {
	    # candidate in user directory
	    my ($web1, $name1) = $session->normalizeWebTopicName($web, $file);

	    if( validateTopic($session,$store,$session->{user},$name1,$web1) ) {
		    next if (defined($this->{files}->{'topic'.$session->{user},$name1,$web1}));
		    #recursion prevention.
		    $this->{files}->{'topic'.$session->{user},$name1,$web1} = 1;
			if ($Foswiki::cfg{CacheTemplates} && $this->{VARS}->{'toplevel'}) { 
			# a user template, we want to set the web name in the name of the template-cache file
				$this->{VARS}->{'web'} = $web1;
				$this->{VARS}->{'toplevel'} = 0;
			}
			return retrieveTopic( $store, $web1, $name1 );
	    }
	  } else {
	    if( validateFile( $file )) {
		    next if (defined($this->{files}->{$file}));
		    #recursion prevention.
		    $this->{files}->{$file} = 1;
			if ($Foswiki::cfg{CacheTemplates} && $this->{VARS}->{'toplevel'}) { 
			# not a user template, we don't want to set the web name in the name of the template-cache file
				$this->{VARS}->{'web'} = '';
				$this->{VARS}->{'toplevel'} = 0;
			}
            return Foswiki::readFile( $file );
	    }
	  }
       }
    }

    # SMELL: should really
    #throw Error::Simple( 'Template '.$name.' was not found' );
    # instead of
    #print STDERR "Template $name could not be found anywhere\n";
    #Is Failing Silently the best option here?
    return '';
}

sub validateFile {
   my $file = shift;
   return -e $file;
}

sub validateTopic {
   my( $session, $store, $user, $topic, $web ) = @_;
   return $store->topicExists( $web, $topic ) && 
   $session->security->checkAccessPermission(
       'VIEW', $user, undef, undef, $topic, $web );
}

sub retrieveTopic {
   my( $store, $web, $topic ) = @_;
   my ( $meta, $text ) = $store->readTopic( undef, $web, $topic, undef );
   return $text;
}

sub deleteTemplate {
	my ( $this, $topic, $web ) = @_;
	my $cachetopic = $topic;
	$cachetopic =~ s/Template$//;
	#$this->{session}->writeDebug('in deleteTemplate');
	eval {
		chdir($Foswiki::cfg{TemplateDir}.'_cache');
		opendir(DIR, ".") or die "$!";
		my @files = grep { /\.tmpl$/ && -f "$_" } readdir(DIR);
		closedir DIR;
		# delete cached (EDIT|VIEW)_TEMPLATE files
		map {unlink(Foswiki::Sandbox::untaintUnchecked($_))} 
							grep {/$cachetopic.*$web.tmpl$/} @files;
		#map {unlink($sandbox->untaintUnchecked($_))} grep (/^$web\.$topic/, @files);
		# and the cached comment template if required
		# this should go in an afterSaveHandler for the CommentPlugin
		if ($topic =~ /Comment.*?Template$/) {
			map {unlink(Foswiki::Sandbox->untaintUnchecked($_))} 
							grep {/^comments/} @files;
		}
	};
	$this->{session}->writeWarning( "cached template files not updated:$@" ) if $@;
}

1;
