/* 
    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.ui;

import org.apache.log4j.Logger;
import org.apache.wiki.InternalWikiException;
import org.apache.wiki.WikiEngine;
import org.apache.wiki.WikiPage;
import org.apache.wiki.WikiProvider;
import org.apache.wiki.api.exceptions.ProviderException;
import org.apache.wiki.auth.GroupPrincipal;
import org.apache.wiki.parser.MarkupParser;
import org.apache.wiki.util.TextUtil;

import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Properties;

/**
 * <p>Resolves special pages, JSPs and Commands on behalf of a
 * WikiEngine. CommandResolver will automatically resolve page names
 * with singular/plural variants. It can also detect the correct Command
 * based on parameters supplied in an HTTP request, or due to the
 * JSP being accessed.</p>
 * <p>
 * <p>CommandResolver's static {@link #findCommand(String)} method is
 * the simplest method; it looks up and returns the Command matching
 * a supplied wiki context. For example, looking up the request context
 * <code>view</code> returns {@link PageCommand#VIEW}. Use this method
 * to obtain static Command instances that aren't targeted at a particular
 * page or group.</p>
 * <p>For more complex lookups in which the caller supplies an HTTP
 * request, {@link #findCommand(HttpServletRequest, String)} will
 * look up and return the correct Command. The String parameter
 * <code>defaultContext</code> supplies the request context to use
 * if it cannot be detected. However, note that the default wiki
 * context may be over-ridden if the request was for a "special page."</p>
 * <p>For example, suppose the WikiEngine's properties specify a
 * special page called <code>UserPrefs</code>
 * that redirects to <code>UserPreferences.jsp</code>. The ordinary
 * lookup method {@linkplain #findCommand(String)} using a supplied
 * context <code>view</code> would return {@link PageCommand#VIEW}. But
 * the {@linkplain #findCommand(HttpServletRequest, String)} method,
 * when passed the same context (<code>view</code>) and an HTTP request
 * containing the page parameter value <code>UserPrefs</code>,
 * will instead return {@link WikiCommand#PREFS}.</p>
 * @since 2.4.22
 */
public final class CommandResolver
{
    /** Prefix in jspwiki.properties signifying special page keys. */
    private static final String PROP_SPECIALPAGE = "jspwiki.specialPage.";

    /** Private map with request contexts as keys, Commands as values */
    private static final Map<String, Command>    CONTEXTS;

    /** Private map with JSPs as keys, Commands as values */
    private static final Map<String, Command>    JSPS;

    /** Store the JSP-to-Command and context-to-Command mappings */
    static
    {
        CONTEXTS = new HashMap<String, Command>();
        JSPS = new HashMap<String, Command>();
        Command[] commands = AbstractCommand.allCommands();
        for( int i = 0; i < commands.length; i++ )
        {
            JSPS.put( commands[i].getJSP(), commands[i] );
            CONTEXTS.put( commands[i].getRequestContext(), commands[i] );
        }
    }

    private final Logger        m_log = Logger.getLogger( CommandResolver.class );

    private final WikiEngine    m_engine;

    /** If true, we'll also consider english plurals (+s) a match. */
    private final boolean       m_matchEnglishPlurals;

    /** Stores special page names as keys, and Commands as values. */
    private final Map<String, Command> m_specialPages;

    /**
     * Constructs a CommandResolver for a given WikiEngine. This constructor
     * will extract the special page references for this wiki and store them in
     * a cache used for resolution.
     * @param engine the wiki engine
     * @param properties the properties used to initialize the wiki
     */
    public CommandResolver( WikiEngine engine, Properties properties )
    {
        m_engine = engine;
        m_specialPages = new HashMap<String, Command>();

        // Skim through the properties and look for anything with
        // the "special page" prefix. Create maps that allow us
        // look up the correct Command based on special page name.
        // If a matching command isn't found, create a RedirectCommand.
        for(String key : properties.stringPropertyNames())
        {
            if ( key.startsWith( PROP_SPECIALPAGE ) )
            {
                String specialPage = key.substring( PROP_SPECIALPAGE.length() );
                String jsp = (String) properties.getProperty(key);
                if ( specialPage != null && jsp != null )
                {
                    specialPage = specialPage.trim();
                    jsp = jsp.trim();
                    Command command = JSPS.get( jsp );
                    if ( command == null )
                    {
                        Command redirect = RedirectCommand.REDIRECT;
                        command = redirect.targetedCommand( jsp );
                    }
                    m_specialPages.put( specialPage, command );
                }
            }
        }

        // Do we match plurals?
        m_matchEnglishPlurals = TextUtil.getBooleanProperty( properties, WikiEngine.PROP_MATCHPLURALS, true );
    }

    /**
     * Attempts to locate a wiki command for a supplied request context.
     * The resolution technique is simple: we examine the list of
     * Commands returned by {@link AbstractCommand#allCommands()} and
     * return the one whose <code>requestContext</code> matches the
     * supplied context. If the supplied context does not resolve to a known
     * Command, this method throws an {@link IllegalArgumentException}.
     * @param context the request context
     * @return the resolved context
     */
    public static Command findCommand( String context )
    {
        Command command = CONTEXTS.get( context );
        if ( command == null )
        {
            throw new IllegalArgumentException( "Unsupported wiki context: " + context + "." );
        }
        return command;
    }

    /**
     * <p>
     * Attempts to locate a Command for a supplied wiki context and HTTP
     * request, incorporating the correct WikiPage into the command if reqiured.
     * This method will first determine what page the user requested by
     * delegating to {@link #extractPageFromParameter(String, HttpServletRequest)}. If
     * this page equates to a special page, we return the Command
     * corresponding to that page. Otherwise, this method simply returns the
     * Command for the supplied request context.
     * </p>
     * <p>
     * The reason this method attempts to resolve against special pages is
     * because some of them resolve to contexts that may be different from the
     * one supplied. For example, a VIEW request context for the special page
     * "UserPreferences" should return a PREFS context instead.
     * </p>
     * <p>
     * When the caller supplies a request context and HTTP request that
     * specifies an actual wiki page (rather than a special page), this method
     * will return a "targeted" Command that includes the resolved WikiPage
     * as the target. (See {@link #resolvePage(HttpServletRequest, String)}
     * for the resolution algorithm). Specifically, the Command will
     * return a non-<code>null</code> value for its {@link AbstractCommand#getTarget()} method.
     * </p>
     * <p><em>Note: if this method determines that the Command is the VIEW PageCommand,
     * then the Command returned will always be targeted to the front page.</em></p>
     * @param request the HTTP request; if <code>null</code>, delegates
     * to {@link #findCommand(String)}
     * @param defaultContext the request context to use by default
     * @return the resolved wiki command
     */
    public Command findCommand( HttpServletRequest request, String defaultContext )
    {
        // Corner case if request is null
        if ( request == null )
        {
            return findCommand( defaultContext );
        }

        Command command = null;

        // Determine the name of the page (which may be null)
        String pageName = extractPageFromParameter( defaultContext, request );

        // Can we find a special-page command matching the extracted page?
        if ( pageName != null )
        {
            command = m_specialPages.get( pageName );
        }

        // If we haven't found a matching command yet, extract the JSP path
        // and compare to our list of special pages
        if ( command == null )
        {
            command = extractCommandFromPath( request );

            // Otherwise: use the default context
            if ( command == null )
            {
                command = CONTEXTS.get( defaultContext );
                if ( command == null )
                {
                    throw new IllegalArgumentException( "Wiki context " + defaultContext + " is illegal." );
                }
            }
        }

        // For PageCommand.VIEW, default to front page if a page wasn't supplied
        if( PageCommand.VIEW.equals( command ) && pageName == null )
        {
            pageName = m_engine.getFrontPage();
        }

        // These next blocks handle targeting requirements

        // If we were passed a page parameter, try to resolve it
        if ( command instanceof PageCommand && pageName != null )
        {
            // If there's a matching WikiPage, "wrap" the command
            WikiPage page = resolvePage( request, pageName );
            if ( page != null )
            {
                return command.targetedCommand( page );
            }
        }

        // If "create group" command, target this wiki
        String wiki = m_engine.getApplicationName();
        if ( WikiCommand.CREATE_GROUP.equals( command ) )
        {
            return WikiCommand.CREATE_GROUP.targetedCommand( wiki );
        }

        // If group command, see if we were passed a group name
        if ( command instanceof GroupCommand )
        {
            String groupName = request.getParameter( "group" );
            groupName = TextUtil.replaceEntities( groupName );
            if ( groupName != null && groupName.length() > 0 )
            {
                GroupPrincipal group = new GroupPrincipal( groupName );
                return command.targetedCommand( group );
            }
        }

        // No page provided; return an "ordinary" command
        return command;
    }

    /**
     * <p>
     * Returns the correct page name, or <code>null</code>, if no such page can be found.
     * Aliases are considered.
     * </p>
     * <p>
     * In some cases, page names can refer to other pages. For example, when you
     * have matchEnglishPlurals set, then a page name "Foobars" will be
     * transformed into "Foobar", should a page "Foobars" not exist, but the
     * page "Foobar" would. This method gives you the correct page name to refer
     * to.
     * </p>
     * <p>
     * This facility can also be used to rewrite any page name, for example, by
     * using aliases. It can also be used to check the existence of any page.
     * </p>
     * @since 2.4.20
     * @param page the page name.
     * @return The rewritten page name, or <code>null</code>, if the page does not exist.
     * @throws ProviderException if the underlyng page provider that locates pages
     * throws an exception
     */
    public String getFinalPageName( String page ) throws ProviderException
    {
        boolean isThere = simplePageExists( page );
        String  finalName = page;

        if ( !isThere && m_matchEnglishPlurals )
        {
            if ( page.endsWith( "s" ) )
            {
                finalName = page.substring( 0, page.length() - 1 );
            }
            else
            {
                finalName += "s";
            }

            isThere = simplePageExists( finalName );
        }

        if( !isThere )
        {
            finalName = MarkupParser.wikifyLink( page );
            isThere = simplePageExists(finalName);

            if( !isThere && m_matchEnglishPlurals )
            {
                if( finalName.endsWith( "s" ) )
                {
                    finalName = finalName.substring( 0, finalName.length() - 1 );
                }
                else
                {
                    finalName += "s";
                }

                isThere = simplePageExists( finalName );
            }
        }

        return isThere ? finalName : null;
    }

    /**
     * <p>
     * If the page is a special page, this method returns a direct URL to that
     * page; otherwise, it returns <code>null</code>.
     * </p>
     * <p>
     * Special pages are non-existant references to other pages. For example,
     * you could define a special page reference "RecentChanges" which would
     * always be redirected to "RecentChanges.jsp" instead of trying to find a
     * Wiki page called "RecentChanges".
     * </p>
     * @param page the page name ro search for
     * @return the URL of the special page, if the supplied page is one, or <code>null</code>
     */
    public String getSpecialPageReference( String page )
    {
        Command command = m_specialPages.get( page );

        if ( command != null )
        {
            return m_engine.getURLConstructor()
                    .makeURL( command.getRequestContext(), command.getURLPattern(), true, null );
        }

        return null;
    }

    /**
     * Extracts a Command based on the JSP path of an HTTP request.
     * If the JSP requested matches a Command's <code>getJSP()</code>
     * value, that Command is returned.
     * @param request the HTTP request
     * @return the resolved Command, or <code>null</code> if not found
     */
    protected Command extractCommandFromPath( HttpServletRequest request )
    {
        String jsp = request.getServletPath();

        // Take everything to right of initial / and left of # or ?
        int hashMark = jsp.indexOf( '#' );
        if ( hashMark != -1 )
        {
            jsp = jsp.substring( 0, hashMark );
        }
        int questionMark = jsp.indexOf( '?' );
        if ( questionMark != -1 )
        {
            jsp = jsp.substring( 0, questionMark );
        }
        if ( jsp.startsWith( "/" ) )
        {
            jsp = jsp.substring( 1 );
        }

        // Find special page reference?
        for( Iterator< Map.Entry< String, Command > > i = m_specialPages.entrySet().iterator(); i.hasNext(); )
        {
            Map.Entry< String, Command > entry = i.next();
            Command specialCommand = entry.getValue();
            if ( specialCommand.getJSP().equals( jsp ) )
            {
                return specialCommand;
            }
        }

        // Still haven't found a matching command?
        // Ok, see if we match against our standard list of JSPs
        if ( jsp.length() > 0 && JSPS.containsKey( jsp ) )
        {
            return JSPS.get( jsp );
        }

        return null;
    }

    /**
     * Determines the correct wiki page based on a supplied request context and
     * HTTP request. This method attempts to determine the page requested by a
     * user, taking into acccount special pages. The resolution algorithm will:
     * <ul>
     * <li>Extract the page name from the URL according to the rules for the
     * current {@link org.apache.wiki.url.URLConstructor}. If a page name was
     * passed in the request, return the correct name after taking into account
     * potential plural matches.</li>
     * <li>If the extracted page name is <code>null</code>, attempt to see
     * if a "special page" was intended by examining the servlet path. For
     * example, the request path "/UserPreferences.jsp" will resolve to
     * "UserPreferences."</li>
     * <li>If neither of these methods work, this method returns
     * <code>null</code></li>
     * </ul>
     * @param requestContext the request context
     * @param request the HTTP request
     * @return the resolved page name
     */
    protected String extractPageFromParameter( final String requestContext, final HttpServletRequest request ) {
        String page;

        // Extract the page name from the URL directly
        try {
            page = m_engine.getURLConstructor().parsePage( requestContext, request, m_engine.getContentEncoding() );
            if ( page != null ) {
                try {
                    // Look for singular/plural variants; if one
                    // not found, take the one the user supplied
                    final String finalPage = getFinalPageName( page );
                    if ( finalPage != null ) {
                        page = finalPage;
                    }
                } catch( final ProviderException e ) {
                    // FIXME: Should not ignore!
                }
                return page;
            }
        } catch( final IOException e ) {
            m_log.error( "Unable to create context", e );
            throw new InternalWikiException( "Big internal booboo, please check logs." , e);
        }

        // Didn't resolve; return null
        return null;
    }

    /**
     * Looks up and returns the correct, versioned WikiPage based on a supplied
     * page name and optional <code>version</code> parameter passed in an HTTP
     * request. If the <code>version</code> parameter does not exist in the
     * request, the latest version is returned.
     * @param request the HTTP request
     * @param page the name of the page to look up; this page <em>must</em> exist
     * @return the wiki page
     */
    protected WikiPage resolvePage( HttpServletRequest request, String page )
    {
        // See if the user included a version parameter
        WikiPage wikipage;
        int version = WikiProvider.LATEST_VERSION;
        String rev = request.getParameter( "version" );

        if ( rev != null )
        {
            try
            {
                version = Integer.parseInt( rev );
            }
            catch( NumberFormatException e )
            {
                // This happens a lot with bots or other guys who are trying
                // to test if we are vulnerable to e.g. XSS attacks.  We catch
                // it here so that the admin does not get tons of mail.
            }
        }

        wikipage = m_engine.getPage( page, version );

        if ( wikipage == null )
        {
            page = MarkupParser.cleanLink( page );
            wikipage = new WikiPage( m_engine, page );
        }
        return wikipage;
    }

    /**
     * Determines whether a "page" exists by examining the list of special pages
     * and querying the page manager.
     * @param page the page to seek
     * @return <code>true</code> if the page exists, <code>false</code>
     *         otherwise
     * @throws ProviderException if the underlyng page provider that locates pages
     * throws an exception
     */
    protected boolean simplePageExists( String page ) throws ProviderException
    {
        if ( m_specialPages.containsKey( page ) )
        {
            return true;
        }
        return m_engine.getPageManager().pageExists( page );
    }

}
