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

import java.io.IOException;
import java.io.InputStream;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;

import jakarta.servlet.http.HttpServletResponse;

import org.apache.wicket.Application;
import org.apache.wicket.MetaDataKey;
import org.apache.wicket.WicketRuntimeException;
import org.apache.wicket.request.HttpHeaderCollection;
import org.apache.wicket.request.Request;
import org.apache.wicket.request.Response;
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.resource.caching.IResourceCachingStrategy;
import org.apache.wicket.request.resource.caching.IStaticCacheableResource;
import org.apache.wicket.util.io.Streams;
import org.apache.wicket.util.lang.Args;
import org.apache.wicket.util.lang.Classes;
import org.apache.wicket.util.string.Strings;

/**
 * Convenience resource implementation. The subclass must implement
 * {@link #newResourceResponse(org.apache.wicket.request.resource.IResource.Attributes)} method.
 * 
 * @author Matej Knopp
 * @author Tobias Soloschenko
 */
public abstract class AbstractResource implements IResource
{
	private static final long serialVersionUID = 1L;

	/** header values that are managed internally and must not be set directly */
	public static final Set<String> INTERNAL_HEADERS;

	/** The meta data key of the content range start byte **/
	public static final MetaDataKey<Long> CONTENT_RANGE_STARTBYTE = new MetaDataKey<>()
	{
		private static final long serialVersionUID = 1L;
	};

	/** The meta data key of the content range end byte **/
	public static final MetaDataKey<Long> CONTENT_RANGE_ENDBYTE = new MetaDataKey<>()
	{
		private static final long serialVersionUID = 1L;
	};

	public static final String CONTENT_DISPOSITION_HEADER_NAME = "content-disposition";

	/**
	 * All available content range types. The type name represents the name used in header
	 * information.
	 */
	public enum ContentRangeType
	{
		BYTES("bytes"), NONE("none");

		private final String typeName;

		ContentRangeType(String typeName)
		{
			this.typeName = typeName;
		}

		public String getTypeName()
		{
			return typeName;
		}
	}

	static
	{
		INTERNAL_HEADERS = new HashSet<>();
		INTERNAL_HEADERS.add("server");
		INTERNAL_HEADERS.add("date");
		INTERNAL_HEADERS.add("expires");
		INTERNAL_HEADERS.add("last-modified");
		INTERNAL_HEADERS.add("content-type");
		INTERNAL_HEADERS.add("content-length");
		INTERNAL_HEADERS.add(CONTENT_DISPOSITION_HEADER_NAME);
		INTERNAL_HEADERS.add("transfer-encoding");
		INTERNAL_HEADERS.add("connection");
		INTERNAL_HEADERS.add("content-range");
		INTERNAL_HEADERS.add("accept-range");
	}

	/**
	 * Construct.
	 */
	public AbstractResource()
	{
	}

	/**
	 * Override this method to return a {@link ResourceResponse} for the request.
	 * 
	 * @param attributes
	 *            request attributes
	 * @return resource data instance
	 */
	protected abstract ResourceResponse newResourceResponse(Attributes attributes);

	/**
	 * Represents data used to configure response and write resource data.
	 * 
	 * @author Matej Knopp
	 */
	public static class ResourceResponse
	{
		private Integer errorCode;
		private Integer statusCode;
		private String errorMessage;
		private String fileName = null;
		private ContentDisposition contentDisposition = ContentDisposition.INLINE;
		private String contentType = null;
		private String contentRange = null;
		private ContentRangeType contentRangeType = null;
		private String textEncoding;
		private long contentLength = -1;
		private Instant lastModified = null;
		private WriteCallback writeCallback;
		private Duration cacheDuration;
		private WebResponse.CacheScope cacheScope;
		private final HttpHeaderCollection headers;

		/**
		 * Construct.
		 */
		public ResourceResponse()
		{
			// disallow caching for public caches. this behavior is similar to wicket 1.4:
			// setting it to [PUBLIC] seems to be sexy but could potentially cache confidential
			// data on public proxies for users migrating to 1.5
			cacheScope = WebResponse.CacheScope.PRIVATE;

			// collection of directly set response headers
			headers = new HttpHeaderCollection();
		}

		/**
		 * Sets the error code for resource. If there is an error code set the data will not be
		 * rendered and the code will be sent to client.
		 * 
		 * @param errorCode
		 *            error code
		 *
		 * @return {@code this}, for chaining.
		 */
		public ResourceResponse setError(Integer errorCode)
		{
			setError(errorCode, null);
			return this;
		}

		/**
		 * Sets the error code and message for resource. If there is an error code set the data will
		 * not be rendered and the code and message will be sent to client.
		 * 
		 * @param errorCode
		 *            error code
		 * @param errorMessage
		 *            error message
		 *
		 * @return {@code this}, for chaining.
		 */
		public ResourceResponse setError(Integer errorCode, String errorMessage)
		{
			this.errorCode = errorCode;
			this.errorMessage = errorMessage;
			return this;
		}

		/**
		 * @return error code or <code>null</code>
		 */
		public Integer getErrorCode()
		{
			return errorCode;
		}

		/**
		 * Sets the status code for resource.
		 *
		 * @param statusCode
		 *            status code
		 *
		 * @return {@code this}, for chaining.
		 */
		public ResourceResponse setStatusCode(Integer statusCode)
		{
			this.statusCode = statusCode;
			return this;
		}

		/**
		 * @return status code or <code>null</code>
		 */
		public Integer getStatusCode()
		{
			return statusCode;
		}

		/**
		 * @return error message or <code>null</code>
		 */
		public String getErrorMessage()
		{
			return errorMessage;
		}

		/**
		 * Sets the file name of the resource.
		 * 
		 * @param fileName
		 *            file name
		 *
		 * @return {@code this}, for chaining.
		 */
		public ResourceResponse setFileName(String fileName)
		{
			this.fileName = fileName;
			return this;
		}

		/**
		 * @return resource file name
		 */
		public String getFileName()
		{
			return fileName;
		}

		/**
		 * Determines whether the resource will be inline or an attachment.
		 * 
		 * @see ContentDisposition
		 * 
		 * @param contentDisposition
		 *            content disposition (attachment or inline)
		 *
		 * @return {@code this}, for chaining.
		 */
		public ResourceResponse setContentDisposition(ContentDisposition contentDisposition)
		{
			Args.notNull(contentDisposition, "contentDisposition");
			this.contentDisposition = contentDisposition;
			return this;
		}

		/**
		 * @return whether the resource is inline or attachment
		 */
		public ContentDisposition getContentDisposition()
		{
			return contentDisposition;
		}

		/**
		 * Sets the content type for the resource. If no content type is set it will be determined
		 * by the extension.
		 * 
		 * @param contentType
		 *            content type (also known as mime type)
		 *
		 * @return {@code this}, for chaining.
		 */
		public ResourceResponse setContentType(String contentType)
		{
			this.contentType = contentType;
			return this;
		}

		/**
		 * @return resource content type
		 */
		public String getContentType()
		{
			if (contentType == null && fileName != null)
			{
				contentType = Application.get().getMimeType(fileName);
			}
			return contentType;
		}

		/**
		 * Gets the content range of the resource. If no content range is set the client assumes the
		 * whole content.
		 *
		 * @return the content range
		 */
		public String getContentRange()
		{
			return contentRange;
		}

		/**
		 * Sets the content range of the resource. If no content range is set the client assumes the
		 * whole content. Please note that if the content range is set, the content length, the
		 * status code and the accept range must be set right, too.
		 *
		 * @param contentRange
		 *            the content range
		 */
		public void setContentRange(String contentRange)
		{
			this.contentRange = contentRange;
		}

		/**
		 * If the resource accepts ranges
		 *
		 * @return the type of range (e.g. bytes)
		 */
		public ContentRangeType getAcceptRange()
		{
			return contentRangeType;
		}

		/**
		 * Sets the accept range header (e.g. bytes)
		 *
		 * @param contentRangeType
		 *            the content range header information
		 */
		public void setAcceptRange(ContentRangeType contentRangeType)
		{
			this.contentRangeType = contentRangeType;
		}

		/**
		 * Sets the text encoding for the resource. This setting must only used if the resource
		 * response represents text.
		 * 
		 * @param textEncoding
		 *            character encoding of text body
		 *
		 * @return {@code this}, for chaining.
		 */
		public ResourceResponse setTextEncoding(String textEncoding)
		{
			this.textEncoding = textEncoding;
			return this;
		}

		/**
		 * @return text encoding for resource
		 */
		protected String getTextEncoding()
		{
			return textEncoding;
		}

		/**
		 * Sets the content length (in bytes) of the data. Content length is optional but it's
		 * recommended to set it so that the browser can show download progress.
		 * 
		 * @param contentLength
		 *            length of response body
		 *
		 * @return {@code this}, for chaining.
		 */
		public ResourceResponse setContentLength(long contentLength)
		{
			this.contentLength = contentLength;
			return this;
		}

		/**
		 * @return content length (in bytes)
		 */
		public long getContentLength()
		{
			return contentLength;
		}

		/**
		 * Sets the last modified data of the resource. Even though this method is optional it is
		 * recommended to set the date. If the date is set properly Wicket can check the
		 * <code>If-Modified-Since</code> to determine if the actual data really needs to be sent
		 * to client.
		 * 
		 * @param lastModified
		 *            last modification timestamp
		 *
		 * @return {@code this}, for chaining.
		 */
		public ResourceResponse setLastModified(Instant lastModified)
		{
			this.lastModified = lastModified;
			return this;
		}

		/**
		 * @return last modification timestamp
		 */
		public Instant getLastModified()
		{
			return lastModified;
		}

		/**
		 * Check to determine if the resource data needs to be written. This method checks the
		 * <code>If-Modified-Since</code> request header and compares it to lastModified property.
		 * In order for this method to work {@link #setLastModified(Instant)} has to be called first.
		 * 
		 * @param attributes
		 *            request attributes
		 * @return <code>true</code> if the resource data does need to be written,
		 *         <code>false</code> otherwise.
		 */
		public boolean dataNeedsToBeWritten(Attributes attributes)
		{
			WebRequest request = (WebRequest)attributes.getRequest();
			Instant ifModifiedSince = request.getIfModifiedSinceHeader();

			if (cacheDuration != Duration.ZERO && ifModifiedSince != null && lastModified != null)
			{
				// [Last-Modified] headers have a maximum precision of one second
				// so we have to truncate the milliseconds part for a proper compare.
				// that's stupid, since changes within one second will not be reliably
				// detected by the client ... any hint or clarification to improve this
				// situation will be appreciated...
				Instant roundedLastModified = lastModified.truncatedTo(ChronoUnit.SECONDS);

				return ifModifiedSince.isBefore(roundedLastModified);
			}
			else
			{
				return true;
			}
		}

		/**
		 * Disables caching.
		 *
		 * @return {@code this}, for chaining.
		 */
		public ResourceResponse disableCaching()
		{
			return setCacheDuration(Duration.ZERO);
		}

		/**
		 * Sets caching to maximum available duration.
		 *
		 * @return {@code this}, for chaining.
		 */
		public ResourceResponse setCacheDurationToMaximum()
		{
			cacheDuration = WebResponse.MAX_CACHE_DURATION;
			return this;
		}

		/**
		 * Controls how long this response may be cached.
		 * 
		 * @param duration
		 *            caching duration in seconds
		 *
		 * @return {@code this}, for chaining.
		 */
		public ResourceResponse setCacheDuration(Duration duration)
		{
			cacheDuration = Args.notNull(duration, "duration");
			return this;
		}

		/**
		 * Returns how long this resource may be cached for.
		 * <p/>
		 * The special value Duration.NONE means caching is disabled.
		 * 
		 * @return duration for caching
		 * 
		 * @see org.apache.wicket.settings.ResourceSettings#setDefaultCacheDuration(Duration)
		 * @see org.apache.wicket.settings.ResourceSettings#getDefaultCacheDuration()
		 */
		public Duration getCacheDuration()
		{
			Duration duration = cacheDuration;
			if (duration == null && Application.exists())
			{
				duration = Application.get().getResourceSettings().getDefaultCacheDuration();
			}

			return duration;
		}

		/**
		 * returns what kind of caches are allowed to cache the resource response
		 * <p/>
		 * resources are only cached at all if caching is enabled by setting a cache duration.
		 * 
		 * @return cache scope
		 * 
		 * @see org.apache.wicket.request.resource.AbstractResource.ResourceResponse#getCacheDuration()
		 * @see org.apache.wicket.request.resource.AbstractResource.ResourceResponse#setCacheDuration(Duration)
		 * @see org.apache.wicket.request.http.WebResponse.CacheScope
		 */
		public WebResponse.CacheScope getCacheScope()
		{
			return cacheScope;
		}

		/**
		 * controls what kind of caches are allowed to cache the response
		 * <p/>
		 * resources are only cached at all if caching is enabled by setting a cache duration.
		 * 
		 * @param scope
		 *            scope for caching
		 * 
		 * @see org.apache.wicket.request.resource.AbstractResource.ResourceResponse#getCacheDuration()
		 * @see org.apache.wicket.request.resource.AbstractResource.ResourceResponse#setCacheDuration(Duration)
		 * @see org.apache.wicket.request.http.WebResponse.CacheScope
		 *
		 * @return {@code this}, for chaining.
		 */
		public ResourceResponse setCacheScope(WebResponse.CacheScope scope)
		{
			cacheScope = Args.notNull(scope, "scope");
			return this;
		}

		/**
		 * Sets the {@link WriteCallback}. The callback is responsible for generating the response
		 * data.
		 * <p>
		 * It is necessary to set the {@link WriteCallback} if
		 * {@link #dataNeedsToBeWritten(org.apache.wicket.request.resource.IResource.Attributes)}
		 * returns <code>true</code> and {@link #setError(Integer)} has not been called.
		 * 
		 * @param writeCallback
		 *            write callback
		 *
		 * @return {@code this}, for chaining.
		 */
		public ResourceResponse setWriteCallback(final WriteCallback writeCallback)
		{
			Args.notNull(writeCallback, "writeCallback");
			this.writeCallback = writeCallback;
			return this;
		}

		/**
		 * @return write callback.
		 */
		public WriteCallback getWriteCallback()
		{
			return writeCallback;
		}

		/**
		 * get custom headers
		 * 
		 * @return collection of the response headers
		 */
		public HttpHeaderCollection getHeaders()
		{
			return headers;
		}
	}

	/**
	 * Configure the web response header for client cache control.
	 * 
	 * @param data
	 *            resource data
	 * @param attributes
	 *            request attributes
	 */
	protected void configureCache(final ResourceResponse data, final Attributes attributes)
	{
		Response response = attributes.getResponse();

		if (response instanceof WebResponse)
		{
			Duration duration = data.getCacheDuration();
			WebResponse webResponse = (WebResponse)response;
			if (duration.compareTo(Duration.ZERO) > 0)
			{
				webResponse.enableCaching(duration, data.getCacheScope());
			}
			else
			{
				webResponse.disableCaching();
			}
		}
	}

	protected IResourceCachingStrategy getCachingStrategy()
	{
		return Application.get().getResourceSettings().getCachingStrategy();
	}

	/**
	 * 
	 * @see org.apache.wicket.request.resource.IResource#respond(org.apache.wicket.request.resource.IResource.Attributes)
	 */
	@Override
	public void respond(final Attributes attributes)
	{
		// Sets the request attributes
		setRequestMetaData(attributes);

		// Get a "new" ResourceResponse to write a response
		ResourceResponse data = newResourceResponse(attributes);

		// is resource supposed to be cached?
		if (this instanceof IStaticCacheableResource)
		{
			final IStaticCacheableResource cacheable = (IStaticCacheableResource)this;

			// is caching enabled?
			if (cacheable.isCachingEnabled())
			{
				// apply caching strategy to response
				getCachingStrategy().decorateResponse(data, cacheable);
			}
		}
		// set response header
		setResponseHeaders(data, attributes);

		if (!data.dataNeedsToBeWritten(attributes) || data.getErrorCode() != null ||
			needsBody(data.getStatusCode()) == false)
		{
			return;
		}

		if (data.getWriteCallback() == null)
		{
			throw new IllegalStateException("ResourceResponse#setWriteCallback() must be set.");
		}

		try
		{
			data.getWriteCallback().writeData(attributes);
		}
		catch (IOException iox)
		{
			throw new WicketRuntimeException(iox);
		}
	}

	/**
	 * Decides whether a response body should be written back to the client depending on the set
	 * status code
	 *
	 * @param statusCode
	 *            the status code set by the application
	 * @return {@code true} if the status code allows response body, {@code false} - otherwise
	 */
	private boolean needsBody(Integer statusCode)
	{
		return statusCode == null ||
								(statusCode < 300 &&
								statusCode != HttpServletResponse.SC_NO_CONTENT &&
								statusCode != HttpServletResponse.SC_RESET_CONTENT);
	}

	/**
	 * check if header is directly modifyable
	 * 
	 * @param name
	 *            header name
	 * 
	 * @throws IllegalArgumentException
	 *             if access is forbidden
	 */
	private void checkHeaderAccess(String name)
	{
		name = Args.notEmpty(name.trim().toLowerCase(Locale.ROOT), "name");

		if (INTERNAL_HEADERS.contains(name))
		{
			throw new IllegalArgumentException("you are not allowed to directly access header [" +
				name + "], " + "use one of the other specialized methods of " +
						Classes.simpleName(getClass()) + " to get or modify its value");
		}
	}

	/**
	 * Reads the plain request header information and applies enriched information as meta data to
	 * the current request. Those information are available for the whole request cycle.
	 *
	 * @param attributes
	 *            the attributes to get the plain request header information
	 */
	protected void setRequestMetaData(Attributes attributes)
	{
		Request request = attributes.getRequest();
		if (request instanceof WebRequest)
		{
			WebRequest webRequest = (WebRequest)request;

			setRequestRangeMetaData(webRequest);
		}
	}

	protected void setRequestRangeMetaData(WebRequest webRequest)
	{
		String rangeHeader = webRequest.getHeader("range");

		// The content range header is only be calculated if a range is given
		if (!Strings.isEmpty(rangeHeader) &&
				rangeHeader.contains(ContentRangeType.BYTES.getTypeName()))
		{
			// fixing white spaces
			rangeHeader = rangeHeader.replaceAll(" ", "");

			String range = rangeHeader.substring(rangeHeader.indexOf('=') + 1,
					rangeHeader.length());
			
			// support only the first range (WICKET-5995)
			final int idxOfComma = range.indexOf(',');
			String firstRange = idxOfComma > -1 ? range.substring(0, idxOfComma) : range;

			String[] rangeParts = Strings.split(firstRange, '-');

			String startByteString = rangeParts[0];
			String endByteString = rangeParts[1];

			long startbyte = !Strings.isEmpty(startByteString) ? Long.parseLong(startByteString) : 0;
			long endbyte = !Strings.isEmpty(endByteString) ? Long.parseLong(endByteString) : -1;

			// Make the content range information available for the whole request cycle
			RequestCycle requestCycle = RequestCycle.get();
			requestCycle.setMetaData(CONTENT_RANGE_STARTBYTE, startbyte);
			requestCycle.setMetaData(CONTENT_RANGE_ENDBYTE, endbyte);
		}
	}

	/**
	 * Sets the response header of resource response to the response received from the attributes
	 *
	 * @param resourceResponse
	 *            the resource response to get the header fields from
	 * @param attributes
	 *            the attributes to get the response from to which the header information are going
	 *            to be applied
	 */
	protected void setResponseHeaders(final ResourceResponse resourceResponse,
		final Attributes attributes)
	{
		Response response = attributes.getResponse();
		if (response instanceof WebResponse)
		{
			WebResponse webResponse = (WebResponse)response;

			// 1. Last Modified
			Instant lastModified = resourceResponse.getLastModified();
			if (lastModified != null)
			{
				webResponse.setLastModifiedTime(lastModified);
			}

			// 2. Caching
			configureCache(resourceResponse, attributes);

			if (resourceResponse.getErrorCode() != null)
			{
				webResponse.sendError(resourceResponse.getErrorCode(),
					resourceResponse.getErrorMessage());
				return;
			}

			if (resourceResponse.getStatusCode() != null)
			{
				webResponse.setStatus(resourceResponse.getStatusCode());
			}

			if (!resourceResponse.dataNeedsToBeWritten(attributes))
			{
				webResponse.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
				return;
			}

			// 3. Content Disposition
			String fileName = resourceResponse.getFileName();
			ContentDisposition disposition = resourceResponse.getContentDisposition();
			if (ContentDisposition.ATTACHMENT == disposition)
			{
				webResponse.setAttachmentHeader(fileName);
			}
			else if (ContentDisposition.INLINE == disposition)
			{
				webResponse.setInlineHeader(fileName);
			}

			// 4. Mime Type (+ encoding)
			String mimeType = resourceResponse.getContentType();
			if (mimeType != null)
			{
				final String encoding = resourceResponse.getTextEncoding();

				if (encoding == null)
				{
					webResponse.setContentType(mimeType);
				}
				else
				{
					webResponse.setContentType(mimeType + "; charset=" + encoding);
				}
			}

			// 5. Accept Range
			ContentRangeType acceptRange = resourceResponse.getAcceptRange();
			if (acceptRange != null)
			{
				webResponse.setAcceptRange(acceptRange.getTypeName());
			}

			long contentLength = resourceResponse.getContentLength();
			boolean contentRangeApplied = false;

			// 6. Content Range
			// for more information take a look here:
			// http://stackoverflow.com/questions/8293687/sample-http-range-request-session
			// if the content range header has been set directly
			// to the resource response use it otherwise calculate it
			String contentRange = resourceResponse.getContentRange();
			if (contentRange != null)
			{
				webResponse.setContentRange(contentRange);
			}
			else
			{
				// content length has to be set otherwise the content range header can not be
				// calculated - accept range must be set to bytes - others are not supported at the
				// moment
				if (contentLength != -1 && ContentRangeType.BYTES.equals(acceptRange))
				{
					contentRangeApplied = setResponseContentRangeHeaderFields(webResponse,
						attributes, contentLength);
				}
			}

			// 7. Content Length
			if (contentLength != -1 && !contentRangeApplied)
			{
				webResponse.setContentLength(contentLength);
			}

			// add custom headers and values
			final HttpHeaderCollection headers = resourceResponse.getHeaders();

			for (String name : headers.getHeaderNames())
			{
				checkHeaderAccess(name);

				for (String value : headers.getHeaderValues(name))
				{
					webResponse.addHeader(name, value);
				}
			}
		}
	}

	/**
	 * Sets the content range header fields to the given web response
	 *
	 * @param webResponse
	 *            the web response to apply the content range information to
	 * @param attributes
	 *            the attributes to get the request from
	 * @param contentLength
	 *            the content length of the response
	 * @return if the content range header information has been applied
	 */
	protected boolean setResponseContentRangeHeaderFields(WebResponse webResponse,
		Attributes attributes, long contentLength)
	{
		boolean contentRangeApplied = false;
		if (attributes.getRequest() instanceof WebRequest)
		{
			Long startbyte = RequestCycle.get().getMetaData(CONTENT_RANGE_STARTBYTE);
			Long endbyte = RequestCycle.get().getMetaData(CONTENT_RANGE_ENDBYTE);

			if (startbyte != null && endbyte != null)
			{
				// if end byte hasn't been set
				if (endbyte == -1)
				{
					endbyte = contentLength - 1;
				}

				// Change the status code to 206 partial content
				webResponse.setStatus(206);
				// currently only bytes are supported.
				webResponse.setContentRange(ContentRangeType.BYTES.getTypeName() + " " + startbyte +
					'-' + endbyte + '/' + contentLength);
				// WARNING - DO NOT SET THE CONTENT LENGTH, even if it is calculated right - 
				// SAFARI / CHROME are causing issues otherwise!
				// webResponse.setContentLength((endbyte - startbyte) + 1);

				// content range has been applied do not set the content length again!
				contentRangeApplied = true;
			}
		}
		return contentRangeApplied;
	}

	/**
	 * Callback invoked when resource data needs to be written to response. Subclass needs to
	 * implement the {@link #writeData(org.apache.wicket.request.resource.IResource.Attributes)}
	 * method.
	 * 
	 * @author Matej Knopp
	 */
	public abstract static class WriteCallback
	{
		/**
		 * Write the resource data to response.
		 * 
		 * @param attributes
		 *            request attributes
		 */
		public abstract void writeData(Attributes attributes) throws IOException;

		/**
		 * Convenience method to write an {@link InputStream} to response.
		 * 
		 * @param attributes
		 *            request attributes
		 * @param stream
		 *            input stream
		 */
		protected void writeStream(Attributes attributes, InputStream stream) throws IOException
		{
			final Response response = attributes.getResponse();
			Streams.copy(stream, response.getOutputStream());
		}
	}
}
