/*
 *  Copyright (C) 2000 - 2008 TagServlet Ltd
 *
 *  This file is part of Open BlueDragon (OpenBD) CFML Server Engine.
 *
 *  OpenBD is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  Free Software Foundation,version 3.
 *
 *  OpenBD 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 General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with OpenBD.  If not, see http://www.gnu.org/licenses/
 *
 *  Additional permission under GNU GPL version 3 section 7
 *
 *  If you modify this Program, or any covered work, by linking or combining
 *  it with any of the JARS listed in the README.txt (or a modified version of
 *  (that library), containing parts covered by the terms of that JAR, the
 *  licensors of this Program grant you additional permission to convey the
 *  resulting work.
 *  README.txt @ http://www.openbluedragon.org/license/README.txt
 *
 *  http://www.openbluedragon.org/
 */

/*
 * Created on 21-May-2004 by Alan Williamson
 *
 * Implements the CFCACHE tag
 *
 * Due to the way we retrieve the content we do not need USERNAME, PASSWORD, HTTP, PORT attributes
 * of the CFCACHE tag.
 *
 */

package com.naryx.tagfusion.cfm.tag;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileFilter;
import java.io.FileInputStream;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Serializable;

import javax.servlet.RequestDispatcher;
import javax.servlet.http.HttpServletResponse;

import org.apache.oro.text.regex.MalformedPatternException;
import org.apache.oro.text.regex.Pattern;
import org.apache.oro.text.regex.PatternMatcherInput;
import org.apache.oro.text.regex.Perl5Compiler;
import org.apache.oro.text.regex.Perl5Matcher;
import org.apache.oro.text.regex.Perl5Substitution;
import org.apache.oro.text.regex.Util;

import com.nary.util.Lock;
import com.naryx.tagfusion.cfm.engine.catchDataFactory;
import com.naryx.tagfusion.cfm.engine.cfEngine;
import com.naryx.tagfusion.cfm.engine.cfSession;
import com.naryx.tagfusion.cfm.engine.cfmBadFileException;
import com.naryx.tagfusion.cfm.engine.cfmRunTimeException;
import com.naryx.tagfusion.servlet.jsp.cfIncludeHttpServletResponseWrapper;

public class cfCACHE extends cfTag implements Serializable, cfOptionalBodyTag
{

	static final long serialVersionUID = 1;

  private static final int  ACTION_CACHE        = 0;
  private static final int  ACTION_FLUSH        = 1;
  private static final int  ACTION_CLIENTCACHE  = 2;
  private static final int  ACTION_SERVERCACHE  = 4;
  private static int	totalCacheHit = 0;	// total server cache hit (doesn't include client cache hits)

  private int actionType = ACTION_CACHE;

  private static Lock cacheLock = new Lock();

	private String endMarker = null;

	public static int getTotalHits(){
		return totalCacheHit;
	}

  public String getEndMarker() {
		return endMarker;
  }

  public void setEndTag() {
		//--[ This is called once from the cfParseTag class.  its to handle <CFMODULE/> which is to trigger double execution
		endMarker = "";
  }

  public void lookAheadForEndTag(tagReader inFile) {
		endMarker = new tagLocator("CFCACHE", inFile).findEndMarker();
  }

  //------------------------------------
  //------------------------------------

  protected void defaultParameters( String _tag ) throws cfmBadFileException {
		defaultAttribute( "ACTION",       "cache" );
		defaultAttribute( "PROTOCOL",     "http://" );
		defaultAttribute( "PORT",     		"80" );
		defaultAttribute( "EXPIREURL", 		"" );

		parseTagHeader( _tag );

    //--[ Get the ACTION property out
    String action = getConstant( "ACTION" ).toLowerCase();
    if ( action.equals("cache") || action.equals("optimal") )
      actionType = ACTION_CACHE;
    else if ( action.equals("flush") )
      actionType = ACTION_FLUSH;
		else if ( action.equals("clientcache") )
	  	actionType = ACTION_CLIENTCACHE;
		else if ( action.equals("servercache") )
	   actionType = ACTION_SERVERCACHE;

		if ( containsAttribute( "TIMEOUT" ) ){
			throw newBadFileException("Unsupported Attribute", "The TIMEOUT attribute is not supported. Use TIMESPAN instead." );
		}
		//--[ No longer required so lets remove it
    removeAttribute( "ACTION" );
  }


	public cfTagReturnType render( cfSession _Session ) throws cfmRunTimeException {

		if ( !containsAttribute( "DIRECTORY" ) ) {
	        defaultAttribute( "DIRECTORY", new File( cfEngine.thisPlatform.getFileIO().getWorkingDirectory(), "cfcache" ).toString() );
	    }
		
		if ( actionType == ACTION_CACHE ){

			//-- Check to see that this execution run isn't in response to a CFCACHE call
			//-- If it is, then simply ignore this execution
			if ( _Session.REQ.getAttribute("bdcache") != null )
				return cfTagReturnType.NORMAL;

			//-- Determine if we are to send back the page we have in cache
			//-- or create a new one
			long browserDate	= getBrowserDate( _Session.REQ.getHeader("If-Modified-Since") );
			File cacheName		= new File( getDirectory(_Session), generateFilename( _Session ) );
			String lockName = cacheName.getAbsolutePath();

			// First check the client cache
			if ( !isClientCachedFileExpired(_Session, cacheName, browserDate, _Session.getCurrentFile().lastModified()) ){
				// It's not expired in the client cache so return not modified and abort processing
				_Session.setStatus( 304, "Not Modified" );
				_Session.abortPageProcessing();
			}

			synchronized( cacheLock.getLock( lockName ) ){
				try{

					// Now check the server cache
					if ( isServerCachedFileExpired(_Session, cacheName, _Session.getCurrentFile().lastModified() ) ){
						// It's expired in the client and server cache so send last-modified to cache it in the
						// client cache and call makeCacheFile() to cache it in the server cache.
						_Session.setHeader( "Last-Modified", com.nary.util.Date.formatNow( "EEE, dd MMM yyyy HH:mm:ss" ) + " GMT" );

						String errorMsg = makeCacheFile(_Session, cacheName);
						if ( errorMsg != null )
							throw new cfmRunTimeException( catchDataFactory.generalException("errorCode.runtimeError","runtime.general", new String[] {"Failed to cache " + _Session.getRequestURI() + " (" + errorMsg + ")"}));
					}else{
						//-- The server cache is good
						totalCacheHit++;
					}

					//-- Read the cache from file and send it to the client
					sendCacheFile( _Session, cacheName );
				}finally{
					cacheLock.removeLock( lockName );
				}
			}

			//-- No point in continuing any further
			_Session.abortPageProcessing( true );

		} else if ( actionType == ACTION_SERVERCACHE ){

			//-- Check to see that this execution run isn't in response to a CFCACHE call
			//-- If it is, then simply ignore this execution
			if ( _Session.REQ.getAttribute("bdcache") != null )
				return cfTagReturnType.NORMAL;

			File cacheName	= new File( getDirectory(_Session), generateFilename( _Session ) );
			String lockName = cacheName.getAbsolutePath();

			synchronized( cacheLock.getLock( lockName ) ){
				try{
					if ( isServerCachedFileExpired(_Session, cacheName, _Session.getCurrentFile().lastModified() ) ){
						String errorMsg = makeCacheFile(_Session, cacheName);
						if ( errorMsg != null )
							throw new cfmRunTimeException( catchDataFactory.generalException("errorCode.runtimeError","runtime.general", new String[] {"Failed to cache " + _Session.getRequestURI() + " (" + errorMsg + ")"}));
					}else{
						//-- The server cache is good
						totalCacheHit++;
					}

					//-- Read the cache from file and send it to the client
					sendCacheFile( _Session, cacheName );
				}finally{
					cacheLock.removeLock( lockName );
				}
			}

			//-- No point in continuing any further
			_Session.abortPageProcessing();

		} else if ( actionType == ACTION_CLIENTCACHE ){

			long browserDate	= getBrowserDate( _Session.REQ.getHeader("If-Modified-Since") );
			File cacheName		= new File( getDirectory(_Session), generateFilename( _Session ) );

			if ( isClientCachedFileExpired(_Session, cacheName, browserDate, _Session.getCurrentFile().lastModified()) ){
				// When only the client cache is being used, we create an empty file
				// in the server cache so we can detect a flush action.
				touchLocalFile( _Session, cacheName );
				_Session.setHeader( "Last-Modified", com.nary.util.Date.formatNow( "EEE, dd MMM yyyy HH:mm:ss" ) + " GMT" );

			}else{
				_Session.setStatus( 304, "Not Modified" );
				_Session.abortPageProcessing();
			}

		}else if ( actionType == ACTION_FLUSH ){
			expireFiles( getDirectory(_Session), getDynamic(_Session,"EXPIREURL").getString(), _Session.REQ.getServerName().toLowerCase() );
		}

		return cfTagReturnType.NORMAL;
	}

  private static long getBrowserDate(String date){
  	if ( date == null || date.length() == 0 )
  		return Long.MAX_VALUE;

		date	= date.substring( date.indexOf(",")+1 ).trim();
		date	= date.substring( 0, date.lastIndexOf(" "));

		java.util.Date dd = com.nary.util.date.dateTimeTokenizer.getUKDate( date );
		if ( dd != null )
			return dd.getTime();
		else
			return Long.MAX_VALUE;
  }

  private File getDirectory(cfSession _Session ) throws cfmRunTimeException {
  	File dir	= new File(getDynamic(_Session,"DIRECTORY").getString());
  	if ( !dir.isDirectory() )
  		dir.mkdirs();

  	return dir;
  }

  private static void touchLocalFile(cfSession _Session, File localFile ){
  	BufferedWriter out = null;
  	try{
			String server = _Session.REQ.getServerName().toLowerCase();
  		String uri		= _Session.getRequestURI();
  		String queryStr	= _Session.REQ.getQueryString();

  		out	= new BufferedWriter( cfEngine.thisPlatform.getFileIO().getFileWriter(localFile) );

  		if ( queryStr != null )
				out.write( "<!-- " + server + uri + "?" + queryStr + " -->\r\n" );
			else
				out.write( "<!-- " + server + uri + " -->\r\n" );

  	}catch(Exception ignore){
  	}finally{
		// Make sure the writer is closed so we'll be able to delete the file for a flush action.
		try{if ( out != null ) out.close();}catch(Exception ignoreClose){}
  	}
 }

  private static String generateFilename( cfSession _Session ) {
		String 	server 		= _Session.REQ.getServerName().toLowerCase();
  	String	queryStr	= _Session.REQ.getQueryString();
  	String	filename	= server + _Session.getRequestURI();
  	if ( queryStr != null )
  		filename	+= "?" + queryStr;

  	return "cfcache_" + com.nary.util.string.hashCode(filename) + ".htm";
  }

	private boolean isServerCachedFileExpired(cfSession _Session, File cacheName, long _pageLastModified )
	  	throws cfmRunTimeException{
	// If the file isn't cached on the server then return true to indicate it expired.
	if ( !cacheName.exists() )
		return true;

  	// if the cfm page has been modified since it was last cached
  	if ( _pageLastModified > cacheName.lastModified() ){
  		return true;
  	}

	// If a timespan was specified and the current time is greater than the cached file's
	// last modified time plus the timespan then return true to indicate it expired.
	if ( containsAttribute("TIMESPAN") ){
	  double timespan = getDynamic(_Session,"TIMESPAN").getDouble();

	  // Convert the timespan to milliseconds
	  long timespanMillis	= (long)((double)86400000 * timespan);

	  // Check if it expired
	  if ( System.currentTimeMillis() > cacheName.lastModified() + timespanMillis )
		  return true;
	}

	return false;
  }

  private boolean isClientCachedFileExpired(cfSession _Session, File cacheName, long browserDate, long _codeLastModified )
  	throws cfmRunTimeException
  {
	// When only the client cache is being used, we create an empty file in the server cache so we can
	// detect a flush action.  When both the client and server cache are being used then the file in the
	// server cache will actually contain the last cached results.  In either case, if the file doesn't
	// exist then we now it's been flushed so return true to indicate it expired.
	if ( !cacheName.exists() )
		return true;

	// If the file doesn't exist in the client cache then return true to indicate it expired.
	// NOTE:  This is detected by the absence of the "If-Modified-Since" request header.
	if ( browserDate == Long.MAX_VALUE )
		return true;

	if ( _codeLastModified > cacheName.lastModified() ){
		return true;
	}

	// If a timespan was specified and the current time is greater than the browserDate plus
	// the timespan then return true to indicate it expired.
	if ( containsAttribute("TIMESPAN") ){
		double timespan = getDynamic(_Session,"TIMESPAN").getDouble();

  		// Convert the timespan to milliseconds
		long timespanMillis	= (long)((double)86400000 * timespan);

		// Check if it expired
		if ( System.currentTimeMillis() > browserDate + timespanMillis )
			return true;
	}

	return false;
  }


  private static String makeCacheFile( cfSession _session, File cacheName ){

  	String uri = _session.getRequestURI();

  	RequestDispatcher rd = _session.REQ.getRequestDispatcher( uri );
		if ( rd == null ) {
			return "Failed to get RequestDispatcher";
		}


  	//---[ Now that the servlet has been found, trigger its execution
		cfIncludeHttpServletResponseWrapper servletOutput = new cfIncludeHttpServletResponseWrapper( _session.RES );
		_session.REQ.setAttribute("bdcache", "");

		try {
			rd.include( _session.REQ, servletOutput );
		} catch ( Exception exc ) {
			//-- This page had an error with it; therefore, we don't cache the page, but continue to
			//-- execute it as a whole so the user can see the *real* error
			return "Requested page threw an exception - " + exc.toString();
		}

		//-- Lets make sure all is well
		if ( servletOutput.getStatusCode() == HttpServletResponse.SC_TEMPORARY_REDIRECT ){
			//- The page we were attempting to cache issued a redirect via CFLOCATION.
			//- We'll honour it here so the page isn't run twice which may cause problems
			try{
				_session.sendRedirect( servletOutput.getRedirectURI() );
			}catch(Exception ignore){}
			return "Failed to redirect";
		} else if ( servletOutput.getStatusCode() != HttpServletResponse.SC_OK ){
			return "Received status code - " + servletOutput.getStatusCode();
		}


		//-- Write output to File
		BufferedWriter	out = null;
		OutputStream fout = null;
		OutputStreamWriter osw = null;
		try{
			String 	server = _session.REQ.getServerName().toLowerCase();
			String	queryStr = _session.REQ.getQueryString();
			fout = cfEngine.thisPlatform.getFileIO().getFileOutputStream(cacheName);
			osw = new OutputStreamWriter( fout, "utf-8" );
			out	= new BufferedWriter( osw );

			if ( queryStr != null )
				out.write( "<!-- " + server + uri + "?" + queryStr + " -->\r\n" );
			else
				out.write( "<!-- " + server + uri + " -->\r\n" );

			String enc = com.nary.util.Localization.convertCharSetToCharEncoding( servletOutput.getCharacterEncoding() );
			String s = new String( servletOutput.getByteArray(), enc );
			out.write( s );
			out.flush();
		}catch(Exception E){
			//-- Something went wrong with the cache output; flag this as an invalid cache creation
			return "Failed to write file to cache - " + E.toString();
		}finally{
			// Make sure the writer is closed so we'll be able to delete the file for a flush action.
			try{if ( out != null ) out.close();}catch(Exception ignoreClose){}
		}

		return null;
  }

  private static void sendCacheFile( cfSession _Session, File cacheName ) throws cfmRunTimeException {
		try{
			sendCacheFile( _Session, cacheName, false );
		}catch( IOException e ){
			String errorMsg = makeCacheFile(_Session, cacheName);
			if ( errorMsg != null )
				throw new cfmRunTimeException( catchDataFactory.generalException("errorCode.runtimeError","runtime.general", new String[] {"Failed to cache " + _Session.getRequestURI() + " (" + errorMsg + ")"}));
			try {
	      sendCacheFile( _Session, cacheName, true );
      } catch ( IOException e1 ) { // shouldn't happen but log it in any case
      	cfEngine.log( "Unexpected exception. IOException should be masked" );
      }
		}
  }

	private static void sendCacheFile( cfSession _Session, File cacheName, boolean _maskIOException ) throws IOException, cfmRunTimeException {
		BufferedReader	in	= null;
		FileInputStream fis = null;
		InputStreamReader isr = null;
		try{
			fis = new FileInputStream( cacheName );
			isr = new InputStreamReader( fis, com.nary.util.Localization.convertCharSetToCharEncoding( "utf-8" ) );
			in	= new BufferedReader( isr );
			String lineIn = in.readLine();	//-- Read the first line; its BlueDragon related
			while ( (lineIn=in.readLine()) != null ){
				_Session.write( lineIn );
				_Session.write( "\r\n" );
			}

		}catch(Exception E){
			if ( ! _maskIOException && E instanceof IOException ){
				throw (IOException) E;
			}
			throw new cfmRunTimeException( catchDataFactory.extendedException( "errorCode.runtimeError",
					"cfcache.fromdisk",
					new String[]{cacheName.toString()},
					E.getMessage()) );
		}finally{
			// Make sure the reader is closed so we'll be able to delete the file for a flush action.
			try{if ( in != null ) in.close();}catch(Exception ignoreClose){}
		}
	}

	private static class cfCACHEFileFilter implements FileFilter
  	{
	  public boolean accept(File pathname)
	  {
	    if(pathname.getName().matches("cfcache_-?[0-9]+\\.htm"))
	    	return true;
	    else
	    	return false;
	  }
	}

  	private static cfCACHEFileFilter cfCacheFileFilter = new cfCACHEFileFilter();

	private void expireFiles( File directory, String expireURL, String virtualServer ) throws cfmRunTimeException {
		File[] listOfFiles = directory.listFiles(cfCacheFileFilter);

		boolean	deleteAll	= (expireURL.equals("*") || expireURL.length() == 0);

		//use of this var is the fix for NA bug #3308
	  	boolean ignoreHost = (! deleteAll && expireURL.startsWith("*"));

		File thisFile;
		String firstline;

		Perl5Compiler 			compiler 	= new Perl5Compiler();
		Perl5Matcher				matcher 	= new Perl5Matcher();
		Pattern 						pattern 	= null;

		if ( !deleteAll ){
			try {
				if(!ignoreHost)
				{
					/* The string in the cache file is always <servername>/<uri>; we need to add in the servername and adjust the expireURL accordingly */
					if ( expireURL.startsWith("/") )
						expireURL = virtualServer + expireURL;
					else
						expireURL = virtualServer + "/" + expireURL;
				}

				pattern		= compiler.compile( escapeExpireUrl( expireURL ) );
			} catch (MalformedPatternException e) {
				throw new cfmRunTimeException( catchDataFactory.extendedException( "errorCode.runtimeError",
						 "cfcache.expireUrl",
						 new String[]{expireURL},
						 e.getMessage()) );
			}
		}

  	for ( int x=0; x < listOfFiles.length; x++ ){
  		thisFile	= listOfFiles[x];
  		if ( deleteAll ) {
  			boolean success = false;
  			int tries = 0;
  			for ( ; (tries < 10) && (!success); tries++ )
  			{
  				if ( deleteCachedFile( thisFile ) )
  					success = true;
 			}
			if ( !success ) {
				throw newRunTimeException( "Failed to delete cache file: " + thisFile );
			}
  		}
  		else{
  			firstline	= getURIFromFile( thisFile );
  			if ( firstline != null ){

  				if( pattern != null && matcher.contains( new PatternMatcherInput( firstline ), pattern ) )
  					deleteCachedFile( thisFile );

  			}
  		}
  	}
  }

  private boolean deleteCachedFile( File _f ){
  	synchronized( cacheLock.getLock( _f.getAbsolutePath() ) ){
  		try{
  			return _f.delete();
  		}finally{
  			cacheLock.removeLock( _f.getAbsolutePath() );
  		}
  	}
  }

  private static String escapeExpireUrl(String expireURL){
		Perl5Compiler 			compiler 	= new Perl5Compiler();
		Perl5Matcher				matcher 	= new Perl5Matcher();
		Pattern 						pattern 	= null;

		try{
			pattern		= compiler.compile( "([+?.])" );
		  expireURL = Util.substitute(matcher, pattern, new Perl5Substitution( "\\\\$1" ), expireURL, Util.SUBSTITUTE_ALL );
		  return com.nary.util.string.replaceString(expireURL,"*",".*");
		}catch(Exception E){
			return null;
		}
  }


  private static String getURIFromFile(File file){
  	try{
  		BufferedReader	in	= new BufferedReader( new FileReader(file) );

  		String lineIn  = in.readLine();
  		in.close();

  		int c1 = lineIn.indexOf("<!--");
  		if ( c1 == -1 )	return null;

  		return lineIn.substring( c1+4, lineIn.indexOf("-->")-1 );

  	}catch(Exception E){
  		return null;
  	}
  }

}