/* Copyright (c) 2001, Oracle Corporation. All rights reserved. */
package oracle.portal.provider.v2.preference;

import java.io.*;
import java.util.*;
import javax.naming.NamingException;
import oracle.portal.log.LogManager;
import oracle.portal.provider.v2.*;
import oracle.portal.utils.EnvLookup;
import oracle.portal.provider.v2.utils.ValidationError;
import oracle.portal.utils.xml.v2.*;
import oracle.webdb.utils.SimpleStringBuffer;
import oracle.webdb.utils.HTTPUtils;

/**
 * <code>FilePreferenceStore</code> is a lightweight implementation of the
 * <code>PreferenceStore</code> storage mechanism that persists data using the
 * filesystem.<p>
 *
 * All data is persisted in subdirectories of a designated root directory on
 * the filesystem, one data file per user preference. This root directory can
 * be registered explicitly using {@link #setRootDirectory(String)}. If not set
 * explicitly, however, the path returned by {@link
 * ProviderContext#getRepositoryPath()} will be used as the root directory. If
 * the provider is running under {@link
 * oracle.portal.provider.v2.http.DefaultProviderLoader}, this will be the same
 * directory as the provider's XML definition file.<p>
 *
 * If the 'path hashing' feature has been activated (i.e. {@link
 * #setUseHashing(boolean) setUseHashing(true)} has been called, then each
 * preference data file stored in an extra subdirectory whose name is
 * determined by 'hashing' the data file name. This can improve filesystem
 * performance by limiting the number of preference data files stored in a
 * single directory.<p>
 *
 * Note that this class is directly compatible with data from persisted from
 * <code>PersonalizationObject</code>s using the old
 * <code>oracle.portal.provider.v1.FilePersonalizationManager</code> class.
 */
public class FilePreferenceStore extends PreferenceStore
    implements InitializableXMLObject
{
    private File mRoot = null;
    private boolean mUseHashing = false;

    // Preference data filenames for the 'default' customization preference
    // type start with one of these Strings
    private static final String DEFAULT_FILE_PREFIX = "_default";
    private static final String DEFAULT_FILE_PREFIX_OLD = "edit_defaults";

    // Preference data filenames end with one of these strings
    private static final String FILE_SUFFIX = ".dat";
    private static final String FILE_SUFFIX_OLD = ".ser";

    // The radix used for encoding hashed values as strings
    private static final int HASH_BASE = 16;
    // The largest prime number less than HASH_BASE^2
    private static final int HASH_SIZE = 251;

    /**
     * Null Constructor
     */
    public FilePreferenceStore()
    {
    }

    /**
     * Should be called immediately after construction of a
     * <code>FilePreferenceStore</code> to associate it with its parent {@link
     * ProviderDefinition}.
     * @param parent the provider definition object. Should be a {@link
     * ProviderDefinition}
     * @exception NodeHandlerException if the given object is not a
     * <code>ProviderDefinition</code>
     */
    public void preInitialize(Object parent) throws NodeHandlerException
    {
        if (!(parent instanceof ProviderDefinition))
        {
            throw new NodeHandlerException("Parent of a PreferenceStore must be a ProviderDefinition");
        }

        // Set the location for customisations.  First try to retrieve this from 
        // a JNDI variable.
        EnvLookup el = new EnvLookup();
        ProviderDefinition p = (ProviderDefinition)parent;
        ProviderContext pc = p.getProviderContext();

        String rootDir = null;
        try
        {
            rootDir = 
              (String)el.lookup(EnvLookup.FILE_PREF_STORE_LOCATION, 
                                (String)pc.getAttribute(ProviderContext.SERVICE_NAME));
        }
        catch (NamingException ne)
        {
            // Do nothing here, we need to check the value of the rootDir and 
            // maybe retrieve it from somewhere else outside of the try catch 
            // block.
        }

        // if the root directory was not found, we must look for the value in 
        // ProviderContext
        if (rootDir == null || rootDir.length() == 0)
        {
            rootDir = pc.getRepositoryPath();
        }
        
        setRootDirectory(rootDir);
    }

    /**
     * Should be called to complete initialization of this
     * <code>FilePreferenceStore</code>.
     */
    public void postInitialize()
    {
    }

    /**
     *  Sets the path to the filesystem directory under which preference data
     *  will be stored.
     *
     *  @param path physical path to serve as the root directory for the
     *  preference data repository.
     */
    public void setRootDirectory(String root)
    {
        File rootFile = new File(root);
        if (!rootFile.isDirectory() || rootFile.toString().equals("/"))
        {
            throw new SecurityException("Invalid root directory specified " +
                                        "for FilePreferenceStore");
        }
        mRoot = rootFile;
    }

    /**
     *  Activate or deactivate the 'path hashing' feature for this
     *  FilePreferenceStore.
     *
     *  @param useHashing boolean indicating whether the 'path hashing' feature
     *  should be used when determining the filesystem path in which to store a
     *  preference data file for a particular portlet reference.  If
     *  <code>false</code>, then data files for all preferences with the same
     *  context path will be stored in a single directory corresponding to that
     *  context path. If <code>true</code>, then an extra level of directories
     *  below the context path directory, with names derived by a hash function
     *  of the preference data file name, will be used in order to limit the
     *  number of preference data files stored in a single directory.
     */
    public void setUseHashing(boolean useHashing)
    {
        mUseHashing = useHashing;
    }
    
    /**
     *  Activate or deactivate the 'path hashing' feature for this
     *  FilePreferenceStore.
     *
     *  @param useHashing String representation of boolean ("true" or "false")
     *  indicating whether the 'path hashing' feature should be used when
     *  determining the filesystem path in which to store a preference data
     *  file for a particular portlet reference.
     *
     *  @see #setUseHashing(boolean)
     */
    public void setUseHashing(String useHashing)
    {
        setUseHashing(Boolean.valueOf(useHashing).booleanValue());
    }

    /**
     * Gets an <code>Iterator</code> over all of the {@link Preference} objects
     * in this <code>PreferenceStore</code> stored under the given context path
     * with one of the given preference types. Note that this listing is
     * 'recursive', in that <code>Preference</code>s stored in context paths
     * below the given one will also be returned. Note also that the data
     * object is not loaded into any of the listed <code>Preference</code>s.
     * @param context the context path under which <code>Preference</code>s
     * should be listed.
     * @param preferenceTypes a list of strings, naming the preference types to
     * include, or <code>null</code> if all preference types should be
     * included.
     * @exception PreferenceStoreException if an error occurs during the
     * operation.
     */
    public Iterator list(String context, List preferenceTypes)
        throws PreferenceStoreException
    {
        return new FilePreferenceIterator(context, preferenceTypes);
    }

    /**
     * Destroys all of the {@link Preference} objects in this
     * <code>PreferenceStore</code> stored under the given context path with
     * one of the given preference types. Note that this operation is
     * 'recursive', in that <code>Preference</code>s stored in context paths
     * below the given one will also be destroyed.
     * @param context the context path under which <code>Preference</code>s
     * should be listed.
     * @param preferenceTypes a list of strings, naming the preference types to
     * include, or <code>null</code> if all preference types should be
     * included.
     * @exception PreferenceStoreException if an error occurs during the
     * operation.
     */
    public void destroy(String context, List preferenceTypes)
        throws PreferenceStoreException
    {
        // Let the list method handle the clever filtering of the types we want
        // to delete
        Iterator i = list(context, preferenceTypes);
        while(i.hasNext())
        {
            Preference p = (Preference)i.next();
            // The destroy will also handle the cleaning up of parent
            // directories left empty
            destroy(p);
        }
    }

    /**
     * Returns <code>true</code> if persisted data exists for the given
     * <code>Preference</code>.
     * @return <code>true</code> if persisted data exists for the given
     * <code>Preference</code>, <code>false</code> otherwise.
     */
    public boolean exists(Preference pref)
        throws PreferenceStoreException
    {
        File encodedFileName = encodeFileName(pref);
        try
        {
            return encodedFileName.isFile();
        }
        catch(SecurityException se)
        {
            throw new PreferenceStoreException(null, se.getMessage(),
                                               null, se);
        }
    }
 
    /**
     * Gets an <code>InputStream</code> for reading the data of the given
     * <code>Preference</code>.<p> 
     *
     * This method is provided in conjunction with {@link #getOutputStream} as
     * a way of streamlining the process of copying data between
     * <code>PreferenceStore</code>s, since data can be copied directly from
     * the <code>InputStream</code> of one <code>Preference</code> to the
     * <code>OutputStream</code> of another, without having to instantiate a
     * <code>PreferenceDataObject</code>.
     * @exception EntryNotFoundException if no data for the given
     * <code>Preference</code> exists.
     * @exception PreferenceStoreException if any other error occurs during the
     * operation.
     */
    public InputStream getInputStream(Preference pref)
        throws EntryNotFoundException,
               PreferenceStoreException
    {
        File encodedFileName = encodeFileName(pref);
        try
        {
            return new FileInputStream(encodedFileName);
        }
        catch(SecurityException se)
        {
            throw new PreferenceStoreException(null, se.getMessage(),
                                               null, se);
        }
        catch(FileNotFoundException fnfe)
        {
            throw new EntryNotFoundException(null, fnfe.getMessage(),
                                             null, fnfe);
        }
    }

    /**
     * Gets an <code>OutputStream</code> for writing the data of the given
     * <code>Preference</code> directly into storage.<p> 
     *
     * This method is provided in conjunction with {@link #getInputStream} as a
     * way of streamlining the process of copying data between
     * <code>PreferenceStore</code>s, since data can be copied directly from
     * the <code>InputStream</code> of one <code>Preference</code> to the
     * <code>OutputStream</code> of another, without having to instantiate a
     * <code>PreferenceDataObject</code>.
     * @exception EntryAlreadyExistsException if <code>overwrite</code> was
     * <code>false</code> and data for the given <code>Preference</code>
     * already exists in storage.
     * @exception PreferenceStoreException if any other error occurs during the
     * operation.
     */
    public OutputStream getOutputStream(Preference pref,
                                        boolean overwrite)
        throws EntryAlreadyExistsException,
               PreferenceStoreException
    {
        File encodedFileName = encodeFileName(pref);
        try
        {
            if(encodedFileName.exists())
            {
                if(!overwrite)
                {
                    Object[] args = {pref.getReferencePath()};
                    throw new EntryAlreadyExistsException(null,
                                                          "Preference {0} already exists",
                                                          args);
                }
            }
            else
            {
                // First make sure the file's directory exists
                File dir = encodedFileName.getParentFile();
                if(!dir.exists())
                {
                    dir.mkdirs();
                }
            }
            return new FileOutputStream(encodedFileName);
        }
        catch(SecurityException se)
        {
            throw new PreferenceStoreException(null, se.getMessage(),
                                               null, se);
        }
        catch(IOException ioe)
        {
            throw new PreferenceStoreException(null, ioe.getMessage(),
                                               null, ioe);
        }
    }
        
    /**
     * Destroys the data persisted for the given <code>Preference</code>.
     * @param pref the <code>Preference</code> whose persisted data should be
     * destroyed.
     * @exception PreferenceStoreException if an error occurs during the
     * operation.
     */
    public void destroy(Preference pref)
        throws PreferenceStoreException
    {
        File encodedFileName = encodeFileName(pref);
        deleteFile(encodedFileName);
    }

    /**
     * Validates the internal state of this <code>FilePreferenceStore</code>
     * instance. Errors found are reported using the supplied {@link
     * oracle.portal.provider.v2.utils.ValidationError} instance.
     *
     * @param context The context of the calling instance.
     * @param errors The current list of errors.
     */
    public void validate(String context, ValidationError errors)
    {
        String ctx = ValidationError.newContext(context, "FilePreferenceStore",
                                                getName());
        // validate the supertype
        super.validate(ctx, errors);

        // Check the validity of the member variables
        if( mRoot == null )
        {
            errors.addError(ctx, "root directory must be set", null);
        }
        else if(!mRoot.canWrite())
        {
            errors.addError(ctx, "Cant write to root directory",
                            mRoot.toString());
        }
    }

    protected class FilePreferenceIterator implements Iterator
    {
        String mContext;
        Iterator mTypeDirIterator;
        Stack mStack = new Stack();
        Preference mNextPref;
        Set mTypeSet;

        protected FilePreferenceIterator(String context, List preferenceTypes)
            throws PreferenceStoreException
        {
            mContext = context;
            mTypeDirIterator = getTypeDirIterator(preferenceTypes);
            // Convert the type list into a set, so we can make some quick
            // lookups
            if(preferenceTypes != null)
            {
                mTypeSet = new HashSet(preferenceTypes);
            }
            mNextPref = fetchNext();
        }

        // Returns true if the iteration has more elements. (In other words,
        // returns true if next would return an element rather than throwing an
        // exception.)
        public boolean hasNext()
        {
            return mNextPref != null;
        }

        // Returns the next element in the iteration.
        public Object next()
        {
            if(!hasNext())
            {
                throw new NoSuchElementException();
            }

            // Use the version of the constructor that uses the ready-encoded paths
            Preference retVal = mNextPref;

            mNextPref = fetchNext();
            return retVal;
        }

        // Removes from the underlying collection the last element returned by
        // the iterator (optional operation). Not implemented.
        public void remove()
        {
            throw new UnsupportedOperationException();
        }

        protected Preference fetchNext()
        {
            // Keep looping while there is the possibility of us finding
            // another file
            while(!mStack.empty() || mTypeDirIterator.hasNext())
            {
                // If the stack is empty, move on to the next type dir
                if(mStack.empty())
                {
                    File contextDir = new File((File)mTypeDirIterator.next(),
                                               mContext);
                    if(contextDir.exists())
                    {
                        File[] contextFiles;
                        if(contextDir.equals(mRoot))
                        {
                            contextFiles = contextDir.listFiles(new FileFilter()
                                {
                                    public boolean accept(File pathname)
                                    {
                                        return isProviderDirectory(pathname);
                                    }
                                });
                        }
                        else
                        {
                            contextFiles = contextDir.listFiles();
                        }
                        mStack.push(Arrays.asList(contextFiles).iterator());
                    }
                } // end if the stack is empty

                // Keep looping through the stack until it is empty
                while(!mStack.empty())
                {
                    // Get the next file from the iterator at the top of the
                    // stack, if it has one
                    Iterator i = (Iterator)mStack.peek();
                    if(i.hasNext())
                    {
                        File f = (File)i.next();
                        // If it is a directory, then push an iterator for its list
                        // of files
                        if(f.isDirectory())
                        {
                            File[] contextFiles = f.listFiles();
                            mStack.push(Arrays.asList(contextFiles).iterator());
                        }
                        // Otherwise, we have found a preference data file, so
                        // return a preference key for it
                        else
                        {
                            try
                            {
                                Preference p = decodeFileName(f);
                                
                                // If we have found data for one of the
                                // 'special' preference types mapped to the
                                // root directory, there is still the
                                // possiblility that the preference isn't of
                                // one of the requested types, so we double
                                // check before we return it
                                if(mTypeSet == null ||
                                   mTypeSet.contains(p.getType()))
                                {
                                    return p;
                                }
                            }
                            catch(PreferenceStoreException pse)
                            {
                                // If there is a problem decoding the filename,
                                // just skip it
                                LogManager.log("FilePreferenceStore: Error decoding filename", pse);
                            }
                        }
                    }
                    // If we are at the end of the iterator at the top of the
                    // stack, then pop it from the stack
                    else
                    {
                        mStack.pop();
                    }
                } // end while the stack is not empty
            } // end while we haven't exhausted all possibilities
            return null;
        } // end fetchNext() method
    }

    // return an iterator over the directories corresponding to the path types
    // in preferenceTypes, or over ALL type directories if preferenceTypes is
    // null.
    protected Iterator getTypeDirIterator(List preferenceTypes)
    {
        ArrayList typeDirList = new ArrayList();
        // If the root directory doesn't even exist, there's no point going any
        // further
        if(!mRoot.exists())
        {
            return typeDirList.iterator();
        }

        // If we have been passed a non-null type list, we have to convert it
        // to a list of type directories
        if(preferenceTypes != null)
        {
            boolean addedRoot = false;
            Iterator i = preferenceTypes.iterator();
            while(i.hasNext())
            {
                String type = (String)i.next();
                // If the type is one of the 'special' types that is stored
                // under the root directory, then add the root directory
                if(type.equals(USER_CUSTOMIZATION_TYPE) ||
                   type.equals(DEFAULT_CUSTOMIZATION_TYPE))
                {
                    if(!addedRoot)
                    {
                        typeDirList.add(mRoot);
                        addedRoot = true;
                    }
                }
                // Otherwise, if a directory exists that corresponds to that
                // type name, add it
                else
                {
                    File dir = new File(mRoot, type);
                    if(dir.isDirectory())
                    {
                        typeDirList.add(dir);
                    }
                }
            }

        } // if(preferenceTypes != null)
        // Otherwise, we have to find ALL the directories that correspond to preference types
        else
        {
            // We add the root directory itself, corresponding to the 'special'
            // types
            typeDirList.add(mRoot);

            // List all the directories under the root directory. Type names
            // that look like provider ids, i.e. longs, aren't real type names,
            // so we filter them out
            File[] typeDirs = mRoot.listFiles(new FileFilter()
                {
                    public boolean accept(File pathname)
                    {
                        return !isProviderDirectory(pathname) &&
                            pathname.isDirectory();
                    }
                });
            typeDirList.addAll(Arrays.asList(typeDirs));
        } // if(preferenceTypes == null)

        return typeDirList.iterator();
    }

    protected boolean isProviderDirectory(File pathname)
    {
        if(!pathname.isDirectory())
        {
            return false;
        }
        try
        {
            Long.parseLong(pathname.getName());
            return true;
        }
        catch(NumberFormatException nfe)
        {
            return false;
        }
    }

    // This method deletes a file and recursively deletes its parent
    // directories if they are left empty
    protected void deleteFile(File targetFile)
    {
        File parentDirectory = targetFile.getParentFile();

        try
        {
            // delete will only succeed for a file or empty dir
            if(targetFile.delete() && !parentDirectory.equals(mRoot))
            {
                deleteFile(parentDirectory);
            }
        }
        catch (SecurityException se) 
        {
            // do not re-throw the exception
            LogManager.log("FilePreferenceStore: Error deleting file", se);
        }
    }

    protected File encodeFileName(Preference pref)
        throws PreferenceStoreException
    {
        String type = pref.getType();
        String name = pref.getName();
        File dir;
        String fileName;

        // If the preference type is the special 'default' customization
        // type, then we derive the filename in a backward-compatible way
        if(type.equals(DEFAULT_CUSTOMIZATION_TYPE))
        {
            if(name.length() > 0)
            {
                fileName = DEFAULT_FILE_PREFIX + "_" + name + FILE_SUFFIX;
            }
            else
            {
                fileName = DEFAULT_FILE_PREFIX +
                    FILE_SUFFIX;
            }
            // Default customizations are stored directly under the root
            dir = mRoot;
        }
        // Otherwise the filename is simply the encoded preference name
        // with the appropriate suffix added
        else
        {
            fileName = HTTPUtils.encodeUTF(name) + 
                FILE_SUFFIX;
            // User customizations are stored directly under the root
            if(type.equals(USER_CUSTOMIZATION_TYPE))
            {
                dir = mRoot;
            }
            // Other customizations are stored in a directory named after
            // their type
            else
            {
                dir = new File(mRoot, type);
            }
        }

        // Remember that if useHashing is true, we store the file under an
        // additional subdirectory whose name is derived by 'hashing' the
        // filename
        String contextPath = pref.getContextPath();
        String unencodedPath;
        if(mUseHashing)
        {
            unencodedPath = contextPath + PATH_SEPARATOR_STRING + 
                hashName(fileName) + PATH_SEPARATOR_STRING + fileName;
        }
        else
        {
            unencodedPath = contextPath + PATH_SEPARATOR_STRING + 
                fileName;
        }
        return new File(dir, encodePath(unencodedPath));
    }

    protected Preference decodeFileName(File encodedFileName)
        throws PreferenceStoreException
    {
        String name, type, contextPath;

        File fileDir = encodedFileName.getParentFile();
        String pathString = fileDir.toString();
        String rootString = mRoot.toString();
        int rootEndIndex = rootString.length();
        if(!pathString.startsWith(rootString) || pathString.length() < rootEndIndex+1)
        {
            SimpleStringBuffer exc = new SimpleStringBuffer(200);
            exc.append("Unable to determine preference type for data file \"");
            exc.append(encodedFileName.getAbsolutePath());
            exc.append("\"");
            throw new PreferenceStoreException(null, exc.toString());
        }
        // Work out where the separator between the 'type directory' and
        // the reference path is
        int sepIndex = pathString.indexOf(File.separatorChar, rootEndIndex+1);
        File typeDir;
        if(sepIndex == -1)
        {
            typeDir = fileDir;
        }
        else
        {
            typeDir = new File(pathString.substring(0, sepIndex));
        }


        // Remove the suffix from the filename
        String fileName = encodedFileName.getName();
        int dotIndex = fileName.lastIndexOf('.');
        if(dotIndex != -1)
        {
            fileName = fileName.substring(0, dotIndex);
        }

        if(isProviderDirectory(typeDir))
        {
            contextPath = pathString.substring(rootEndIndex+1);
            if(fileName.startsWith(DEFAULT_FILE_PREFIX_OLD))
            {
                type = DEFAULT_CUSTOMIZATION_TYPE;
                if(fileName.length() > DEFAULT_FILE_PREFIX_OLD.length() + 1)
                {
                    name = fileName.substring(DEFAULT_FILE_PREFIX_OLD.length() + 1);
                }
                else name = "";
            }
            else if(fileName.startsWith(DEFAULT_FILE_PREFIX))
            {
                type = DEFAULT_CUSTOMIZATION_TYPE;
                if(fileName.length() > DEFAULT_FILE_PREFIX.length() + 1)
                {
                    name = fileName.substring(DEFAULT_FILE_PREFIX.length() + 1);
                }
                else name = "";
            }
            else
            {
                type = USER_CUSTOMIZATION_TYPE;
                name = HTTPUtils.decodeUTF(fileName);
            }
        } // end if(isProviderDirectory(typeDir))
        else
        {
            contextPath = pathString.substring(sepIndex+1);
            type = typeDir.getName();
            name = HTTPUtils.decodeUTF(fileName);
        }

        // Remember that if useHashing is true, we have to get rid of the
        // additional 'hashed' subdirectory from the context path
        if(mUseHashing)
        {
            sepIndex = contextPath.lastIndexOf(File.separatorChar);
            if(sepIndex != -1)
            {
                contextPath = contextPath.substring(0, sepIndex);
            }
        }

        // Finally, decode what's left of the context path and use it in
        // the reference path
        contextPath = contextPath.replace(File.separatorChar,
                                          PATH_SEPARATOR_CHAR);
        contextPath = decodePath(contextPath);

        // It's impossible to work out the data class from the current file
        // naming system
        return new Preference(contextPath, name, type, (String)null);
    }
    
    // maps filename string string to a two character hexadecimal string, e.g. "b6"
    protected String hashName(String name) 
    {
        // Use Math.abs in case we get a negative value back from hashCode()
        int hashValue = Math.abs(name.hashCode() % HASH_SIZE);
        return Integer.toString(hashValue, HASH_BASE);
    }

}
