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

import java.io.IOException;
import java.io.InputStream;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.apache.wicket.Application;
import org.apache.wicket.util.io.Connections;
import org.apache.wicket.util.io.IOUtils;
import org.apache.wicket.util.lang.Args;
import org.apache.wicket.util.lang.Bytes;
import org.apache.wicket.util.lang.Objects;
import org.apache.wicket.util.resource.AbstractResourceStream;
import org.apache.wicket.util.resource.IFixedLocationResourceStream;
import org.apache.wicket.util.resource.ResourceStreamNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;


/**
 * UrlResourceStream implements IResource for URLs.
 *
 * @see org.apache.wicket.util.resource.IResourceStream
 * @see org.apache.wicket.util.watch.IModifiable
 * @author Jonathan Locke
 * @author Igor Vaynberg
 */
public class UrlResourceStream extends AbstractResourceStream
	implements
		IFixedLocationResourceStream
{
	private static final long serialVersionUID = 1L;

	/** Logging. */
	private static final Logger log = LoggerFactory.getLogger(UrlResourceStream.class);

	/**
	 * The meta data for this stream. Lazy loaded on demand.
	 */
	private transient StreamData streamData;

	/** The URL to this resource. */
	private final URL url;

	/** Last known time the stream was last modified. */
	private Instant lastModified;

	/**
	 * Meta data class for the stream attributes
	 */
	private static class StreamData
	{
		private URLConnection connection;

		/**
		 * The streams read from this connection.
		 * Some URLConnection implementations return the same instance of InputStream
		 * every time URLConnection#getInputStream() is called. Other return a new instance
		 * of InputStream.
		 * Here we keep a list of all returned ones and close them in UrlResourceStream#close().
		 * Even it is the same instance several times we will try to close it quietly several times.
		 */
		private List<InputStream> inputStreams;

		/** Length of stream. */
		private long contentLength;

		/** Content type for stream. */
		private String contentType;

	}

	/**
	 * Construct.
	 *
	 * @param url
	 *            URL of resource
	 */
	public UrlResourceStream(final URL url)
	{
		this.url = Args.notNull(url, "url");
	}

	/**
	 * Lazy loads the stream settings on demand
	 *
	 * @param initialize
	 *            a flag indicating whether to load the settings
	 * @return the meta data with the stream settings
	 */
	private StreamData getData(boolean initialize)
	{
		if (streamData == null && initialize)
		{
			streamData = new StreamData();

			try
			{
				streamData.connection = url.openConnection();
				streamData.contentLength = streamData.connection.getContentLength();

				// Default implementation "sun.net.www.MimeTable" works on strings with "/" only and
				// doesn't properly parse paths nor URLs. So providing an absolute URI is compatible
				// with the default implementation, while the string can't be misinterpreted as path
				// like has been the case with "URL.getFile" before. That doesn't decode to paths,
				// results only look similar sometimes.
				String uriStr = url.toURI().toString();

				if (Application.exists())
				{
					streamData.contentType = Application.get().getMimeType(uriStr);
				}
				else
				{
					streamData.contentType = streamData.connection.getContentType();
				}

				if (streamData.contentType == null || streamData.contentType.contains("unknown"))
				{
					streamData.contentType = URLConnection.getFileNameMap().getContentTypeFor(
						uriStr);
				}
			}
			catch (IOException | URISyntaxException ex)
			{
				throw new IllegalArgumentException("Invalid URL parameter " + url, ex);
			}
		}

		return streamData;
	}

	/**
	 * Closes this resource.
	 *
	 * @throws IOException
	 */
	@Override
	public void close() throws IOException
	{
		StreamData data = getData(false);

		if (data != null)
		{
			Connections.closeQuietly(data.connection);
			if (data.inputStreams != null)
			{
				for (InputStream is : data.inputStreams) {
					IOUtils.closeQuietly(is);
				}
			}
			streamData = null;
		}
	}

	/**
	 * @return A readable input stream for this resource.
	 * @throws ResourceStreamNotFoundException
	 */
	@Override
	public InputStream getInputStream() throws ResourceStreamNotFoundException
	{
		try
		{
			StreamData data = getData(true);
			InputStream is = data.connection.getInputStream();
			if (data.inputStreams == null) {
				data.inputStreams = new ArrayList<>();
			}
			data.inputStreams.add(is);
			return is;
		}
		catch (IOException e)
		{
			throw new ResourceStreamNotFoundException("Resource " + url + " could not be opened", e);
		}
	}

	/**
	 * @return The URL to this resource (if any)
	 */
	public URL getURL()
	{
		return url;
	}

	/**
	 * @see org.apache.wicket.util.watch.IModifiable#lastModifiedTime()
	 * @return The last time this resource was modified
	 */
	@Override
	public Instant lastModifiedTime()
	{
		try
		{
			// get url modification timestamp
			final Instant time = Connections.getLastModified(url);

			// if timestamp changed: update content length and last modified date
			if (Objects.equal(time, lastModified) == false)
			{
				lastModified = time;
				updateContentLength();
			}
			return lastModified;
		}
		catch (IOException e)
		{
			log.warn("getLastModified() for '{}' failed: {}", url, e.getMessage());

			// allow modification watcher to detect the problem
			return null;
		}
	}

	private void updateContentLength() throws IOException
	{
		StreamData data = getData(false);

		if (data != null)
		{
			URLConnection connection = url.openConnection();
			try {
				data.contentLength = connection.getContentLength();
			} finally {
				Connections.close(connection);
			}
		}
	}

	@Override
	public String toString()
	{
		return url.toString();
	}

	/**
	 * @return The content type of this resource, such as "image/jpeg" or "text/html"
	 */
	@Override
	public String getContentType()
	{
		return getData(true).contentType;
	}

	@Override
	public Bytes length()
	{
		long length = getData(true).contentLength;

		if (length == -1)
		{
			return null;
		}

		return Bytes.bytes(length);
	}

	@Override
	public String locationAsString()
	{
		return url.toExternalForm();
	}
}
