/*
 * 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.markup.head.http2;

import java.io.IOException;
import java.net.URL;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;

import jakarta.servlet.http.HttpServletRequest;
import org.apache.wicket.Page;
import org.apache.wicket.WicketRuntimeException;
import org.apache.wicket.markup.head.HeaderItem;
import org.apache.wicket.markup.html.WebPage;
import org.apache.wicket.protocol.http.WebApplication;
import org.apache.wicket.request.IRequestHandler;
import org.apache.wicket.request.Request;
import org.apache.wicket.request.Response;
import org.apache.wicket.request.Url;
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.request.mapper.parameter.PageParameters;
import org.apache.wicket.request.mapper.parameter.PageParametersEncoder;
import org.apache.wicket.request.resource.ResourceReference;
import org.apache.wicket.util.lang.Args;

/**
 * A push header item to be used in the http/2 context and to reduce the latency of the web
 * application. Follow these steps for your page:<br>
 * <br>
 * - Override the setHeaders method and don't call super.setHeaders to disable caching<br>
 * - Get the page request / response and store them as transient fields that are given into the
 * PushHeaderItem<br>
 * - Ensure a valid https connection (not self signed), because otherwise no caching information are
 * accepted from Chrome or other browsers
 *
 * @author Tobias Soloschenko
 *
 */
public class PushHeaderItem extends HeaderItem
{
	private static final long serialVersionUID = 1L;

	/**
	 * The header date formats for if-modified-since / last-modified
	 */
	private static final DateTimeFormatter headerDateFormat_RFC1123 = DateTimeFormatter
		.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz")
		.withLocale(java.util.Locale.ENGLISH)
		.withZone(ZoneOffset.UTC); // Sun, 06 Nov 1994 08:49:37 GMT ; RFC 822, updated by RFC 1123

	private static final DateTimeFormatter headerDateFormat_RFC1036 = DateTimeFormatter
		.ofPattern("EEEE, dd-MMM-yy HH:mm:ss zzz")
		.withLocale(java.util.Locale.ENGLISH)
		.withZone(ZoneOffset.UTC); // Sunday, 06-Nov-94 08:49:37 GMT ; RFC 850, obs. by RFC 1036

	private static final DateTimeFormatter headerDateFormat_ASCTIME = DateTimeFormatter
		.ofPattern("EEE MMM d HH:mm:ss yyyy")
		.withLocale(java.util.Locale.ENGLISH)
		.withZone(ZoneOffset.UTC); // Sun Nov 6 08:49:37 1994 ; ANSI C's asctime() format

	/**
	 * The http2 protocol string
	 */
	public static final String HTTP2_PROTOCOL = "http/2";

	/**
	 * The token suffix to be used in this header item
	 */
	private static final String TOKEN_SUFFIX = HTTP2_PROTOCOL + "_pushed";

	/**
	 * The URLs of resources to be pushed to the client
	 */
	private Set<PushItem> pushItems = ConcurrentHashMap.newKeySet();
	/**
	 * The web response of the page to apply the caching information to
	 */
	private WebResponse pageWebResponse;

	/**
	 * The web request of the page to get the caching information from
	 */
	private WebRequest pageWebRequest;

	/**
	 * The page to get the modification time of
	 */
	private Page page;

	private final IPushBuilder pushBuilder;

	/**
	 * Creates a push header item based on the given page and the corresponding page request / page
	 * response. To get the request and response
	 *
	 *
	 * @param page
	 *            the page this header item is applied to
	 * @param pageRequest
	 *            the page request this header item is applied to
	 * @param pageResponse
	 *            the page response this header item is applied to
	 */
	public PushHeaderItem(Page page, Request pageRequest, Response pageResponse)
	{
		this(page, pageRequest, pageResponse, new PushBuilder());
	}

	public PushHeaderItem(Page page, Request pageRequest, Response pageResponse, IPushBuilder pushBuilder)
	{
		if (!(page instanceof WebPage) || !(pageRequest instanceof WebRequest) || !(pageResponse instanceof WebResponse))
		{
			throw new WicketRuntimeException(
				"Please hand over the web page, the web request and the web response to the push header item like \"new PushHeaderItem(page, webRequest, webResponse)\" - " +
					"The webResponse / webRequest can be obtained via \"getRequestCycle().getRequest()\" / \"getRequestCycle().getResponse()\" and placed into the page as fields " +
					"\"private transient Response webPageResponse;\" / \"private transient Request webPageRequest;\"");
		}
		this.pageWebRequest = (WebRequest)pageRequest;
		this.pageWebResponse = (WebResponse)pageResponse;
		this.page = page;
		this.pushBuilder = Args.notNull(pushBuilder, "pushBuilder");
	}

	/**
	 * Uses the URLs that has already been pushed to the client to ensure not to push them again
	 */
	@Override
	public Iterable<?> getRenderTokens()
	{
		Set<String> tokens = new TreeSet<>();
		for (PushItem pushItem : pushItems)
		{
			tokens.add(pushItem.getUrl() + TOKEN_SUFFIX);
		}
		return tokens;
	}

	/**
	 * Gets the time the page of this header item has been modified. The default implementation is
	 * to get the last modification date of the HTML file of the corresponding page, but it can be
	 * overridden to apply a custom behavior. For example place in a properties-file into the class
	 * path which contains the compile time. <br>
	 * Example: <code>
	 * <pre>
	 * protected Time getPageModificationTime(){
	 * 	Time time = getPageModificationTime();
	 * 	// read properties file with build time and place it into a second time variable
	 * 	return time.before(buildTime) ? buildTime : time;
	 * }
	 * </pre>
	 * </code>
	 *
	 * @return the time the page of this header item has been modified
	 */
	protected Instant getPageModificationTime()
	{
		URL resource = page.getClass().getResource(page.getClass().getSimpleName() + ".html");
		if (resource == null)
		{
			throw new WicketRuntimeException(
				"The markup to the page couldn't be found: " + page.getClass().getName());
		}
		try
		{
			return Instant.ofEpochMilli(resource.openConnection().getLastModified());
		}
		catch (IOException e)
		{
			throw new WicketRuntimeException(
				"The time couln't be determined of the markup file of the page: " +
					page.getClass().getName(),
				e);
		}
	}

	/**
	 * Applies the cache header item to the response
	 */
	protected void applyPageCacheHeader()
	{
		// check modification of page html
		Instant pageModificationTime = getPageModificationTime();
		// The date of the page is now
		pageWebResponse.setDateHeader("Date", Instant.now());
		// Set the modification time so that the browser sends a "If-Modified-Since" header which
		// can be compared
		pageWebResponse.setLastModifiedTime(pageModificationTime);
		// Make the resource stale so that it gets revalidated even if a cache entry is set
		// (see http://stackoverflow.com/questions/11357430/http-expires-header-values-0-and-1)
		pageWebResponse.setHeader("Expires", "-1");
		// Set a cache but set it to max-age=0 / must-revalidate so that the request to the page is
		// done
		pageWebResponse.setHeader("Cache-Control",
			"max-age=0, public, must-revalidate, proxy-revalidate");
	}

	/**
	 * Pushes the previously created URLs to the client
	 */
	@Override
	public void render(Response response)
	{
		// applies the caching header to the actual page request
		applyPageCacheHeader();

		HttpServletRequest request = getContainerRequest(RequestCycle.get().getRequest());
		// Check if the protocol is http/2 or http/2.0 to only push the resources in this case
		if (isHttp2(request))
		{

			Instant pageModificationTime = getPageModificationTime();
			String ifModifiedSinceHeader = pageWebRequest.getHeader("If-Modified-Since");

			// Check if the if-modified-since header is set - if not push all resources
			if (ifModifiedSinceHeader != null)
			{

				// Try to parse RFC1123
				Instant ifModifiedSinceFromRequestTime = parseIfModifiedSinceHeader(
					ifModifiedSinceHeader, headerDateFormat_RFC1123);

				// Try to parse ASCTIME
				if (ifModifiedSinceFromRequestTime == null)
				{
					ifModifiedSinceFromRequestTime = parseIfModifiedSinceHeader(
						ifModifiedSinceHeader, headerDateFormat_ASCTIME);
				}

				// Try to parse RFC1036 - because it is obsolete due to RFC 1036 check this last.
				if (ifModifiedSinceFromRequestTime == null)
				{
					ifModifiedSinceFromRequestTime = parseIfModifiedSinceHeader(
						ifModifiedSinceHeader, headerDateFormat_RFC1036);
				}

				// if the modified since header is before the page modification time or if it can't
				// be parsed push it.
				if (ifModifiedSinceFromRequestTime == null ||
					ifModifiedSinceFromRequestTime.isBefore(pageModificationTime))
				{
					// Some browsers like IE 9-11 or Chrome 39 that does not send right headers
					// receive the resource via push all the time
					push(request);
				}
			}
			else
			{
				// Push the resources if the "if-modified-since" is not available
				push(request);
			}
		}
	}

	/**
	 * Parses the given if modified since header with the date time formatter
	 *
	 * @param ifModifiedSinceHeader
	 *            the if modified since header string
	 * @param dateTimeFormatter
	 *            the formatter to parse the header string with
	 * @return the time or null
	 */
	private Instant parseIfModifiedSinceHeader(String ifModifiedSinceHeader,
		DateTimeFormatter dateTimeFormatter)
	{
		try
		{
			return LocalDateTime
				.parse(ifModifiedSinceHeader, dateTimeFormatter)
				.toInstant(ZoneOffset.UTC);
		}
		catch (DateTimeParseException e)
		{
			// NOOP
		}
		return null;
	}

	/**
	 * Pushed all URLs of this header item to the client
	 *
	 * @param request
	 *            the request to push the URLs to
	 */
	protected void push(HttpServletRequest request)
	{
		pushBuilder.push(request, pushItems.toArray(new PushItem[0]));
	}

	/**
	 * Creates a URL and pushes the resource to the client - this is only supported if http2 is
	 * enabled
	 *
	 * @param pushItems
	 *            a list of items to be pushed to the client
	 * @return the current push header item
	 */
	@SuppressWarnings("unchecked")
	public PushHeaderItem push(List<PushItem> pushItems)
	{
		RequestCycle requestCycle = RequestCycle.get();
		if (isHttp2(getContainerRequest(requestCycle.getRequest())))
			for (PushItem pushItem : pushItems)
			{
				Object object = pushItem.getObject();
				PageParameters parameters = pushItem.getPageParameters();

				if (object == null)
				{
					throw new WicketRuntimeException(
						"Please provide an object to the items to be pushed, so that the url can be created for the given resource.");
				}

				CharSequence url;
				if (object instanceof ResourceReference)
				{
					url = requestCycle.urlFor((ResourceReference)object, parameters);
				}
				else if (Page.class.isAssignableFrom(object.getClass()))
				{
					url = requestCycle.urlFor((Class<? extends Page>)object, parameters);
				}
				else if (object instanceof IRequestHandler)
				{
					url = requestCycle.urlFor((IRequestHandler)object);
				}
				else if (pushItem.getUrl() != null)
				{
					url = pushItem.getUrl();
				}
				else
				{
					Url encoded = new PageParametersEncoder().encodePageParameters(parameters);
					String queryString = encoded.getQueryString();
					url = object.toString() + (queryString != null ? "?" + queryString : "");
				}

				if (url.toString().equals("."))
				{
					url = "/";
				}
				else if (url.toString().startsWith("."))
				{
					url = url.toString().substring(1);
				}

				// The context path and the filter have to be applied to the URL, because otherwise
				// the resource is not pushed correctly
				StringBuilder partialUrl = new StringBuilder();
				String contextPath = WebApplication.get().getServletContext().getContextPath();
				partialUrl.append(contextPath);
				if (!"/".equals(contextPath))
				{
					partialUrl.append('/');
				}
				String filterPath = WebApplication.get().getWicketFilter().getFilterPath();
				if ("/".equals(filterPath))
				{
					filterPath = "";
				}
				else if (filterPath.endsWith("/"))
				{
					filterPath = filterPath.substring(0, filterPath.length() - 1);
				}
				partialUrl.append(filterPath);
				partialUrl.append(url);

				// Set the url the resource is going to be pushed with
				pushItem.setUrl(partialUrl.toString());

				// Apply the push item to be used during the push process
				this.pushItems.add(pushItem);
			}
		return this;
	}

	/**
	 * Gets the container request
	 *
	 * @param request
	 *            the wicket request to get the container request from
	 * @return the container request
	 */
	public HttpServletRequest getContainerRequest(Request request)
	{

		return checkHttpServletRequest(request);
	}

	/**
	 * Checks if the given request is a http/2 request
	 *
	 * @param request
	 *            the request to check if it is a http/2 request
	 * @return if the request is a http/2 request
	 */
	public boolean isHttp2(HttpServletRequest request)
	{
		// detects http/2 and http/2.0
		return request.getProtocol().toLowerCase(Locale.ROOT).contains(HTTP2_PROTOCOL);
	}

	/**
	 * Checks if the container request from the given request is instance of
	 * {@link HttpServletRequest} if not the API of the PushHeaderItem can't be used and a
	 * {@link WicketRuntimeException} is thrown.
	 *
	 * @param request
	 *            the request to get the container request from. The container request is checked if
	 *            it is instance of {@link HttpServletRequest}
	 * @return the container request get from the given request casted to {@link HttpServletRequest}
	 * @throws WicketRuntimeException - if the container request is not a {@link HttpServletRequest}
	 */
	public HttpServletRequest checkHttpServletRequest(Request request)
	{
		Object assumedHttpServletRequest = request.getContainerRequest();
		if (!(assumedHttpServletRequest instanceof HttpServletRequest))
		{
			throw new WicketRuntimeException(
				"The request is not a HttpServletRequest - the usage of PushHeaderItem is not support in the current environment: " +
					request.getClass().getName());
		}
		return (HttpServletRequest)assumedHttpServletRequest;
	}

	@Override
	public boolean equals(Object o)
	{
		if (this == o)
			return true;
		if (o == null || getClass() != o.getClass())
			return false;
		PushHeaderItem that = (PushHeaderItem)o;
		return Objects.equals(pushItems, that.pushItems) &&
			Objects.equals(pageWebResponse, that.pageWebResponse) &&
			Objects.equals(pageWebRequest, that.pageWebRequest) && Objects.equals(page, that.page);
	}

	@Override
	public int hashCode()
	{
		return Objects.hash(pushItems, pageWebResponse, pageWebRequest, page);
	}
}
