/*
    Licensed to the Apache Software Foundation (ASF) under one
    or more contributor license agreements.  See the NOTICE file
    distributed with this work for additional information
    regarding copyright ownership.  The ASF licenses this file
    to you under the Apache License, Version 2.0 (the
    "License"); you may not use this file except in compliance
    with the License.  You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

    Unless required by applicable law or agreed to in writing,
    software distributed under the License is distributed on an
    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
    KIND, either express or implied.  See the License for the
    specific language governing permissions and limitations
    under the License.  
 */
package org.apache.wiki.plugin;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.oro.text.GlobCompiler;
import org.apache.oro.text.regex.MalformedPatternException;
import org.apache.oro.text.regex.Pattern;
import org.apache.oro.text.regex.PatternCompiler;
import org.apache.oro.text.regex.PatternMatcher;
import org.apache.oro.text.regex.Perl5Matcher;
import org.apache.wiki.WikiBackgroundThread;
import org.apache.wiki.api.core.Context;
import org.apache.wiki.api.core.ContextEnum;
import org.apache.wiki.api.core.Engine;
import org.apache.wiki.api.core.Page;
import org.apache.wiki.api.exceptions.PluginException;
import org.apache.wiki.api.plugin.InitializablePlugin;
import org.apache.wiki.api.plugin.Plugin;
import org.apache.wiki.event.WikiEngineEvent;
import org.apache.wiki.event.WikiEvent;
import org.apache.wiki.event.WikiEventListener;
import org.apache.wiki.event.WikiPageEvent;
import org.apache.wiki.event.WikiPageRenameEvent;
import org.apache.wiki.references.ReferenceManager;
import org.apache.wiki.render.RenderingManager;
import org.apache.wiki.util.TextUtil;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.text.MessageFormat;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Properties;
import java.util.TreeMap;


/**
 * This plugin counts the number of times a page has been viewed.<br/>
 * Parameters:
 * <ul>
 * <li>count=yes|no</li>
 * <li>show=none|count|list</li>
 * <li>entries=maximum number of list entries to be returned</li>
 * <li>min=minimum page count to be listed</li>
 * <li>max=maximum page count to be listed</li>
 * <li>sort=name|count</li>
 * </ul>
 * Default values:<br/>
 * <code>show=none  sort=name</code>
 * 
 * @since 2.8
 */
public class PageViewPlugin extends AbstractReferralPlugin implements Plugin, InitializablePlugin {

    private static final Logger LOG = LogManager.getLogger( PageViewPlugin.class );

    /** The page view manager. */
    private static PageViewManager c_singleton;

    /** Constant for the 'count' parameter / value. */
    private static final String PARAM_COUNT = "count";

    /** Name of the 'entries' parameter. */
    private static final String PARAM_MAX_ENTRIES = "entries";

    /** Name of the 'max' parameter. */
    private static final String PARAM_MAX_COUNT = "max";

    /** Name of the 'min' parameter. */
    private static final String PARAM_MIN_COUNT = "min";

    /** Name of the 'refer' parameter. */
    private static final String PARAM_REFER = "refer";

    /** Name of the 'sort' parameter. */
    private static final String PARAM_SORT = "sort";

    /** Constant for the 'none' parameter value. */
    private static final String STR_NONE = "none";

    /** Constant for the 'list' parameter value. */
    private static final String STR_LIST = "list";

    /** Constant for the 'yes' parameter value. */
    private static final String STR_YES = "yes";

    /** Constant for empty string. */
    private static final String STR_EMPTY = "";

    /** Constant for Wiki markup separator. */
    private static final String STR_SEPARATOR = "----";

    /** Constant for comma-separated list separator. */
    private static final String STR_COMMA = ",";

    /** Constant for no-op glob expression. */
    private static final String STR_GLOBSTAR = "*";

    /** Constant for file storage. */
    private static final String COUNTER_PAGE = "PageCount.txt";

    /** Constant for storage interval in seconds. */
    private static final int STORAGE_INTERVAL = 60;

    /**
     * Initialize the PageViewPlugin and its singleton.
     * 
     * @param engine The wiki engine.
     */
    @Override
    public void initialize( final Engine engine ) {
        LOG.info( "initializing PageViewPlugin" );
        synchronized( this ) {
            if( c_singleton == null ) {
                c_singleton = new PageViewManager();
            }
            c_singleton.initialize( engine );
        }
    }

    /**
     * Cleanup the singleton reference.
     */
    private void cleanup() {
        LOG.info( "cleaning up PageView Manager" );
        c_singleton = null;
    }

    /**
     *  {@inheritDoc}
     */
    @Override
    public String execute( final Context context, final Map< String, String > params ) throws PluginException {
        final PageViewManager manager = c_singleton;
        String result = STR_EMPTY;

        if( manager != null ) {
            result = manager.execute( context, params );
        }

        return result;
    }

    /**
     * Page view manager, handling all storage.
     */
    public final class PageViewManager implements WikiEventListener {
        /** Are we initialized? */
        private boolean m_initialized;

        /** The page counters. */
        private Map<String, Counter> m_counters;

        /** The page counters in storage format. */
        private Properties m_storage;

        /** Are all changes stored? */
        private boolean m_dirty;

        /** The page count storage background thread. */
        private Thread m_pageCountSaveThread;

        /** The work directory. */
        private String m_workDir;

        /** Comparator for descending sort on page count. */
        private final Comparator< Object > m_compareCountDescending = ( o1, o2 ) -> {
            final int v1 = getCount( o1 );
            final int v2 = getCount( o2 );
            return ( v1 == v2 ) ? ( ( String )o1 ).compareTo( ( String )o2 ) : ( v1 < v2 ) ? 1 : -1;
        };

        /**
         * Initialize the page view manager.
         * 
         * @param engine The wiki engine.
         */
        public synchronized void initialize( final Engine engine ) {
            LOG.info( "initializing PageView Manager" );
            m_workDir = engine.getWorkDir();
            engine.addWikiEventListener( this );
            if( m_counters == null ) {
                // Load the counters into a collection
                m_storage = new Properties();
                m_counters = new TreeMap<>();

                loadCounters();
            }

            // backup counters every 5 minutes
            if( m_pageCountSaveThread == null ) {
                m_pageCountSaveThread = new CounterSaveThread( engine, 5 * STORAGE_INTERVAL, this );
                m_pageCountSaveThread.start();
            }

            m_initialized = true;
        }

        /**
         * Handle the shutdown event via the page counter thread.
         */
        private synchronized void handleShutdown() {
            LOG.info( "handleShutdown: The counter store thread was shut down." );

            cleanup();

            if( m_counters != null ) {

                m_dirty = true;
                storeCounters();

                m_counters.clear();
                m_counters = null;

                m_storage.clear();
                m_storage = null;
            }

            m_initialized = false;

            m_pageCountSaveThread = null;
        }

        /**
         * Inspect wiki events for shutdown.
         * 
         * @param event The wiki event to inspect.
         */
        @Override
        public void actionPerformed( final WikiEvent event ) {
            if( event instanceof WikiEngineEvent ) {
                if( event.getType() == WikiEngineEvent.SHUTDOWN ) {
                    LOG.info( "Detected wiki engine shutdown" );
                    handleShutdown();
                }
            } else if( ( event instanceof WikiPageRenameEvent ) && ( event.getType() == WikiPageRenameEvent.PAGE_RENAMED ) ) {
                final String oldPageName = ( ( WikiPageRenameEvent )event ).getOldPageName();
                final String newPageName = ( ( WikiPageRenameEvent )event ).getNewPageName();
                final Counter oldCounter = m_counters.get( oldPageName );
                if( oldCounter != null ) {
                    m_storage.remove( oldPageName );
                    m_counters.put( newPageName, oldCounter );
                    m_storage.setProperty( newPageName, oldCounter.toString() );
                    m_counters.remove( oldPageName );
                    m_dirty = true;
                }
            } else if( ( event instanceof WikiPageEvent ) && ( event.getType() == WikiPageEvent.PAGE_DELETED ) ) {
                final String pageName = ( ( WikiPageEvent )event ).getPageName();
                m_storage.remove( pageName );
                m_counters.remove( pageName );
            }
        }

        /**
         * Count a page hit, present a pages' counter or output a list of page counts.
         * 
         * @param context the wiki context
         * @param params the plugin parameters
         * @return String Wiki page snippet
         * @throws PluginException Malformed pattern parameter.
         */
        public String execute( final Context context, final Map< String, String > params ) throws PluginException {
            final Engine engine = context.getEngine();
            final Page page = context.getPage();
            String result = STR_EMPTY;

            if( page != null ) {
                // get parameters
                final String pagename = page.getName();
                String count = params.get( PARAM_COUNT );
                final String show = params.get( PARAM_SHOW );
                int entries = TextUtil.parseIntParameter( params.get( PARAM_MAX_ENTRIES ), Integer.MAX_VALUE );
                final int max = TextUtil.parseIntParameter( params.get( PARAM_MAX_COUNT ), Integer.MAX_VALUE );
                final int min = TextUtil.parseIntParameter( params.get( PARAM_MIN_COUNT ), Integer.MIN_VALUE );
                final String sort = params.get( PARAM_SORT );
                final String body = params.get( DefaultPluginManager.PARAM_BODY );
                final Pattern[] exclude = compileGlobs( PARAM_EXCLUDE, params.get( PARAM_EXCLUDE ) );
                final Pattern[] include = compileGlobs( PARAM_INCLUDE, params.get( PARAM_INCLUDE ) );
                final Pattern[] refer = compileGlobs( PARAM_REFER, params.get( PARAM_REFER ) );
                final PatternMatcher matcher = (null != exclude || null != include || null != refer) ? new Perl5Matcher() : null;
                boolean increment = false;

                // increment counter?
                if( STR_YES.equals( count ) ) {
                    increment = true;
                } else {
                    count = null;
                }

                // default increment counter?
                if( ( show == null || STR_NONE.equals( show ) ) && count == null ) {
                    increment = true;
                }

                // filter on referring pages?
                Collection< String > referrers = null;

                if( refer != null ) {
                    final ReferenceManager refManager = engine.getManager( ReferenceManager.class );
                    for( final String name : refManager.findCreated() ) {
                        boolean use = false;
                        for( int n = 0; !use && n < refer.length; n++ ) {
                            use = matcher.matches( name, refer[ n ] );
                        }

                        if( use ) {
                            final Collection< String > refs = engine.getManager( ReferenceManager.class ).findReferrers( name );
                            if( refs != null && !refs.isEmpty() ) {
                                if( referrers == null ) {
                                    referrers = new HashSet<>();
                                }
                                referrers.addAll( refs );
                            }
                        }
                    }
                }

                synchronized( this ) {
                    Counter counter = m_counters.get( pagename );

                    // only count in view mode, keep storage values in sync
                    if( increment && ContextEnum.PAGE_VIEW.getRequestContext().equalsIgnoreCase( context.getRequestContext() ) ) {
                        if( counter == null ) {
                            counter = new Counter();
                            m_counters.put( pagename, counter );
                        }
                        counter.increment();
                        m_storage.setProperty( pagename, counter.toString() );
                        m_dirty = true;
                    }

                    if( show == null || STR_NONE.equals( show ) ) {
                        // nothing to show

                    } else if( PARAM_COUNT.equals( show ) ) {
                        // show page count
                        if( counter == null ) {
                            counter = new Counter();
                            m_counters.put( pagename, counter );
                            m_storage.setProperty( pagename, counter.toString() );
                            m_dirty = true;
                        }
                        result = counter.toString();

                    } else if( body != null && !body.isEmpty() && STR_LIST.equals( show ) ) {
                        // show list of counts
                        String header = STR_EMPTY;
                        String line = body;
                        String footer = STR_EMPTY;
                        int start = body.indexOf( STR_SEPARATOR );

                        // split body into header, line, footer on ---- separator
                        if( 0 < start ) {
                            header = body.substring( 0, start );
                            start = skipWhitespace( start + STR_SEPARATOR.length(), body );
                            int end = body.indexOf( STR_SEPARATOR, start );
                            if( start >= end ) {
                                line = body.substring( start );
                            } else {
                                line = body.substring( start, end );
                                end = skipWhitespace( end + STR_SEPARATOR.length(), body );
                                footer = body.substring( end );
                            }
                        }

                        // sort on name or count?
                        Map< String, Counter > sorted = m_counters;
                        if( PARAM_COUNT.equals( sort ) ) {
                            sorted = new TreeMap<>( m_compareCountDescending );
                            sorted.putAll( m_counters );
                        }

                        // build a messagebuffer with the list in wiki markup
                        final StringBuffer buf = new StringBuffer( header );
                        final MessageFormat fmt = new MessageFormat( line );
                        final Object[] args = new Object[] { pagename, STR_EMPTY, STR_EMPTY };
                        final Iterator< Entry< String, Counter > > iter = sorted.entrySet().iterator();

                        while( 0 < entries && iter.hasNext() ) {
                            final Entry< String, Counter > entry = iter.next();
                            final String name = entry.getKey();

                            // check minimum/maximum count
                            final int value = entry.getValue().getValue();
                            boolean use = min <= value && value <= max;

                            // did we specify a refer-to page?
                            if( use && referrers != null ) {
                                use = referrers.contains( name );
                            }

                            // did we specify what pages to include?
                            if( use && include != null ) {
                                use = false;

                                for( int n = 0; !use && n < include.length; n++ ) {
                                    use = matcher.matches( name, include[ n ] );
                                }
                            }

                            // did we specify what pages to exclude?
                            if( use && null != exclude ) {
                                for( int n = 0; use && n < exclude.length; n++ ) {
                                    use = !matcher.matches( name, exclude[ n ] );
                                }
                            }

                            if( use ) {
                                args[ 1 ] = engine.getManager( RenderingManager.class ).beautifyTitle( name );
                                args[ 2 ] = entry.getValue();

                                fmt.format( args, buf, null );

                                entries--;
                            }
                        }
                        buf.append( footer );

                        // let the engine render the list
                        result = engine.getManager( RenderingManager.class ).textToHTML( context, buf.toString() );
                    }
                }
            }
            return result;
        }

        /**
         * Compile regexp parameter.
         * 
         * @param name The name of the parameter.
         * @param value The parameter value.
         * @return Pattern[] The compiled patterns, or <code>null</code>.
         * @throws PluginException On malformed patterns.
         */
        private Pattern[] compileGlobs( final String name, final String value ) throws PluginException {
            Pattern[] result = null;
            if( value != null && !value.isEmpty() && !STR_GLOBSTAR.equals( value ) ) {
                try {
                    final PatternCompiler pc = new GlobCompiler();
                    final String[] ptrns = StringUtils.split( value, STR_COMMA );
                    result = new Pattern[ ptrns.length ];

                    for( int n = 0; n < ptrns.length; n++ ) {
                        result[ n ] = pc.compile( ptrns[ n ] );
                    }
                } catch( final MalformedPatternException e ) {
                    throw new PluginException( "Parameter " + name + " has a malformed pattern: " + e.getMessage() );
                }
            }

            return result;
        }

        /**
         * Adjust offset skipping whitespace.
         * 
         * @param offset The offset in value to adjust.
         * @param value String in which offset points.
         * @return int Adjusted offset into value.
         */
        private int skipWhitespace( int offset, final String value ) {
            while( Character.isWhitespace( value.charAt( offset ) ) ) {
                offset++;
            }
            return offset;
        }

        /**
         * Retrieve a page count.
         * 
         * @return int The page count for the given key.
         * @param key the key for the Counter
         */
        int getCount( final Object key )
        {
            return m_counters.get( key ).getValue();
        }

        /**
         * Load the page view counters from file.
         */
        private void loadCounters() {
            if( m_counters != null && m_storage != null ) {
                LOG.info( "Loading counters." );
                synchronized( this ) {
                    try( final InputStream fis = Files.newInputStream( new File( m_workDir, COUNTER_PAGE ).toPath() ) ) {
                        m_storage.load( fis );
                    } catch( final IOException ioe ) {
                        LOG.error( "Can't load page counter store: " + ioe.getMessage() + " , will create a new one!" );
                    }

                    // Copy the collection into a sorted map
                    for( final Entry< ?, ? > entry : m_storage.entrySet() ) {
                        m_counters.put( ( String )entry.getKey(), new Counter( ( String )entry.getValue() ) );
                    }
                    
                    LOG.info( "Loaded " + m_counters.size() + " counter values." );
                }
            }
        }

        /**
         * Save the page view counters to file.
         */
        void storeCounters() {
            if( m_counters != null && m_storage != null && m_dirty ) {
                LOG.info( "Storing " + m_counters.size() + " counter values." );
                synchronized( this ) {
                    // Write out the collection of counters
                    try( final OutputStream fos = Files.newOutputStream( new File( m_workDir, COUNTER_PAGE ).toPath() ) ) {
                        m_storage.store( fos, "\n# The number of times each page has been viewed.\n# Do not modify.\n" );
                        fos.flush();

                        m_dirty = false;
                    } catch( final IOException ioe ) {
                        LOG.error( "Couldn't store counters values: " + ioe.getMessage() );
                    }
                }
            }
        }

        /**
         * Is the given thread still current?
         *
         * @param thrd thread that can be the current background thread.
         * @return boolean <code>true</code> if the thread is still the current background thread.
         */
        private synchronized boolean isRunning( final Thread thrd )
        {
            return m_initialized && thrd == m_pageCountSaveThread;
        }

    }

    /** Counter for page hits collection. */
    private static final class Counter {

        /** The count value. */
        private int m_count;

        /**
         * Create a new counter.
         */
        public Counter() {
        }

        /**
         * Create and initialize a new counter.
         * 
         * @param value Count value.
         */
        public Counter( final String value )
        {
            setValue( value );
        }

        /**
         * Increment counter.
         */
        public void increment()
        {
            m_count++;
        }

        /**
         * Get the count value.
         * 
         * @return int
         */
        public int getValue()
        {
            return m_count;
        }

        /**
         * Set the count value.
         * 
         * @param value String representation of the count.
         */
        public void setValue( final String value )
        {
            m_count = NumberUtils.toInt( value );
        }

        /**
         * @return String representation of the count.
         */
        @Override
        public String toString()
        {
            return String.valueOf( m_count );
        }

    }

    /**
     * Background thread storing the page counters.
     */
    static final class CounterSaveThread extends WikiBackgroundThread {

        /** The page view manager. */
        private final PageViewManager m_manager;

        /**
         * Create a wiki background thread to store the page counters.
         * 
         * @param engine The wiki engine.
         * @param interval Delay in seconds between saves.
         * @param pageViewManager page view manager.
         */
        public CounterSaveThread( final Engine engine, final int interval, final PageViewManager pageViewManager ) {
            super( engine, interval );
            if( pageViewManager == null ) {
                throw new IllegalArgumentException( "Manager cannot be null" );
            }

            m_manager = pageViewManager;
        }

        /**
         * Save the page counters to file.
         */
        @Override
        public void backgroundTask() {
            if( m_manager.isRunning( this ) ) {
                m_manager.storeCounters();
            }
        }
    }
}
