/*
 * #%L
 * Alfresco Repository
 * %%
 * Copyright (C) 2005 - 2016 Alfresco Software Limited
 * %%
 * This file is part of the Alfresco software. 
 * If the software was purchased under a paid Alfresco license, the terms of 
 * the paid license agreement will prevail.  Otherwise, the software is 
 * provided under the following open source license terms:
 * 
 * Alfresco is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * Alfresco 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 General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public License
 * along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
 * #L%
 */
package org.alfresco.repo.audit;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.WeakHashMap;
import java.util.regex.Pattern;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/**
 * Filter using property file values to accept or reject audit map values.<p>
 * 
 * The last component in the {@code rootPath} is considered to be the event
 * action. The keys in an audit map identify each audit value. Properties may be
 * defined to accept or reject each value. If any value in an audit map is
 * rejected, the whole map is rejected. So that one does not have to define
 * too many properties, a 'default' event action property may be defined. This
 * will be inherited by all actions unless a property is defined for a particular
 * event action. For example:
 * <pre>
 *   audit.filter.alfresco-access.default.enabled=true
 *   audit.filter.alfresco-access.default.user=~System;.*
 *   audit.filter.alfresco-access.default.type=cm:folder;cm:content;st:site
 *   audit.filter.alfresco-access.default.path=/app:company_home/.*
 *   audit.filter.alfresco-access.transaction.user=
 *   audit.filter.alfresco-access.login.user=jblogs
 *   ...
 * </pre>
 * 
 * Each property value defines a list of regular expressions that will be used
 * to match the actual audit map values. In the above example, events created
 * by any user except for the internal user 'System' will be recorded by default
 * for all event actions. However the property for the 'transaction' event action
 * overrides this to record even 'System' events.<p>
 * 
 * For any filters to be applied to an event action, that action's filters must be
 * enabled with an 'enabled' property set to {@code "true"}. However this may
 * also be done by using the 'default' event action, as shown above.<p> 
 * 
 * Note: Property names have a {@code "audit.filter."} prefix and use {@code '.'}
 * as a separator where as components of rootPath and keys in the audit map use
 * {@code '/'}. The following is an example rootPath and audit map which could be
 * used with the corresponding property names shown above:
 * 
 * <pre>
 *     rootPath                       auditMap
 *     "/alfresco-access/transaction" "user" => "System"
 *                                    "path" => "/app:company_home/st:sites/cm:mysite/cm:documentLibrary/cm:folder1"
 *                                    "type" => "cm:folder"
 *                                    "node" => ...
 * </pre> 
 * 
 * Lists are evaluated from left to right allowing one flexibility to accept or
 * reject different combinations of values. If no match is made by the end of the
 * list the value is rejected. If there is not a property for a given value or
 * an empty list is defined (as above for the user value on a transaction action)
 * any value is accepted.<p>
 * 
 * Each regular expression in the list is separated by a {@code ';'}. Expressions
 * that include a {@code ';'} may be escaped using a {@code '\'}. An expression
 * that starts with a {@code '~'} indicates that any matching value should be
 * rejected. If the first character of an expression needs to be a {@code '~'} it
 * too may be escaped with a {@code '\'}.<p>
 * 
 * A property value may be a reference to another property, which saves having
 * multiple copies. This is indicated by a {@code '$'} as the first character of the
 * property value. If the first character of an expression needs to be a
 * {@code '$'} it too may be escaped with a {@code '\'}. For example:
 * <pre>
 *   audit.filter.alfresco-access.default.type=cm:folder;cm:content
 *   audit.filter.alfresco-access.moveNode.from.type=$audit.filter.alfresco-access.default.type
 * </pre> 
 * 
 * @author Alan Davis
 */
public class PropertyAuditFilter implements AuditFilter
{
    private static Log logger = LogFactory.getLog(PropertyAuditFilter.class);

    private static final char NOT = '~';
    private static final char REDIRECT = '$';
    private static final String REG_EXP_SEPARATOR = ";";
    private static final char PROPERTY_SEPARATOR = '.';
    private static final String PROPERY_NAME_PREFIX = "audit.filter";
    private static final char ESCAPE = '\\';
    
    private static final String ESCAPED_REDIRECT = ""+ESCAPE+REDIRECT;
    private static final String ESCAPED_REG_EXP_SEPARATOR = ""+ESCAPE+REG_EXP_SEPARATOR;
    private static final String ESCAPED_NOT = ""+ESCAPE+NOT;
    
    private static final String ENABLED = "enabled";
    private static final String DEFAULT = "default";

    /**
     * Cache of {@code Patterns} for performance.
     */
    static Map<String, Pattern> patternCache =
        Collections.synchronizedMap(new WeakHashMap<String, Pattern>());
    
    /**
     * Properties to drive the filter.
     */
    Properties properties;
    
    /**
     * Set the properties object holding filter configuration
     * @since 3.2
     */
    public void setProperties(Properties properties)
    {
        this.properties = properties;
    }

    /**
     * @param rootPath String
     * @return boolean
     */
    @Override
    public boolean accept(String rootPath, Map<String, Serializable> auditMap)
    {
        String[] root = splitPath(rootPath);
        String rootProperty = getPropertyName(PROPERY_NAME_PREFIX, getPropertyName(root));
        String defaultRootProperty = getDefaultRootProperty(root);
      
        if ("true".equalsIgnoreCase(getProperty(rootProperty, defaultRootProperty, ENABLED)))
        {
            for (Map.Entry<String, Serializable> entry : auditMap.entrySet())
            {
                Serializable value = entry.getValue();
                if (value == null)
                {
                    value = "null";
                }
                String stringValue = (value instanceof String) ? (String)value : value.toString();
                String[] key = splitPath(entry.getKey());
                String propertyValue = getProperty(rootProperty, defaultRootProperty, key);
                if (!acceptValue(stringValue, propertyValue, rootProperty, key))
                {
                    if (logger.isDebugEnabled())
                    {
                        logger.debug("Rejected \n\t            "+rootPath+'/'+entry.getKey()+"="+stringValue+
                                "\n\t"+getPropertyName(rootProperty, getPropertyName(key))+"="+propertyValue);                
                    }
                    return false;
                }
            }
        }
        
        return true;
    }
    
    /**
     * Checks a single value against a list of regular expressions.
     */
    private boolean acceptValue(String value, String regExpValue, String rootProperty, String... key)
    {
        // If no property or zero length it matches.
        if (regExpValue == null || regExpValue.length() == 0)
        {
            return true;
        }
        
        for (String regExp: getRegExpList(regExpValue, rootProperty, key))
        {
            boolean includeExp = regExp.charAt(0) != NOT;
            if (!includeExp || regExp.startsWith(ESCAPED_NOT))
            {
                regExp = regExp.substring(1);
            }
            if (getPattern(regExp).matcher(value).matches())
            {
                return includeExp;
            }
        }        
        
        return false;
    }
    
    private Pattern getPattern(String regExp)
    {
        Pattern pattern = patternCache.get(regExp);
        if (pattern == null)
        {
            pattern = Pattern.compile(regExp);
            patternCache.put(regExp, pattern);
        }
        return pattern;
    }

    /**
     * @return the root property name for the default event action.
     */
    private String getDefaultRootProperty(String[] root)
    {
        String action = root[root.length-1];
        root[root.length-1] = DEFAULT;
        String defaultRootProperty = getPropertyName(PROPERY_NAME_PREFIX, getPropertyName(root));
        root[root.length-1] = action;
        return defaultRootProperty;
    }

    /**
     * @return the value of the property {@code rootProperty+'.'+getPropertyName(keyComponents)}
     * defaulting to {@code defaultRootProperty+'.'+getPropertyName(keyComponents)}.
     */
    private String getProperty(String rootProperty, String defaultRootProperty, String... keyComponents)
    {
        String keyName = getPropertyName(keyComponents);
        String propertyName = getPropertyName(rootProperty, keyName);
        String value = getProperty(null, propertyName);
        if (value == null)
        {
            value = getProperty(null, getPropertyName(defaultRootProperty, keyName));
        }
        return value;
    }
    
    /**
     * @return a property value, including redirected values (where the value
     * of a property starts with a {@code '$'} indicating it is another property
     * name).
     * @throws IllegalArgumentException if redirecting properties reference themselves.
     */
    private String getProperty(List<String> loopCheck, String propertyName)
    {
        String value = properties.getProperty(propertyName);

        // Handle redirection of properties.
        if (value != null && value.length() > 0 && value.charAt(0) == REDIRECT)
        {
            String newPropertyName = value.substring(1);
            if (loopCheck == null)
            {
                loopCheck = new ArrayList<String>();
            }
            if (loopCheck.contains(newPropertyName))
            {
                RuntimeException e = new IllegalArgumentException("Redirected property "+
                        newPropertyName+" referes back to itself.");
                logger.error("Error found in properties for audit filter.", e);
                throw e;
            }
            loopCheck.add(propertyName);
            value = getProperty(loopCheck, newPropertyName);
        }
        else if (value == null && loopCheck != null && !loopCheck.isEmpty())
        {
            RuntimeException e = new IllegalArgumentException("Redirected property "+
                    loopCheck.get(loopCheck.size()-1)+
                    " points to "+propertyName+" but it does not exist.");
            logger.error("Error found in properties for audit filter.", e);
            throw e;
        }
        
        return value;
    }

    /**
     * Returns a List of regular expressions from a property's String value.
     * A leading {@code '~'} indicating the regular expression should be used
     * to reject values. This may be escaped with a leading back slash
     * ({@code "\\~"}) if the first character must be a semicolon. Other
     * escape characters are removed. A check is made that no expression is
     * zero length. 
     * @return a List of regular expressions.
     * @throws IllegalArgumentException if there are any zero length expressions.
     */
    private List<String> getRegExpList(String value, String rootProperty, String... key)
    {
        // Split the value into substrings separated by ';'. This may be escaped using "\;".
        List<String> regExpList = new ArrayList<String>();
        {
            int j = 0;
            int i = j - 1;
            do
            {
                i = value.indexOf(';', i+1);
                if (i != -1)
                {
                    if (i == 0 || value.charAt(i-1) != '\\')
                    {
                        regExpList.add(value.substring(j, i));
                        j = i + 1;
                    }               
                }
            }
            while (i != -1);
            if (j < value.length()-1)
            {
                regExpList.add(value.substring(j));
            }
        }
        
        // Remove escape characters other than the NOT (\~)
        // \$ at the start becomes "$"
        // \; anywhere becomes ";"
        for (int i=regExpList.size()-1; i >= 0; i--)
        {
            String regExp = regExpList.get(i);
            if (regExp.startsWith(ESCAPED_REDIRECT))
            {
                regExp = regExp.substring(1);
            }
            regExp = regExp.replaceAll(ESCAPED_REG_EXP_SEPARATOR, REG_EXP_SEPARATOR);
            
            if (regExp.length() == 0 || (regExp.charAt(0) == NOT && regExp.length() == 1))
            {
                throw new IllegalArgumentException(getPropertyName(rootProperty, getPropertyName(key))+"="+value+
                        "includes an empty regular expression.");
            }       
            regExpList.set(i, regExp);
        }
        return regExpList;
    }

    /**
     * @return a property name from the supplied components. Each component is
     * separated by a {@code '.'}.
     */
    private String getPropertyName(String... components)
    {
        StringBuilder sb = new StringBuilder();
        for (String component: components)
        {
            if (sb.length() > 0)
            {
                sb.append(PROPERTY_SEPARATOR);
            }
            sb.append(component);
        }
        return sb.toString();
    }

    /**
     * @return a list of components separated by '/' characters.
     */
    private String[] splitPath(String path)
    {
        if (path.length() > 0 && path.charAt(0) == '/')
        {
            path = path.substring(1);
        }
        return path.split("/");
    }
}
