/*
 * 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.wicket.protocol.http;

import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.util.HashSet;
import java.util.Set;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.apache.wicket.Session;
import org.apache.wicket.ThreadContext;
import org.apache.wicket.WicketRuntimeException;
import org.apache.wicket.protocol.http.servlet.ResponseIOException;
import org.apache.wicket.request.cycle.RequestCycle;
import org.apache.wicket.request.http.WebRequest;
import org.apache.wicket.request.http.WebResponse;
import org.apache.wicket.util.file.WebXmlFile;
import org.apache.wicket.util.lang.Args;
import org.apache.wicket.util.string.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Filter for initiating handling of Wicket requests.
 * <p>
 * The advantage of a filter is that, unlike a servlet, it can choose not to process the request and
 * let whatever is next in chain try. So when using a Wicket filter and a request comes in for
 * foo.gif the filter can choose not to process it because it knows it is not a wicket-related
 * request. Since the filter didn't process it, it falls on to the application server to try, and
 * then it works."
 * 
 * @see WicketServlet for documentation
 * 
 * @author Jonathan Locke
 * @author Timur Mehrvarz
 * @author Juergen Donnerstag
 * @author Igor Vaynberg (ivaynberg)
 * @author Al Maw
 * @author jcompagner
 * @author Matej Knopp
 */
public class WicketFilter implements Filter
{
	private static final Logger log = LoggerFactory.getLogger(WicketFilter.class);

	/** The name of the root path parameter that specifies the root dir of the app. */
	public static final String FILTER_MAPPING_PARAM = "filterMappingUrlPattern";

	/** The name of the context parameter that specifies application factory class */
	public static final String APP_FACT_PARAM = "applicationFactoryClassName";

	/**
	 * Name of parameter used to express a comma separated list of paths that should be ignored
	 */
	public static final String IGNORE_PATHS_PARAM = "ignorePaths";

	// Wicket's Application object
	private WebApplication application;

	/** the factory used to create the web aplication instance */
	private IWebApplicationFactory applicationFactory;

	private FilterConfig filterConfig;

	private String filterPath;

	// filterPath length without trailing "/"
	private int filterPathLength = -1;

	/** set of paths that should be ignored by the wicket filter */
	private final Set<String> ignorePaths = new HashSet<String>();

	/**
	 * A flag indicating whether WicketFilter is used directly or through WicketServlet
	 */
	private boolean isServlet = false;

	/**
	 * default constructor, usually invoked through the servlet container by the web.xml
	 * configuration
	 */
	public WicketFilter()
	{
	}

	/**
	 * constructor supporting programmatic setup of the filter
	 * <p/>
	 * this can be useful for programmatically creating and appending the wicket filter to the
	 * servlet context using servlet 3 features.
	 * 
	 * @param application
	 *            web application
	 */
	public WicketFilter(WebApplication application)
	{
		this.application = Args.notNull(application, "application");
	}

	/**
	 * @return The class loader
	 */
	protected ClassLoader getClassLoader()
	{
		return Thread.currentThread().getContextClassLoader();
	}

	/**
	 * This is Wicket's main method to execute a request
	 * 
	 * @param request
	 * @param response
	 * @param chain
	 * @return false, if the request could not be processed
	 * @throws IOException
	 * @throws ServletException
	 */
	boolean processRequest(ServletRequest request, final ServletResponse response,
		final FilterChain chain) throws IOException, ServletException
	{
		final ThreadContext previousThreadContext = ThreadContext.detach();

		// Assume we are able to handle the request
		boolean res = true;

		final ClassLoader previousClassLoader = Thread.currentThread().getContextClassLoader();
		final ClassLoader newClassLoader = getClassLoader();

		HttpServletRequest httpServletRequest = (HttpServletRequest)request;
		HttpServletResponse httpServletResponse = (HttpServletResponse)response;

		boolean ioExceptionOccurred = false;
		try
		{
			if (previousClassLoader != newClassLoader)
			{
				Thread.currentThread().setContextClassLoader(newClassLoader);
			}

			// Make sure getFilterPath() gets called before checkIfRedirectRequired()
			String filterPath = getFilterPath(httpServletRequest);

			if (filterPath == null)
			{
				throw new IllegalStateException("filter path was not configured");
			}

			if (shouldIgnorePath(httpServletRequest))
			{
				log.debug("Ignoring request {}", httpServletRequest.getRequestURL());
				if (chain != null)
				{
					// invoke next filter from within Wicket context
					chain.doFilter(request, response);
				}
				return false;
			}

			if ("OPTIONS".equalsIgnoreCase(httpServletRequest.getMethod()))
			{
				// handle the OPTIONS request outside of normal request processing.
				// wicket pages normally only support GET and POST methods, but resources and
				// special pages acting like REST clients can also support other methods, so
				// we include them all.
				httpServletResponse.setStatus(HttpServletResponse.SC_OK);
				httpServletResponse.setHeader("Allow",
					"GET,POST,OPTIONS,PUT,HEAD,PATCH,DELETE,TRACE");
				httpServletResponse.setHeader("Content-Length", "0");
				return true;
			}

			String redirectURL = checkIfRedirectRequired(httpServletRequest);
			if (redirectURL == null)
			{
				// No redirect; process the request
				ThreadContext.setApplication(application);

				WebRequest webRequest = application.createWebRequest(httpServletRequest, filterPath);
				WebResponse webResponse = application.createWebResponse(webRequest,
					httpServletResponse);

				RequestCycle requestCycle = application.createRequestCycle(webRequest, webResponse);
				res = processRequestCycle(requestCycle, webResponse, httpServletRequest,
					httpServletResponse, chain);
			}
			else
			{
				if (Strings.isEmpty(httpServletRequest.getQueryString()) == false)
				{
					redirectURL += "?" + httpServletRequest.getQueryString();
				}

				// send redirect - this will discard POST parameters if the request is POST
				// - still better than getting an error because of lacking trailing slash
				httpServletResponse.sendRedirect(httpServletResponse.encodeRedirectURL(redirectURL));
			}
		}
		catch (IOException e)
		{
			ioExceptionOccurred = true;
			throw e;
		}
		catch (ResponseIOException e)
		{
			ioExceptionOccurred = true;
			throw e.getCause();
		}
		finally
		{
			ThreadContext.restore(previousThreadContext);

			if (newClassLoader != previousClassLoader)
			{
				Thread.currentThread().setContextClassLoader(previousClassLoader);
			}

			if (!ioExceptionOccurred && response.isCommitted() &&
				!httpServletRequest.isAsyncStarted())
			{
				try
				{
					response.flushBuffer();
				}
				catch (ResponseIOException e)
				{
					throw e.getCause();
				}
			}
		}
		return res;
	}

	/**
	 * Process the request cycle
	 * 
	 * @param requestCycle
	 * @param webResponse
	 * @param httpServletRequest
	 * @param httpServletResponse
	 * @param chain
	 * @return false, if the request could not be processed
	 * @throws IOException
	 * @throws ServletException
	 */
	protected boolean processRequestCycle(RequestCycle requestCycle, WebResponse webResponse,
		HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
		final FilterChain chain) throws IOException, ServletException
	{
		boolean reqProcessed;
		try
		{
			reqProcessed = requestCycle.processRequest();
			if (reqProcessed)
			{
				webResponse.flush();
			}
		}
		finally
		{
			requestCycle.detach();
		}

		if (!reqProcessed)
		{
			if (chain != null)
			{
				// invoke next filter from within Wicket context
				chain.doFilter(httpServletRequest, httpServletResponse);
			}
		}
		return reqProcessed;
	}

	/**
	 * @see jakarta.servlet.Filter#doFilter(jakarta.servlet.ServletRequest,
	 *      jakarta.servlet.ServletResponse, jakarta.servlet.FilterChain)
	 */
	@Override
	public void doFilter(final ServletRequest request, final ServletResponse response,
		final FilterChain chain) throws IOException, ServletException
	{
		processRequest(request, response, chain);
	}

	/**
	 * Creates the web application factory instance.
	 * 
	 * If no APP_FACT_PARAM is specified in web.xml ContextParamWebApplicationFactory will be used
	 * by default.
	 * 
	 * @see ContextParamWebApplicationFactory
	 * 
	 * @return application factory instance
	 */
	protected IWebApplicationFactory getApplicationFactory()
	{
		final String appFactoryClassName = filterConfig.getInitParameter(APP_FACT_PARAM);

		if (appFactoryClassName == null)
		{
			// If no context param was specified we return the default factory
			return new ContextParamWebApplicationFactory();
		}
		else
		{
			try
			{
				// Try to find the specified factory class
				// see http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6500212
				// final Class<?> factoryClass = Thread.currentThread()
				// .getContextClassLoader()
				// .loadClass(appFactoryClassName);
				final Class<?> factoryClass = Class.forName(appFactoryClassName, false,
					Thread.currentThread().getContextClassLoader());

				// Instantiate the factory
				return (IWebApplicationFactory)factoryClass.getDeclaredConstructor().newInstance();
			}
			catch (ClassCastException e)
			{
				throw new WicketRuntimeException("Application factory class " +
					appFactoryClassName + " must implement IWebApplicationFactory");
			}
			catch (ClassNotFoundException | InstantiationException | IllegalAccessException | SecurityException
					| NoSuchMethodException | InvocationTargetException e)
			{
				throw new WebApplicationFactoryCreationException(appFactoryClassName, e);
			}
		}
	}

	/**
	 * If you do have a need to subclass, you may subclass {@link #init(boolean, FilterConfig)}
	 * 
	 * @see jakarta.servlet.Filter#init(jakarta.servlet.FilterConfig)
	 */
	@Override
	public final void init(final FilterConfig filterConfig) throws ServletException
	{
		init(false, filterConfig);
	}

	/**
	 * Servlets and Filters are treated essentially the same with Wicket. This is the entry point
	 * for both of them.
	 * 
	 * @see #init(FilterConfig)
	 * 
	 * @param isServlet
	 *            True if Servlet, false if Filter
	 * @param filterConfig
	 * @throws ServletException
	 */
	public void init(final boolean isServlet, final FilterConfig filterConfig)
		throws ServletException
	{
		this.filterConfig = filterConfig;
		this.isServlet = isServlet;
		initIgnorePaths(filterConfig);

		final ClassLoader previousClassLoader = Thread.currentThread().getContextClassLoader();
		final ClassLoader newClassLoader = getClassLoader();
		try
		{
			if (previousClassLoader != newClassLoader)
			{
				Thread.currentThread().setContextClassLoader(newClassLoader);
			}

			// locate application instance unless it was already specified during construction
			if (application == null)
			{
				applicationFactory = getApplicationFactory();
				application = applicationFactory.createApplication(this);
			}

			if (application.getName() == null)
			{
				application.setName(filterConfig.getFilterName());
			}
			application.setWicketFilter(this);

			// Allow the filterPath to be preset via setFilterPath()
			String configureFilterPath = getFilterPath();

			if (configureFilterPath == null)
			{
				configureFilterPath = getFilterPathFromConfig(filterConfig);

				if (configureFilterPath == null)
				{
					configureFilterPath = getFilterPathFromWebXml(isServlet, filterConfig);

					if (configureFilterPath == null)
					{
						configureFilterPath = getFilterPathFromAnnotation(isServlet);
					}
				}

				if (configureFilterPath != null)
				{
					setFilterPath(configureFilterPath);
				}
			}

			if (getFilterPath() == null)
			{
				log.warn("Unable to determine filter path from filter init-param, web.xml, "
					+ "or servlet 3.0 annotations. Assuming user will set filter path "
					+ "manually by calling setFilterPath(String)");
			}

			ThreadContext.setApplication(application);
			try
			{
				application.initApplication();

				// Give the application the option to log that it is started
				application.logStarted();
			}
			finally
			{
				ThreadContext.detach();
			}
		}
		catch (Exception e)
		{
			// #destroy() might not be called by the web container when #init() fails,
			// so destroy now
			log.error(String.format("The initialization of an application with name '%s' has failed.",
					filterConfig.getFilterName()), e);

			try
			{
				destroy();
			}
			catch (Exception destroyException)
			{
				log.error("Unable to destroy after initialization failure", destroyException);
			}

			throw new ServletException(e);
		}
		finally
		{
			if (newClassLoader != previousClassLoader)
			{
				Thread.currentThread().setContextClassLoader(previousClassLoader);
			}
		}
	}

	/**
	 * Stub method that lets subclasses configure filter path from annotations.
	 * 
	 * @param isServlet
	 * @return Filter path from annotation
	 */
	protected String getFilterPathFromAnnotation(boolean isServlet)
	{
		String[] patterns = null;

		if (isServlet)
		{
			WebServlet servlet = getClass().getAnnotation(WebServlet.class);
			if (servlet != null)
			{
				if (servlet.urlPatterns().length > 0)
				{
					patterns = servlet.urlPatterns();
				}
				else
				{
					patterns = servlet.value();
				}
			}
		}
		else
		{
			WebFilter filter = getClass().getAnnotation(WebFilter.class);
			if (filter != null)
			{
				if (filter.urlPatterns().length > 0)
				{
					patterns = filter.urlPatterns();
				}
				else
				{
					patterns = filter.value();
				}
			}
		}

		if (patterns != null && patterns.length > 0)
		{
			String pattern = patterns[0];
			if (patterns.length > 1)
			{
				log.warn(
						"Multiple url patterns defined for Wicket filter/servlet, using the first: {}",
						pattern);
			}

			if ("/*".equals(pattern))
			{
				pattern = "";
			}

			if (pattern.endsWith("*"))
			{
				pattern = pattern.substring(0, pattern.length() - 1);
			}
			return pattern;
		}
		return null;
	}

	/**
	 * 
	 * @param isServlet
	 * @param filterConfig
	 * @return filter path from web.xml
	 */
	protected String getFilterPathFromWebXml(final boolean isServlet,
		final FilterConfig filterConfig)
	{
		return new WebXmlFile().getUniqueFilterPath(isServlet, filterConfig);
	}

	/**
	 * @return filter config
	 */
	public FilterConfig getFilterConfig()
	{
		return filterConfig;
	}

	/**
	 * Either get the filterPath retrieved from web.xml, or if not found the old (1.3) way via a
	 * filter mapping param.
	 * 
	 * @param request
	 * @return filterPath
	 */
	protected String getFilterPath(final HttpServletRequest request)
	{
		return filterPath;
	}

	/**
	 * Provide a standard getter for filterPath.
	 * 
	 * @return The configured filterPath.
	 */
	public String getFilterPath()
	{
		return filterPath;
	}

	/**
	 * 
	 * @param filterConfig
	 * @return filter path
	 */
	protected String getFilterPathFromConfig(FilterConfig filterConfig)
	{
		String result = filterConfig.getInitParameter(FILTER_MAPPING_PARAM);
		if (result != null)
		{
			if (result.equals("/*"))
			{
				result = "";
			}
			else if (!result.startsWith("/") || !result.endsWith("/*"))
			{
				throw new WicketRuntimeException("Your " + FILTER_MAPPING_PARAM +
					" must start with \"/\" and end with \"/*\". It is: " + result);
			}
			else
			{
				// remove leading "/" and trailing "*"
				result = result.substring(1, result.length() - 1);
			}
		}
		return result;
	}

	/**
	 * @see jakarta.servlet.Filter#destroy()
	 */
	@Override
	public void destroy()
	{
		if (application != null)
		{
			try
			{
				ThreadContext.setApplication(application);
				application.internalDestroy();
			}
			finally
			{
				ThreadContext.detach();
				application = null;
			}
		}

		if (applicationFactory != null)
		{
			try
			{
				applicationFactory.destroy(this);
			}
			finally
			{
				applicationFactory = null;
			}
		}
	}

	/**
	 * Try to determine as fast as possible if a redirect is necessary
	 * 
	 * @param request
	 * @return null, if no redirect is necessary. Else the redirect URL
	 */
	private String checkIfRedirectRequired(final HttpServletRequest request)
	{
		return checkIfRedirectRequired(request.getRequestURI(), request.getContextPath());
	}

	/**
	 * Try to determine as fast as possible if a redirect is necessary
	 * 
	 * @param requestURI
	 * @param contextPath
	 * @return null, if no redirect is necessary. Else the redirect URL
	 */
	protected final String checkIfRedirectRequired(final String requestURI, final String contextPath)
	{
		// length without jsessionid (http://.../abc;jsessionid=...?param)
		int uriLength = requestURI.indexOf(';');
		if (uriLength == -1)
		{
			uriLength = requestURI.length();
		}

		// request.getContextPath() + "/" + filterPath. But without any trailing "/".
		int homePathLength = contextPath.length() +
			(filterPathLength > 0 ? 1 + filterPathLength : 0);
		if (uriLength != homePathLength)
		{
			// requestURI and homePath are different (in length)
			// => continue with standard request processing. No redirect.
			return null;
		}

		// Fail fast failed. Revert to "slow" but exact check
		String uri = Strings.stripJSessionId(requestURI);

		// home page without trailing slash URI
		String homePageUri = contextPath + '/' + getFilterPath();
		if (homePageUri.endsWith("/"))
		{
			homePageUri = homePageUri.substring(0, homePageUri.length() - 1);
		}

		// If both are equal => redirect
		if (uri.equals(homePageUri))
		{
			uri += "/";
			return uri;
		}

		// no match => standard request processing; no redirect
		return null;
	}

	/**
	 * Sets the filter path instead of reading it from web.xml.
	 * 
	 * Please note that you must subclass WicketFilter.init(FilterConfig) and set your filter path
	 * before you call super.init(filterConfig).
	 * 
	 * @param filterPath
	 */
	public final void setFilterPath(String filterPath)
	{
		// see https://issues.apache.org/jira/browse/WICKET-701
		if (this.filterPath != null)
		{
			throw new IllegalStateException(
				"Filter path is write-once. You can not change it. Current value='" + filterPath +
					'\'');
		}
		if (filterPath != null)
		{
			filterPath = canonicaliseFilterPath(filterPath);

			// We only need to determine it once. It'll not change.
			if (filterPath.endsWith("/"))
			{
				filterPathLength = filterPath.length() - 1;
			}
			else
			{
				filterPathLength = filterPath.length();
			}
		}
		this.filterPath = filterPath;
	}

	/**
	 * Returns a relative path to the filter path and context root from an HttpServletRequest - use
	 * this to resolve a Wicket request.
	 * 
	 * @param request
	 * @return Path requested, minus query string, context path, and filterPath. Relative, no
	 *         leading '/'.
	 */
	public String getRelativePath(HttpServletRequest request)
	{
		String path = Strings.stripJSessionId(request.getRequestURI());
		String contextPath = request.getContextPath();
		path = path.substring(contextPath.length());
		if (isServlet)
		{
			String servletPath = request.getServletPath();
			path = path.substring(servletPath.length());
		}

		if (path.length() > 0)
		{
			path = path.substring(1);
		}

		// We should always be under the rootPath, except
		// for the special case of someone landing on the
		// home page without a trailing slash.
		String filterPath = getFilterPath();
		if (!path.startsWith(filterPath))
		{
			if (filterPath.equals(path + "/"))
			{
				path += "/";
			}
		}
		if (path.startsWith(filterPath))
		{
			path = path.substring(filterPath.length());
		}

		return path;

	}

	protected WebApplication getApplication()
	{
		return application;
	}

	/**
	 * Checks whether this is a request to an ignored path
	 * 
	 * @param request
	 *            the current http request
	 * @return {@code true} when the request should be ignored, {@code false} - otherwise
	 */
	private boolean shouldIgnorePath(final HttpServletRequest request)
	{
		boolean ignore = false;
		if (ignorePaths.size() > 0)
		{
			String relativePath = getRelativePath(request);
			if (Strings.isEmpty(relativePath) == false)
			{
				for (String path : ignorePaths)
				{
					if (relativePath.startsWith(path))
					{
						ignore = true;
						break;
					}
				}
			}
		}

		return ignore;
	}

	/**
	 * initializes the ignore paths parameter
	 * 
	 * @param filterConfig
	 */
	private void initIgnorePaths(final FilterConfig filterConfig)
	{
		String paths = filterConfig.getInitParameter(IGNORE_PATHS_PARAM);
		if (Strings.isEmpty(paths) == false)
		{
			String[] parts = Strings.split(paths, ',');
			for (String path : parts)
			{
				path = path.trim();
				if (path.startsWith("/"))
				{
					path = path.substring(1);
				}
				ignorePaths.add(path);
			}
		}
	}

	/**
	 * A filterPath should have all leading slashes removed and exactly one trailing slash. A
	 * wildcard asterisk character has no special meaning. If your intention is to mean the top
	 * level "/" then an empty string should be used instead.
	 * 
	 * @param filterPath
	 * @return canonic filter path
	 */
	static String canonicaliseFilterPath(String filterPath)
	{
		if (Strings.isEmpty(filterPath))
		{
			return filterPath;
		}

		int beginIndex = 0;
		int endIndex = filterPath.length();
		while (beginIndex < endIndex)
		{
			char c = filterPath.charAt(beginIndex);
			if (c != '/')
			{
				break;
			}
			beginIndex++;
		}
		int o;
		int i = o = beginIndex;
		while (i < endIndex)
		{
			char c = filterPath.charAt(i);
			i++;
			if (c != '/')
			{
				o = i;
			}
		}
		if (o < endIndex)
		{
			o++; // include exactly one trailing slash
			filterPath = filterPath.substring(beginIndex, o);
		}
		else
		{
			// ensure to append trailing slash
			filterPath = filterPath.substring(beginIndex) + '/';
		}

		if (filterPath.equals("/"))
		{
			return "";
		}
		return filterPath;
	}
}
