/*
 * This program is free software; you can redistribute it and/or modify it under the
 * terms of the GNU Lesser General Public License, version 2.1 as published by the Free Software
 * Foundation.
 *
 * You should have received a copy of the GNU Lesser General Public License along with this
 * program; if not, you can obtain a copy at http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
 * or from the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
 * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU Lesser General Public License for more details.
 *
 * Copyright (c) 2007 - 2009 Pentaho Corporation and Contributors.  All rights reserved.
 */

package org.pentaho.reporting.libraries.base.util;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Reader;
import java.io.Writer;
import java.io.StringWriter;
import java.net.URL;
import java.sql.Blob;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.StringTokenizer;
import java.sql.Clob;
import java.sql.SQLException;

import org.apache.commons.logging.LogFactory;
import org.apache.commons.logging.Log;

/**
 * The IOUtils provide some IO related helper methods.
 *
 * @author Thomas Morgner.
 */
public class IOUtils
{
  /**
   * the singleton instance of the utility package.
   */
  private static IOUtils instance;
  private static final Log logger = LogFactory.getLog(IOUtils.class);
  /**
   * DefaultConstructor.
   */
  private IOUtils()
  {
  }

  /**
   * Gets the singleton instance of the utility package.
   *
   * @return the singleton instance.
   */
  public static synchronized IOUtils getInstance()
  {
    if (instance == null)
    {
      instance = new IOUtils();
    }
    return instance;
  }

  /**
   * Checks, whether the URL uses a file based protocol.
   *
   * @param url the url.
   * @return true, if the url is file based.
   */
  private boolean isFileStyleProtocol(final URL url)
  {
    if (url == null)
    {
      throw new NullPointerException();
    }

    final String protocol = url.getProtocol();
    if ("http".equals(protocol))
    {
      return true;
    }
    if ("https".equals(protocol))
    {
      return true;
    }
    if ("ftp".equals(protocol))
    {
      return true;
    }
    if ("file".equals(protocol))
    {
      return true;
    }
    if ("jar".equals(protocol))
    {
      return true;
    }
    return false;
  }

  /**
   * Parses the given name and returns the name elements as List of Strings.
   *
   * @param name the name, that should be parsed.
   * @return the parsed name.
   */
  private List parseName(final String name)
  {
    final ArrayList list = new ArrayList();
    final StringTokenizer strTok = new StringTokenizer(name, "/");
    while (strTok.hasMoreElements())
    {
      final String s = (String) strTok.nextElement();
      if (s.length() != 0)
      {
        list.add(s);
      }
    }
    return list;
  }

  /**
   * Transforms the name list back into a single string, separated with "/".
   *
   * @param name  the name list.
   * @param query the (optional) query for the URL.
   * @return the constructed name.
   */
  private String formatName(final List name, final String query)
  {
    final StringBuffer b = new StringBuffer(128);
    final Iterator it = name.iterator();
    while (it.hasNext())
    {
      b.append(it.next());
      if (it.hasNext())
      {
        b.append('/');
      }
    }
    if (query != null)
    {
      b.append('?');
      b.append(query);
    }
    return b.toString();
  }

  /**
   * Compares both name lists, and returns the last common index shared between the two lists.
   *
   * @param baseName the name created using the base url.
   * @param urlName  the target url name.
   * @return the number of shared elements.
   */
  private int startsWithUntil(final List baseName, final List urlName)
  {
    final int minIdx = Math.min(urlName.size(), baseName.size());
    for (int i = 0; i < minIdx; i++)
    {
      final String baseToken = (String) baseName.get(i);
      final String urlToken = (String) urlName.get(i);
      if (!baseToken.equals(urlToken))
      {
        return i;
      }
    }
    return minIdx;
  }

  /**
   * Checks, whether the URL points to the same service. A service is equal if the protocol, host and port are equal.
   *
   * @param url     a url
   * @param baseUrl an other url, that should be compared.
   * @return true, if the urls point to the same host and port and use the same protocol, false otherwise.
   */
  private boolean isSameService(final URL url, final URL baseUrl)
  {
    if (!url.getProtocol().equals(baseUrl.getProtocol()))
    {
      return false;
    }
    if (!url.getHost().equals(baseUrl.getHost()))
    {
      return false;
    }
    if (url.getPort() != baseUrl.getPort())
    {
      return false;
    }
    return true;
  }

  /**
   * Creates a relative url by stripping the common parts of the the url. If the baseFile denotes a directory,
   * it must end with a slash.
   *
   * @param targetFile     the to be stripped url
   * @param baseFile the base url, to which the <code>url</code> is relative to.
   * @return the relative url, or the url unchanged, if there is no relation beween both URLs.
   */
  public String createRelativePath(final String targetFile, final String baseFile)
  {
    if (targetFile == null)
    {
      throw new NullPointerException("targetFile must not be null.");
    }
    if (baseFile == null)
    {
      throw new NullPointerException("baseFile must not be null.");
    }

    // If the URL contains a query, ignore that URL; do not
    // attemp to modify it...
    final List baseName = parseName(baseFile);
    if (baseName.isEmpty())
    {
      return targetFile;
    }
    final List urlName = parseName(targetFile);
    if (urlName.isEmpty())
    {
      return targetFile;
    }

    if ((baseFile.length() > 0 && baseFile.charAt(baseFile.length() - 1) == '/') == false)
    {
      // remove trailing slashes and ensure that the last element in baseName points to a directory
      baseName.remove(baseName.size() - 1);
    }

    // if both urls are identical, then return the plain file name...
    if (baseFile.equals(targetFile))
    {
      return (String) urlName.get(urlName.size() - 1);
    }

    int commonIndex = startsWithUntil(urlName, baseName);
    if (commonIndex == 0)
    {
      return targetFile;
    }

    if (commonIndex == urlName.size())
    {
      // correct the base index if there is some weird mapping
      // detected,
      // fi. the file url is fully included in the base url:
      //
      // base: /file/test/funnybase
      // file: /file/test
      //
      // this could be a valid configuration whereever virtual
      // mappings are allowed.
      commonIndex -= 1;
    }

    final ArrayList retval = new ArrayList();
    if ((baseName.size() + 1) != urlName.size())
    {
      final int levels = baseName.size() - commonIndex;
      for (int i = 0; i < levels; i++)
      {
        retval.add("..");
      }
    }

    retval.addAll(urlName.subList(commonIndex, urlName.size()));
    return formatName(retval, null);
  }

  /**
   * Creates a relative url by stripping the common parts of the the url. If the base-URL denotes a directory,
   * it must end with a slash.
   *
   * @param url     the to be stripped url
   * @param baseURL the base url, to which the <code>url</code> is relative to.
   * @return the relative url, or the url unchanged, if there is no relation beween both URLs.
   */
  public String createRelativeURL(final URL url, final URL baseURL)
  {
    if (url == null)
    {
      throw new NullPointerException("content url must not be null.");
    }
    if (baseURL == null)
    {
      throw new NullPointerException("baseURL must not be null.");
    }
    if (isFileStyleProtocol(url) && isSameService(url, baseURL))
    {

      // If the URL contains a query, ignore that URL; do not
      // attemp to modify it...
      final List urlName = parseName(getPath(url));
      final List baseName = parseName(getPath(baseURL));
      final String query = getQuery(url);

      if (!isPath(baseURL))
      {
        baseName.remove(baseName.size() - 1);
      }

      // if both urls are identical, then return the plain file name...
      if (String.valueOf(url).equals(String.valueOf(baseURL)))
      {
        return (String) urlName.get(urlName.size() - 1);
      }

      int commonIndex = startsWithUntil(urlName, baseName);
      if (commonIndex == 0)
      {
        return url.toExternalForm();
      }

      if (commonIndex == urlName.size())
      {
        // correct the base index if there is some weird mapping
        // detected,
        // fi. the file url is fully included in the base url:
        //
        // base: /file/test/funnybase
        // file: /file/test
        //
        // this could be a valid configuration whereever virtual
        // mappings are allowed.
        commonIndex -= 1;
      }

      final ArrayList retval = new ArrayList();
      if (baseName.size() != urlName.size())
      {
        final int levels = baseName.size() - commonIndex;
        for (int i = 0; i < levels; i++)
        {
          retval.add("..");
        }
      }

      retval.addAll(urlName.subList(commonIndex, urlName.size()));
      return formatName(retval, query);
    }
    return url.toExternalForm();
  }

  /**
   * Returns <code>true</code> if the URL represents a path, and <code>false</code> otherwise.
   *
   * @param baseURL the URL.
   * @return A boolean.
   */
  private boolean isPath(final URL baseURL)
  {
    final String path = getPath(baseURL);
    if (path.length() > 0 && path.charAt(path.length() - 1) == '/')
    {
      return true;
    }
    else if ("file".equals(baseURL.getProtocol()))
    {
      final File f = new File(path);
      try
      {
        if (f.isDirectory())
        {
          return true;
        }
      }
      catch (SecurityException se)
      {
        // ignored ...
      }
    }
    return false;
  }

  /**
   * Implements the JDK 1.3 method URL.getPath(). The path is defined as URL.getFile() minus the (optional) query.
   *
   * @param url the URL
   * @return the path
   */
  private String getQuery(final URL url)
  {
    final String file = url.getFile();
    final int queryIndex = file.indexOf('?');
    if (queryIndex == -1)
    {
      return null;
    }
    return file.substring(queryIndex + 1);
  }

  /**
   * Implements the JDK 1.3 method URL.getPath(). The path is defined as URL.getFile() minus the (optional) query.
   *
   * @param url the URL
   * @return the path
   */
  private String getPath(final URL url)
  {
    final String file = url.getFile();
    final int queryIndex = file.indexOf('?');
    if (queryIndex == -1)
    {
      return file;
    }
    return file.substring(0, queryIndex);
  }

  /**
   * Copies the InputStream into the OutputStream, until the end of the stream has been reached. This method uses a
   * buffer of 4096 kbyte.
   *
   * @param in  the inputstream from which to read.
   * @param out the outputstream where the data is written to.
   * @throws java.io.IOException if a IOError occurs.
   */
  public void copyStreams(final InputStream in, final OutputStream out)
      throws IOException
  {
    copyStreams(in, out, 4096);
  }

  /**
   * Copies the InputStream into the OutputStream, until the end of the stream has been reached.
   *
   * @param in         the inputstream from which to read.
   * @param out        the outputstream where the data is written to.
   * @param buffersize the buffer size.
   * @throws java.io.IOException if a IOError occurs.
   */
  public void copyStreams(final InputStream in, final OutputStream out,
                          final int buffersize) throws IOException
  {
    // create a 4kbyte buffer to read the file
    final byte[] bytes = new byte[buffersize];

    // the input stream does not supply accurate available() data
    // the zip entry does not know the size of the data
    int bytesRead = in.read(bytes);
    while (bytesRead > -1)
    {
      out.write(bytes, 0, bytesRead);
      bytesRead = in.read(bytes);
    }
  }

  /**
   * Copies the contents of the Reader into the Writer, until the end of the stream has been reached. This method uses
   * a buffer of 4096 kbyte.
   *
   * @param in  the reader from which to read.
   * @param out the writer where the data is written to.
   * @throws java.io.IOException if a IOError occurs.
   */
  public void copyWriter(final Reader in, final Writer out)
      throws IOException
  {
    copyWriter(in, out, 4096);
  }

  /**
   * Copies the contents of the Reader into the Writer, until the end of the stream has been reached.
   *
   * @param in         the reader from which to read.
   * @param out        the writer where the data is written to.
   * @param buffersize the buffer size.
   * @throws java.io.IOException if a IOError occurs.
   */
  public void copyWriter(final Reader in, final Writer out,
                         final int buffersize)
      throws IOException
  {
    // create a 4kbyte buffer to read the file
    final char[] bytes = new char[buffersize];

    // the input stream does not supply accurate available() data
    // the zip entry does not know the size of the data
    int bytesRead = in.read(bytes);
    while (bytesRead > -1)
    {
      out.write(bytes, 0, bytesRead);
      bytesRead = in.read(bytes);
    }
  }

  /**
   * Reads the given number of bytes into the target array. This method does not return until all bytes are read. In
   * case a end-of-stream is reached, the method throws an Exception.
   *
   * @param in the inputstream from where to read.
   * @param data the array where to store the data.
   * @param offset the offset in the array where to store the data.
   * @param length the number of bytes to be read.
   * @throws IOException if an IO error occured or the End of the stream has been reached.
   */
  public void readFully (final InputStream in,
                         final byte[] data,
                         final int offset,
                         final int length) throws IOException
  {
    int bytesToRead = length;
    int bytesRead = 0;
    do
    {
      final int size = in.read(data, offset + bytesRead, bytesToRead);
      if (size == -1)
      {
        throw new IOException("End-Of-File reached");
      }
      bytesToRead = bytesToRead - size;
      bytesRead += size;
    }
    while(bytesToRead > 0);
  }

  /**
   * Reads the given number of bytes into the target array. This method does not return until all bytes are read. In
   * case a end-of-stream is reached, the method throws an Exception.
   *
   * @param in the inputstream from where to read.
   * @param data the array where to store the data.
   * @param offset the offset in the array where to store the data.
   * @param length the number of bytes to be read.
   * @throws IOException if an IO error occured or the End of the stream has been reached.
   */
  public int readSafely (final InputStream in,
                         final byte[] data,
                         final int offset,
                         final int length) throws IOException
  {
    int bytesToRead = length;
    int bytesRead = 0;
    do
    {
      final int size = in.read(data, offset + bytesRead, bytesToRead);
      if (size == -1)
      {
        return bytesRead;
      }
      bytesToRead = bytesToRead - size;
      bytesRead += size;
    }
    while(bytesToRead > 0);

    // end of file reached ..
    return 0;
  }

  /**
   * Extracts the file name from the URL.
   *
   * @param url the url.
   * @return the extracted filename.
   */
  public String getFileName(final URL url)
  {
    final String fileRaw = url.getFile();
    final int query = fileRaw.lastIndexOf('?');
    final String file;
    if (query == -1)
    {
      file = fileRaw;
    }
    else
    {
      file = fileRaw.substring(0, query);
    }

    // Now the processing is the same as if it is a string
    return getFileName(file);
  }

  /**
   * Extracts the last file name from the given pathname.
   *
   * @param path the path name.
   * @return the extracted filename.
   */
  public String getFileName(final String path)
  {
    // Check for slash and backslash
    final int last = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
    if (last < 0)
    {
      return path;
    }
    return path.substring(last + 1);
  }

  /**
   * Removes the file extension from the given file name.
   *
   * @param file the file name.
   * @return the file name without the file extension.
   */
  public String stripFileExtension(final String file)
  {
    final int idx = file.lastIndexOf('.');
    // handles unix hidden files and files without an extension.
    if (idx < 1)
    {
      return file;
    }
    return file.substring(0, idx);
  }

  /**
   * Returns the file extension of the given file name. The returned value will contain the dot.
   *
   * @param file the file name.
   * @return the file extension.
   */
  public String getFileExtension(final String file)
  {
    final int idx = file.lastIndexOf('.');
    // handles unix hidden files and files without an extension.
    if (idx < 1)
    {
      return "";
    }
    return file.substring(idx);
  }

  /**
   * Checks, whether the child directory is a subdirectory of the base directory.
   *
   * @param base  the base directory.
   * @param child the suspected child directory.
   * @return true, if the child is a subdirectory of the base directory.
   * @throws java.io.IOException if an IOError occured during the test.
   */
  public boolean isSubDirectory(File base, File child)
      throws IOException
  {
    base = base.getCanonicalFile();
    child = child.getCanonicalFile();

    File parentFile = child;
    while (parentFile != null)
    {
      if (base.equals(parentFile))
      {
        return true;
      }
      parentFile = parentFile.getParentFile();
    }
    return false;
  }


  /**
   * Returns a reference to a file with the specified name that is located somewhere on the classpath.  The code for
   * this method is an adaptation of code supplied by Dave Postill.
   *
   * @param name the filename.
   * @return a reference to a file or <code>null</code> if no file could be found.
   * @throws SecurityException if access to the system properties or filesystem is forbidden.
   * @noinspection AccessOfSystemProperties
   */
  public File findFileOnClassPath(final String name) throws SecurityException
  {

    final String classpath = System.getProperty("java.class.path");
    final String pathSeparator = System.getProperty("path.separator");

    final StringTokenizer tokenizer = new StringTokenizer(classpath, pathSeparator);

    while (tokenizer.hasMoreTokens())
    {
      final String pathElement = tokenizer.nextToken();

      final File directoryOrJar = new File(pathElement);
      final File absoluteDirectoryOrJar = directoryOrJar.getAbsoluteFile();

      if (absoluteDirectoryOrJar.isFile())
      {
        final File target = new File(absoluteDirectoryOrJar.getParent(), name);
        if (target.exists())
        {
          return target;
        }
      }
      else
      {
        final File target = new File(directoryOrJar, name);
        if (target.exists())
        {
          return target;
        }
      }

    }
    return null;

  }

  /**
   * Computes the absolute filename for the target file using the baseFile as root directory. If the baseFile is
   * null or empty, the target file will be normalized (all navigation elements like ".." are removed).
   *
   * @param targetFile the target file name.
   * @param baseFile the base file (can be null).
   * @return the absolute path.
   */
  public String getAbsolutePath (final String targetFile, final String baseFile)
  {
    if (targetFile == null)
    {
      throw new NullPointerException("targetFile must not be null.");
    }
    if (baseFile == null || "".equals(baseFile))
    {
      return stripNavigationPaths(targetFile);
    }

    if (targetFile.length() > 0 && targetFile.charAt(0) == '/')
    {
      return stripNavigationPaths(targetFile.substring(1));
    }

    final List baseName = parseName(baseFile);
    if (baseName.isEmpty())
    {
      return stripNavigationPaths(targetFile);
    }
    final List urlName = parseName(targetFile);
    if (urlName.isEmpty())
    {
      return stripNavigationPaths(baseFile);
    }

    if ((baseFile.length() > 0 &&
         baseFile.charAt(baseFile.length() - 1) == '/') == false)
    {
      // trailing slashes indicate directory,
      // so remove last entry if the basefile name does not end with a slash (ie it points to a file)
      baseName.remove(baseName.size() - 1);
      if (baseName.isEmpty())
      {
        return stripNavigationPaths(targetFile);
      }
    }

    for (int i = 0; i < urlName.size(); i++)
    {
      final String pathElement = (String) urlName.get(i);
      if ("".equals(pathElement) || pathElement == null)
      {
        continue;
      }
      if (".".equals(pathElement))
      {
        continue;
      }
      if ("..".equals(pathElement))
      {
        if (baseName.isEmpty() == false)
        {
          baseName.remove(baseName.size() - 1);
        }
        continue;
      }
      baseName.add(pathElement);
    }

    final String s = formatName(baseName, null);
    if (targetFile.length() > 0 && targetFile.charAt(targetFile.length() - 1) == '/')
    {
      return s + '/';
    }
    return s;
  }

  /**
   * Normalizes the given pathname.
   *
   * @param targetFile the target file to be normalized, never null.
   * @return the normalized filename.
   */
  private String stripNavigationPaths (final String targetFile)
  {
    final List list = parseName(targetFile);
    final int capacity = list.size();
    final List path = new ArrayList(capacity);
    for (int i = 0; i < capacity; i++)
    {
      final String pathElement = (String) list.get(i);
      if ("".equals(pathElement) || pathElement == null)
      {
        continue;
      }
      if (".".equals(pathElement))
      {
        continue;
      }
      if ("..".equals(pathElement))
      {
        if (path.isEmpty() == false)
        {
          path.remove(path.size() - 1);
        }
        continue;
      }
      path.add(pathElement);
    }

    final String s = formatName(path, null);
    if (targetFile.length() > 0 && targetFile.charAt(targetFile.length() - 1) == '/')
    {
      return s + '/';
    }
    return s;
  }

  /**
   * Returns the path-portion of the given path (anything before the last slash or backslash) or an empty string.
   *
   * @param path the path or filename from where to extract the path name.
   * @return the extracted path or a empty string.
   */
  public String getPath(final String path)
  {
    // Check for slash and backslash
    final int last = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
    if (last < 0)
    {
      return "";
    }
    return path.substring(0, last);
  }

  /**
   * Converts a SQL-Clob object into a String. If the Clob is larger than 2^31 characters, we cannot convert it.
   * If there are errors converting it, this method will log the cause and return null.
   *
   * @param clob the clob to be read as string.
   * @return the string or null in case of errors.
   */
  public String readClob(final Clob clob)
  {
    try
    {
      final long length = clob.length();
      if (length > Integer.MAX_VALUE)
      {
        logger.warn ("This CLOB contains more than 2^31 characters. We cannot handle that.");
        return null;
      }

      final Reader inStream = clob.getCharacterStream();
      final StringWriter outStream = new StringWriter((int) length);
      try
      {
        IOUtils.getInstance().copyWriter(inStream, outStream);
      }
      catch (IOException e)
      {
        logger.warn ("Copying the stream failed.", e);
      }
      try
      {
        inStream.close();
      }
      catch (IOException e)
      {
        logger.warn ("Failed to close input stream. No worries, we will be alright anyway.", e);
      }
      return outStream.toString();
    }
    catch (SQLException e)
    {
      return null;
    }
  }


  /**
   * Converts a SQL-Clob object into a String. If the Clob is larger than 2^31 characters, we cannot convert it.
   * If there are errors converting it, this method will log the cause and return null.
   *
   * @param clob the clob to be read as string.
   * @return the string or null in case of errors.
   */
  public byte[] readBlob(final Blob clob) throws SQLException
  {
    final long length = clob.length();
    if (length > Integer.MAX_VALUE)
    {
      logger.warn ("This CLOB contains more than 2^31 characters. We cannot handle that.");
      return null;
    }

    final InputStream inStream = clob.getBinaryStream();
    final ByteArrayOutputStream outStream = new ByteArrayOutputStream((int) length);
    try
    {
      IOUtils.getInstance().copyStreams(inStream, outStream);
    }
    catch (IOException e)
    {
      logger.warn ("Copying the stream failed.", e);
    }
    try
    {
      inStream.close();
    }
    catch (IOException e)
    {
      logger.warn ("Failed to close input stream. No worries, we will be alright anyway.", e);
    }
    return outStream.toByteArray();
  }
}
