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

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.wicket.Application;
import org.apache.wicket.Component;
import org.apache.wicket.behavior.Behavior;
import org.apache.wicket.core.request.handler.IPartialPageRequestHandler;
import org.apache.wicket.markup.html.DecoratingHeaderResponse;
import org.apache.wicket.request.cycle.RequestCycle;
import org.apache.wicket.request.resource.ResourceReference;
import org.apache.wicket.resource.CircularDependencyException;
import org.apache.wicket.resource.bundles.ReplacementResourceBundleReference;
import org.apache.wicket.util.lang.Classes;

/**
 * {@code ResourceAggregator} implements resource dependencies, resource bundles and sorting of
 * resources. During the rendering of components, all {@link HeaderItem}s are
 * {@linkplain RecordedHeaderItem recorded} and processed at the end.
 * 
 * @author papegaaij
 */
public class ResourceAggregator extends DecoratingHeaderResponse
{
	/**
	 * The location in which a {@link HeaderItem} is added, consisting of the component/behavior
	 * that added the item, the index in the list for that component/behavior at which the item was
	 * added and the index in the request.
	 * 
	 * @author papegaaij
	 */
	public static class RecordedHeaderItemLocation
	{
		private final Object renderBase;
		private final int indexInRenderBase;
		private final int indexInRequest;

		/**
		 * Construct.
		 * 
		 * @param renderBase
		 *            The component or behavior that added the item.
		 * @param indexInRenderBase
		 *            Indicates the number of items added before this one on the same component or
		 *            behavior.
		 * @param indexInRequest
		 *            Indicates the number of items added before this one in the same request.
		 */
		public RecordedHeaderItemLocation(Object renderBase, int indexInRenderBase,
			int indexInRequest)
		{
			this.renderBase = renderBase;
			this.indexInRenderBase = indexInRenderBase;
			this.indexInRequest = indexInRequest;
		}

		/**
		 * @return the component or behavior that added the item.
		 */
		public Object getRenderBase()
		{
			return renderBase;
		}

		/**
		 * @return the number of items added before this one on the same component or behavior.
		 */
		public int getIndexInRenderBase()
		{
			return indexInRenderBase;
		}

		/**
		 * @return the number of items added before this one in the same request.
		 */
		public int getIndexInRequest()
		{
			return indexInRequest;
		}

		@Override
		public String toString()
		{
			return (renderBase == null ? "null" : Classes.simpleName(renderBase.getClass())) + '@' +
				indexInRenderBase;
		}
	}

	/**
	 * Contains information about an {@link HeaderItem} that must be rendered.
	 * 
	 * @author papegaaij
	 */
	public static class RecordedHeaderItem
	{
		private final HeaderItem item;

		private final List<RecordedHeaderItemLocation> locations;

		/**
		 * Construct.
		 * 
		 * @param item
		 */
		public RecordedHeaderItem(HeaderItem item)
		{
			this.item = item;
			locations = new ArrayList<>();
		}

		/**
		 * Records a location at which the item was added.
		 * 
		 * @param renderBase
		 *            The component or behavior that added the item.
		 * @param indexInRenderBase
		 *            Indicates the number of items added before this one on the same component or
		 *            behavior.
		 * @param indexInRequest
		 *            Indicates the number of items added before this one in this request.
		 */
		public void addLocation(Object renderBase, int indexInRenderBase, int indexInRequest)
		{
			locations.add(new RecordedHeaderItemLocation(renderBase, indexInRenderBase,
				indexInRequest));
		}

		/**
		 * @return the actual item
		 */
		public HeaderItem getItem()
		{
			return item;
		}

		/**
		 * @return The locations at which the item was added.
		 */
		public List<RecordedHeaderItemLocation> getLocations()
		{
			return locations;
		}

		@Override
		public String toString()
		{
			return locations + ":" + item;
		}
	}

	private final Map<HeaderItem, RecordedHeaderItem> itemsToBeRendered;

	/**
	 * Header items which should be executed once the DOM is ready.
	 * Collects OnDomReadyHeaderItems and OnEventHeaderItems
	 */
	private final List<HeaderItem> domReadyItemsToBeRendered;
	private final List<OnLoadHeaderItem> loadItemsToBeRendered;

	private Object renderBase;
	private int indexInRenderBase;
	private int indexInRequest;

	/**
	 * Construct.
	 * 
	 * @param real
	 */
	public ResourceAggregator(IHeaderResponse real)
	{
		super(real);

		itemsToBeRendered = new LinkedHashMap<>();
		domReadyItemsToBeRendered = new ArrayList<>();
		loadItemsToBeRendered = new ArrayList<>();
	}

	@Override
	public void markRendered(Object object)
	{
		super.markRendered(object);
		if (object instanceof Component || object instanceof Behavior)
		{
			renderBase = null;
			indexInRenderBase = 0;
		}
	}

	@Override
	public boolean wasRendered(Object object)
	{
		boolean ret = super.wasRendered(object);
		if (!ret && object instanceof Component || object instanceof Behavior)
		{
			renderBase = object;
			indexInRenderBase = 0;
		}
		return ret;
	}

	private void recordHeaderItem(HeaderItem item, Set<HeaderItem> depsDone)
	{
		renderDependencies(item, depsDone);
		RecordedHeaderItem recordedItem = itemsToBeRendered.get(item);
		if (recordedItem == null)
		{
			recordedItem = new RecordedHeaderItem(item);
			itemsToBeRendered.put(item, recordedItem);
		}
		recordedItem.addLocation(renderBase, indexInRenderBase, indexInRequest);
		indexInRenderBase++;
		indexInRequest++;
	}

	private void renderDependencies(HeaderItem item, Set<HeaderItem> depsDone)
	{
		for (HeaderItem curDependency : item.getDependencies())
		{
			curDependency = getItemToBeRendered(curDependency);
			if (depsDone.add(curDependency))
			{
				recordHeaderItem(curDependency, depsDone);
			}
			else
			{
				throw new CircularDependencyException(depsDone, curDependency);
			}
			depsDone.remove(curDependency);
		}
	}

	@Override
	public void render(HeaderItem item)
	{
		item = getItemToBeRendered(item);
		if (item instanceof OnDomReadyHeaderItem || item instanceof OnEventHeaderItem)
		{
			renderDependencies(item, new LinkedHashSet<HeaderItem>());
			domReadyItemsToBeRendered.add(item);
		}
		else if (item instanceof OnLoadHeaderItem)
		{
			renderDependencies(item, new LinkedHashSet<HeaderItem>());
			loadItemsToBeRendered.add((OnLoadHeaderItem)item);
		}
		else
		{
			Set<HeaderItem> depsDone = new LinkedHashSet<>();
			depsDone.add(item);
			recordHeaderItem(item, depsDone);
		}
	}

	@Override
	public void close()
	{
		renderHeaderItems();

		if (RequestCycle.get().find(IPartialPageRequestHandler.class).isPresent())
		{
			renderSeparateEventScripts();
		}
		else
		{
			renderCombinedEventScripts();
		}
		super.close();
	}

	/**
	 * Renders all normal header items, sorting them and taking bundles into account.
	 */
	private void renderHeaderItems()
	{
		List<RecordedHeaderItem> sortedItemsToBeRendered = new ArrayList<>(
			itemsToBeRendered.values());
		Comparator<? super RecordedHeaderItem> headerItemComparator = Application.get()
			.getResourceSettings()
			.getHeaderItemComparator();
		if (headerItemComparator != null)
		{
			Collections.sort(sortedItemsToBeRendered, headerItemComparator);
		}
		for (RecordedHeaderItem curRenderItem : sortedItemsToBeRendered)
		{
			if (markItemRendered(curRenderItem.getItem()))
			{
				getRealResponse().render(curRenderItem.getItem());
			}
		}
	}

	/**
	 * Combines all DOM ready and onLoad scripts and renders them as 2 script tags.
	 */
	private void renderCombinedEventScripts()
	{
		StringBuilder combinedScript = new StringBuilder();
		for (HeaderItem curItem : domReadyItemsToBeRendered)
		{
			if (markItemRendered(curItem))
			{
				combinedScript.append('\n');
				if (curItem instanceof OnDomReadyHeaderItem)
				{
					combinedScript.append(((OnDomReadyHeaderItem)curItem).getJavaScript());
				} else if (curItem instanceof OnEventHeaderItem)
				{
					combinedScript.append(((OnEventHeaderItem)curItem).getCompleteJavaScript());
				}
				combinedScript.append(';');
			}
		}
		if (combinedScript.length() > 0)
		{
			combinedScript.append("\nWicket.Event.publish(Wicket.Event.Topic.AJAX_HANDLERS_BOUND);");
			getRealResponse().render(
				OnDomReadyHeaderItem.forScript(combinedScript.append('\n').toString()));
		}

		combinedScript.setLength(0);
		for (OnLoadHeaderItem curItem : loadItemsToBeRendered)
		{
			if (markItemRendered(curItem))
			{
				combinedScript.append('\n');
				combinedScript.append(curItem.getJavaScript());
				combinedScript.append(';');
			}
		}
		if (combinedScript.length() > 0)
		{
			getRealResponse().render(
				OnLoadHeaderItem.forScript(combinedScript.append('\n').toString()));
		}
	}

	/**
	 * Renders the DOM ready and onLoad scripts as separate tags.
	 */
	private void renderSeparateEventScripts()
	{
		for (HeaderItem curItem : domReadyItemsToBeRendered)
		{
			if (markItemRendered(curItem))
			{
				getRealResponse().render(curItem);
			}
		}

		for (OnLoadHeaderItem curItem : loadItemsToBeRendered)
		{
			if (markItemRendered(curItem))
			{
				getRealResponse().render(curItem);
			}
		}
	}

	private boolean markItemRendered(HeaderItem item)
	{
		if (wasRendered(item))
			return false;

		if (item instanceof IWrappedHeaderItem)
		{
			getRealResponse().markRendered(((IWrappedHeaderItem)item).getWrapped());
		}
		getRealResponse().markRendered(item);
		for (HeaderItem curProvided : item.getProvidedResources())
		{
			getRealResponse().markRendered(curProvided);
		}
		return true;
	}

	/**
	 * Resolves the actual item that needs to be rendered for the given item. This can be a
	 * {@link NoHeaderItem} when the item was already rendered. It can also be a bundle or the item
	 * itself, when it is not part of a bundle.
	 * 
	 * @param item
	 * @return The item to be rendered
	 */
	private HeaderItem getItemToBeRendered(HeaderItem item)
	{
		HeaderItem innerItem = item;
		while (innerItem instanceof IWrappedHeaderItem)
		{
			innerItem = ((IWrappedHeaderItem)innerItem).getWrapped();
		}
		if (getRealResponse().wasRendered(innerItem))
		{
			return NoHeaderItem.get();
		}

		HeaderItem bundle = Application.get().getResourceBundles().findBundle(innerItem);
		if (bundle == null)
		{
			return item;
		}

		bundle = preserveDetails(item, bundle);

		if (item instanceof IWrappedHeaderItem)
		{
			bundle = ((IWrappedHeaderItem)item).wrap(bundle);
		}
		return bundle;
	}

	/**
	 * Preserves the resource reference details for resource replacements.
	 *
	 * For example if CSS resource with media <em>screen</em> is replaced with
	 * {@link org.apache.wicket.protocol.http.WebApplication#addResourceReplacement(org.apache.wicket.request.resource.CssResourceReference, org.apache.wicket.request.resource.ResourceReference)} then the replacement will
	 * will inherit the media attribute
	 *
	 * @param item   The replaced header item
	 * @param bundle The bundle that represents the replacement
	 * @return the bundle with the preserved details
	 */
	protected HeaderItem preserveDetails(HeaderItem item, HeaderItem bundle)
	{
		HeaderItem resultBundle;
		if (item instanceof CssReferenceHeaderItem && bundle instanceof CssReferenceHeaderItem)
		{
			CssReferenceHeaderItem originalHeaderItem = (CssReferenceHeaderItem) item;
			resultBundle = preserveCssDetails(originalHeaderItem, (CssReferenceHeaderItem) bundle);
		}
		else if (item instanceof JavaScriptReferenceHeaderItem && bundle instanceof JavaScriptReferenceHeaderItem)
		{
			JavaScriptReferenceHeaderItem originalHeaderItem = (JavaScriptReferenceHeaderItem) item;
			resultBundle = preserveJavaScriptDetails(originalHeaderItem, (JavaScriptReferenceHeaderItem) bundle);
		}
		else
		{
			resultBundle = bundle;
		}

		return resultBundle;
	}

	/**
	 * Preserves the resource reference details for JavaScript resource replacements.
	 *
	 * For example if CSS resource with media <em>screen</em> is replaced with
	 * {@link org.apache.wicket.protocol.http.WebApplication#addResourceReplacement(org.apache.wicket.request.resource.JavaScriptResourceReference, org.apache.wicket.request.resource.ResourceReference)} then the replacement will
	 * will inherit the media attribute
	 *
	 * @param item   The replaced header item
	 * @param bundle The bundle that represents the replacement
	 * @return the bundle with the preserved details
	 */
	private HeaderItem preserveJavaScriptDetails(JavaScriptReferenceHeaderItem item, JavaScriptReferenceHeaderItem bundle)
	{
		HeaderItem resultBundle;
		ResourceReference bundleReference = bundle.getReference();
		if (bundleReference instanceof ReplacementResourceBundleReference)
		{
			resultBundle = JavaScriptHeaderItem.forReference(bundleReference,
					item.getPageParameters(),
					item.getId(),
					item.isDefer(),
					item.getCharset(),
					item.getCondition()
			);
		}
		else
		{
			resultBundle = bundle;
		}
		return resultBundle;
	}

	/**
	 * Preserves the resource reference details for CSS resource replacements.
	 *
	 * For example if CSS resource with media <em>screen</em> is replaced with
	 * {@link org.apache.wicket.protocol.http.WebApplication#addResourceReplacement(org.apache.wicket.request.resource.CssResourceReference, org.apache.wicket.request.resource.ResourceReference)} then the replacement will
	 * will inherit the media attribute
	 *
	 * @param item   The replaced header item
	 * @param bundle The bundle that represents the replacement
	 * @return the bundle with the preserved details
	 */
	protected HeaderItem preserveCssDetails(CssReferenceHeaderItem item, CssReferenceHeaderItem bundle)
	{
		HeaderItem resultBundle;
		ResourceReference bundleReference = bundle.getReference();
		if (bundleReference instanceof ReplacementResourceBundleReference)
		{
			resultBundle = CssHeaderItem.forReference(bundleReference,
					item.getPageParameters(),
					item.getMedia(),
					item.getCondition());
		}
		else
		{
			resultBundle = bundle;
		}
		return resultBundle;
	}
}
