/*
 * 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.sis.internal.storage.io;

import java.util.Locale;
import java.io.File;
import java.io.FileInputStream;
import java.io.LineNumberReader;
import java.io.Reader;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.net.URLDecoder;
import java.net.URISyntaxException;
import java.net.MalformedURLException;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.OpenOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.FileSystemNotFoundException;
import java.nio.charset.StandardCharsets;
import javax.imageio.stream.ImageInputStream;
import javax.xml.stream.Location;
import javax.xml.stream.XMLStreamReader;
import org.apache.sis.util.CharSequences;
import org.apache.sis.util.Exceptions;
import org.apache.sis.util.Static;
import org.apache.sis.util.resources.Errors;
import org.apache.sis.internal.storage.Resources;


/**
 * Utility methods related to I/O operations. Many methods in this class accept arbitrary {@link Object} argument
 * and perform a sequence of {@code instanceof} checks. Since this approach provides no type safety and since the
 * sequence of {@code instanceof} checks is somewhat arbitrary, those methods can not be in public API.
 *
 * <p>Unless otherwise specified, giving an instance of unknown type or a {@code null} value cause the methods to
 * return {@code null}. No exception is thrown for unknown type - callers must check that the return value is not
 * null. However exceptions may be thrown for malformed URI or URL.</p>
 *
 * @author  Martin Desruisseaux (Geomatys)
 * @author  Johann Sorel (Geomatys)
 * @version 0.8
 * @since   0.3
 * @module
 */
public final class IOUtilities extends Static {
    /**
     * Do not allow instantiation of this class.
     */
    private IOUtilities() {
    }

    /**
     * Returns the filename from a {@link Path}, {@link File}, {@link URL}, {@link URI} or {@link CharSequence}
     * instance. If the given argument is specialized type like {@code Path} or {@code File}, then this method uses
     * dedicated API like {@link Path#getFileName()}. Otherwise this method gets a string representation of the path
     * and returns the part after the last {@code '/'} or platform-dependent name separator character, if any.
     *
     * @param  path  the path as an instance of one of the above-cited types, or {@code null}.
     * @return the filename in the given path, or {@code null} if the given object is null or of unknown type.
     */
    public static String filename(final Object path) {
        return part(path, false);
    }

    /**
     * Returns the filename extension (without leading dot) from a {@link Path}, {@link File}, {@link URL},
     * {@link URI} or {@link CharSequence} instance. If no extension is found, returns an empty string.
     * If the given object is of unknown type, return {@code null}.
     *
     * @param  path  the path as an instance of one of the above-cited types, or {@code null}.
     * @return the extension in the given path, or an empty string if none, or {@code null}
     *         if the given object is null or of unknown type.
     */
    public static String extension(final Object path) {
        return part(path, true);
    }

    /**
     * Implementation of {@link #filename(Object)} and {@link #extension(Object)} methods.
     */
    private static String part(final Object path, final boolean extension) {
        int fromIndex = 0;
        final String name;
        if (path instanceof File) {
            name = ((File) path).getName();
        } else if (path instanceof Path) {
            name = ((Path) path).getFileName().toString();
        } else {
            char separator = '/';
            if (path instanceof URL) {
                name = ((URL) path).getPath();
            } else if (path instanceof URI) {
                final URI uri = (URI) path;
                name = uri.isOpaque() ? uri.getSchemeSpecificPart() : uri.getPath();
            } else if (path instanceof CharSequence) {
                name = path.toString();
                separator = File.separatorChar;
            } else {
                return null;
            }
            fromIndex = name.lastIndexOf('/') + 1;
            if (separator != '/') {
                // Search for platform-specific character only if the object is neither a URL or a URI.
                fromIndex = Math.max(fromIndex, CharSequences.lastIndexOf(name, separator, fromIndex, name.length()) + 1);
            }
        }
        if (extension) {
            fromIndex = CharSequences.lastIndexOf(name, '.', fromIndex, name.length()) + 1;
            if (fromIndex <= 1) {
                // If the dot is the first character, do not consider as a filename extension.
                return "";
            }
        }
        return name.substring(fromIndex);
    }

    /**
     * Returns a string representation of the given path, or {@code null} if none. The current implementation
     * recognizes only the {@link Path}, {@link File}, {@link URL}, {@link URI} or {@link CharSequence} types.
     *
     * @param  path  the path for which to return a string representation.
     * @return the string representation, or {@code null} if none.
     */
    public static String toString(final Object path) {
        /*
         * For the following types, the string that we want can be obtained only by toString(),
         * or the class is final so we know that the toString(à behavior can not be changed.
         */
        if (path instanceof CharSequence || path instanceof Path || path instanceof URL || path instanceof URI) {
            return path.toString();
        }
        /*
         * While toString() would work too on the default implementation, the following
         * type is not final. So we are better to invoke the dedicated method.
         */
        if (path instanceof File) {
            return ((File) path).getPath();
        }
        return null;
    }

    /**
     * Returns the given path without the directories and without the extension.
     * For example if the given path is {@code "/Users/name/Map.png"}, then this
     * method returns {@code "Map"}.
     *
     * @param  path  the path from which to get the filename without extension, or {@code null}.
     * @return the filename without extension, or {@code null} if none.
     */
    public static String filenameWithoutExtension(String path) {
        if (path != null) {
            int s = path.lastIndexOf(File.separatorChar);
            if (s < 0 && File.separatorChar != '/') {
                s = path.lastIndexOf('/');
            }
            int e = path.lastIndexOf('.');
            if (e <= ++s) {
                e = path.length();
            }
            path = path.substring(s, e);
        }
        return path;
    }

    /**
     * Encodes the characters that are not legal for the {@link URI#URI(String)} constructor.
     * Note that in addition to unreserved characters ("{@code _-!.~'()*}"), the reserved
     * characters ("{@code ?/[]@}") and the punctuation characters ("{@code ,;:$&+=}")
     * are left unchanged, so they will be processed with their special meaning by the
     * URI constructor.
     *
     * <p>The current implementations replaces only the space characters, control characters
     * and the {@code %} character. Future versions may replace more characters as we learn
     * from experience.</p>
     *
     * @param  path  the path to encode, or {@code null}.
     * @return the encoded path, or {@code null} if and only if the given path was null.
     */
    public static String encodeURI(final String path) {
        if (path == null) {
            return null;
        }
        StringBuilder buffer = null;
        final int length = path.length();
        for (int i=0; i<length;) {
            final int c = path.codePointAt(i);
            final int n = Character.charCount(c);
            if (!Character.isSpaceChar(c) && !Character.isISOControl(c) && c != '%') {
                /*
                 * The character is valid, or is punction character, or is a reserved character.
                 * All those characters should be handled properly by the URI(String) constructor.
                 */
                if (buffer != null) {
                    buffer.appendCodePoint(c);
                }
            } else {
                /*
                 * The character is invalid, so we need to escape it. Note that the encoding
                 * is fixed to UTF-8 as of java.net.URI specification (see its class javadoc).
                 */
                if (buffer == null) {
                    buffer = new StringBuilder(path);
                    buffer.setLength(i);
                }
                for (final byte b : path.substring(i, i+n).getBytes(StandardCharsets.UTF_8)) {
                    buffer.append('%');
                    final String hex = Integer.toHexString(Byte.toUnsignedInt(b)).toUpperCase(Locale.ROOT);
                    if (hex.length() < 2) {
                        buffer.append('0');
                    }
                    buffer.append(hex);
                }
            }
            i += n;
        }
        return (buffer != null) ? buffer.toString() : path;
    }

    /**
     * Converts a {@link URL} to a {@link URI}. This is equivalent to a call to the standard {@link URL#toURI()}
     * method, except for the following functionalities:
     *
     * <ul>
     *   <li>Optionally decodes the {@code "%XX"} sequences, where {@code "XX"} is a number.</li>
     *   <li>Converts various exceptions into subclasses of {@link IOException}.</li>
     * </ul>
     *
     * @param  url       the URL to convert, or {@code null}.
     * @param  encoding  if the URL is encoded in a {@code application/x-www-form-urlencoded} MIME format,
     *                   the character encoding (normally {@code "UTF-8"}). If the URL is not encoded,
     *                   then {@code null}.
     * @return the URI for the given URL, or {@code null} if the given URL was null.
     * @throws IOException if the URL can not be converted to a URI.
     *
     * @see URI#URI(String)
     */
    public static URI toURI(final URL url, final String encoding) throws IOException {
        if (url == null) {
            return null;
        }
        /*
         * Convert the URL to a URI, taking in account the encoding if any.
         *
         * Note: URL.toURI() is implemented as new URI(URL.toString()) where toString()
         * delegates to toExternalForm(), and all those methods are final. So we really
         * don't lost anything by doing those steps ourself.
         */
        String path = url.toExternalForm();
        if (encoding != null) {
            path = URLDecoder.decode(path, encoding);
        }
        path = encodeURI(path);
        try {
            return new URI(path);
        } catch (URISyntaxException cause) {
            /*
             * Occurs only if the URL is not compliant with RFC 2396. Otherwise every URL
             * should succeed, so a failure can actually be considered as a malformed URL.
             */
            throw (MalformedURLException) new MalformedURLException(Exceptions.formatChainedMessages(null,
                    Errors.format(Errors.Keys.IllegalArgumentValue_2, "URL", path), cause)).initCause(cause);
        }
    }

    /**
     * Converts a {@link URL} to a {@link File}. This is equivalent to a call to the standard
     * {@link URL#toURI()} method followed by a call to the {@link File#File(URI)} constructor,
     * except for the following functionalities:
     *
     * <ul>
     *   <li>Optionally decodes the {@code "%XX"} sequences, where {@code "XX"} is a number.</li>
     *   <li>Converts various exceptions into subclasses of {@link IOException}.</li>
     * </ul>
     *
     * @param  url       the URL to convert, or {@code null}.
     * @param  encoding  if the URL is encoded in a {@code application/x-www-form-urlencoded} MIME format,
     *                   the character encoding (normally {@code "UTF-8"}). If the URL is not encoded,
     *                   then {@code null}.
     * @return the file for the given URL, or {@code null} if the given URL was null.
     * @throws IOException if the URL can not be converted to a file.
     *
     * @see File#File(URI)
     */
    public static File toFile(final URL url, final String encoding) throws IOException {
        if (url == null) {
            return null;
        }
        final URI uri = toURI(url, encoding);
        /*
         * We really want to call the File constructor expecting a URI argument,
         * not the constructor expecting a String argument, because the one for
         * the URI argument performs additional platform-specific parsing.
         */
        try {
            return new File(uri);
        } catch (IllegalArgumentException cause) {
            /*
             * Typically happen when the URI scheme is not "file". But may also happen if the
             * URI contains fragment that can not be represented in a File (e.g. a Query part).
             * The IllegalArgumentException does not allow us to distinguish those cases.
             */
            throw new IOException(Exceptions.formatChainedMessages(null,
                    Errors.format(Errors.Keys.IllegalArgumentValue_2, "URL", url), cause), cause);
        }
    }

    /**
     * Converts a {@link URL} to a {@link Path}. This is equivalent to a call to the standard
     * {@link URL#toURI()} method followed by a call to the {@link Paths#get(URI)} static method,
     * except for the following functionalities:
     *
     * <ul>
     *   <li>Optionally decodes the {@code "%XX"} sequences, where {@code "XX"} is a number.</li>
     *   <li>Converts various exceptions into subclasses of {@link IOException}.</li>
     * </ul>
     *
     * @param  url       the URL to convert, or {@code null}.
     * @param  encoding  if the URL is encoded in a {@code application/x-www-form-urlencoded} MIME format,
     *                   the character encoding (normally {@code "UTF-8"}). If the URL is not encoded,
     *                   then {@code null}.
     * @return the path for the given URL, or {@code null} if the given URL was null.
     * @throws IOException if the URL can not be converted to a path.
     *
     * @see Paths#get(URI)
     */
    public static Path toPath(final URL url, final String encoding) throws IOException {
        if (url == null) {
            return null;
        }
        final URI uri = toURI(url, encoding);
        try {
            return Paths.get(uri);
        } catch (IllegalArgumentException | FileSystemNotFoundException cause) {
            final String message = Exceptions.formatChainedMessages(null,
                    Errors.format(Errors.Keys.IllegalArgumentValue_2, "URL", url), cause);
            /*
             * If the exception is IllegalArgumentException, then the URI scheme has been recognized
             * but the URI syntax is illegal for that file system. So we can consider that the URL is
             * malformed in regard to the rules of that particular file system.
             */
            final IOException e;
            if (cause instanceof IllegalArgumentException) {
                e = new MalformedURLException(message);
                e.initCause(cause);
            } else {
                e = new IOException(message, cause);
            }
            throw e;
        }
    }

    /**
     * Parses the following path as a {@link File} if possible, or a {@link URL} otherwise.
     * In the special case where the given {@code path} is a URL using the {@code "file"} protocol,
     * the URL is converted to a {@link File} object using the given {@code encoding} for decoding
     * the {@code "%XX"} sequences, if any.
     *
     * <div class="section">Rational</div>
     * A URL can represent a file, but {@link URL#openStream()} appears to return a {@code BufferedInputStream}
     * wrapping the {@link FileInputStream}, which is not a desirable feature when we want to obtain a channel.
     *
     * @param  path      the path to convert, or {@code null}.
     * @param  encoding  if the URL is encoded in a {@code application/x-www-form-urlencoded} MIME format,
     *                   the character encoding (normally {@code "UTF-8"}). If the URL is not encoded,
     *                   then {@code null}. This argument is ignored if the given path does not need
     *                   to be converted from URL to {@code File}.
     * @return the path as a {@link File} if possible, or a {@link URL} otherwise.
     * @throws IOException if the given path is not a file and can't be parsed as a URL.
     */
    public static Object toFileOrURL(final String path, final String encoding) throws IOException {
        if (path == null) {
            return null;
        }
        /*
         * Check if the path seems to be a local file. Those paths are assumed never encoded.
         * The heuristic rules applied here may change in any future SIS version.
         */
        if (path.indexOf('?') < 0 && path.indexOf('#') < 0) {
            final int s = path.indexOf(':');
            /*
             * If the ':' character is found, the part before it is probably a protocol in a URL,
             * except in the particular case where there is just one letter before ':'. In such
             * case, it may be the drive letter of a Windows file.
             */
            if (s<0 || (s==1 && Character.isLetter(path.charAt(0)) && !path.regionMatches(2, "//", 0, 2))) {
                return new File(path);
            }
        }
        final URL url = new URL(path);
        final String scheme = url.getProtocol();
        if (scheme != null && scheme.equalsIgnoreCase("file")) {
            return toFile(url, encoding);
        }
        /*
         * Leave the URL in its original encoding on the assumption that this is the encoding expected by
         * the server. This is different than the policy for URI, because the later are always in UTF-8.
         * If a URI is needed, callers should use toURI(url, encoding).
         */
        return url;
    }

    /**
     * Converts the given output stream to an input stream. It is caller's responsibility to flush
     * the stream and reset its position to the beginning of file before to invoke this method.
     * The data read by the input stream will be the data that have been written in the output stream
     * before this method is invoked.
     *
     * <p>The given output stream should not be used anymore after this method invocation, but should
     * not be closed neither since the returned input stream may be backed by the same channel.</p>
     *
     * @param  stream  the input or output stream to converts to an {@code InputStream}.
     * @return the input stream, or {@code null} if the given stream can not be converted.
     * @throws IOException if an error occurred during input stream creation.
     *
     * @since 0.8
     */
    public static InputStream toInputStream(AutoCloseable stream) throws IOException {
        if (stream != null) {
            if (stream instanceof InputStream) {
                return (InputStream) stream;
            }
            if (stream instanceof OutputStreamAdapter) {
                stream = ((OutputStreamAdapter) stream).output;
            }
            if (stream instanceof ChannelDataOutput) {
                final ChannelDataOutput c = (ChannelDataOutput) stream;
                if (c.channel instanceof ReadableByteChannel) {
                    stream = new ChannelImageInputStream(c.filename, (ReadableByteChannel) c.channel, c.buffer, true);
                }
            }
            if (stream instanceof ImageInputStream) {
                return new InputStreamAdapter((ImageInputStream) stream);
            }
        }
        return null;
    }

    /**
     * Converts the given input stream to an output stream. It is caller's responsibility to reset
     * the stream position to the beginning of file before to invoke this method. The data written
     * by the output stream will overwrite the previous data, but the caller may need to
     * {@linkplain #truncate truncate} the output stream after he finished to write in it.
     *
     * <p>The given input stream should not be used anymore after this method invocation, but should
     * not be closed neither since the returned output stream may be backed by the same channel.</p>
     *
     * @param  stream  the input or output stream to converts to an {@code OutputStream}.
     * @return the output stream, or {@code null} if the given stream can not be converted.
     * @throws IOException if an error occurred during output stream creation.
     *
     * @since 0.8
     */
    public static OutputStream toOutputStream(AutoCloseable stream) throws IOException {
        if (stream != null) {
            if (stream instanceof OutputStream) {
                return (OutputStream) stream;
            }
            if (stream instanceof InputStreamAdapter) {
                stream = ((InputStreamAdapter) stream).input;
            }
            if (stream instanceof ChannelDataInput) {
                final ChannelDataInput c = (ChannelDataInput) stream;
                if (c.channel instanceof WritableByteChannel) {
                    stream = new ChannelImageOutputStream(c.filename, (WritableByteChannel) c.channel, c.buffer);
                }
            }
            if (stream instanceof ChannelImageOutputStream) {
                return new OutputStreamAdapter((ChannelImageOutputStream) stream);
            }
        }
        return null;
    }

    /**
     * Truncates the given output stream at its current position.
     * This method works with Apache SIS implementations backed (sometime indirectly) by {@link SeekableByteChannel}.
     * Callers may need to {@linkplain java.io.Flushable#flush() flush} the stream before to invoke this method.
     *
     * @param  stream  the output stream or writable channel to truncate.
     * @return whether this method has been able to truncate the given stream.
     * @throws IOException if an error occurred while truncating the stream.
     */
    public static boolean truncate(AutoCloseable stream) throws IOException {
        if (stream instanceof OutputStreamAdapter) {
            stream = ((OutputStreamAdapter) stream).output;
        }
        if (stream instanceof ChannelDataOutput) {
            stream = ((ChannelDataOutput) stream).channel;
        }
        if (stream instanceof SeekableByteChannel) {
            final SeekableByteChannel s = (SeekableByteChannel) stream;
            s.truncate(s.position());
            return true;
        }
        return false;
    }

    /**
     * Returns {@code true} if the given options would open a file mostly for writing.
     * This method returns {@code true} if the following conditions are true:
     *
     * <ul>
     *   <li>The array contains {@link StandardOpenOption#WRITE}.</li>
     *   <li>The array does not contain {@link StandardOpenOption#READ}, unless the array contains also
     *       {@link StandardOpenOption#CREATE_NEW} or {@link StandardOpenOption#TRUNCATE_EXISTING} in which
     *       case the {@code READ} option is ignored (because the caller would have no data to read).</li>
     * </ul>
     *
     * @param  options  the open options to check, or {@code null} if none.
     * @return {@code true} if a file opened with the given options would be mostly for write operations.
     *
     * @since 0.8
     */
    public static boolean isWrite(final OpenOption[] options) {
        boolean isRead   = false;
        boolean isWrite  = false;
        boolean truncate = false;
        if (options != null) {
            for (final OpenOption op : options) {
                if (op instanceof StandardOpenOption) {
                    switch ((StandardOpenOption) op) {
                        case READ:              isRead   = true; break;
                        case WRITE:             isWrite  = true; break;
                        case CREATE_NEW:
                        case TRUNCATE_EXISTING: truncate = true; break;
                    }
                }
            }
        }
        return isWrite & (!isRead | truncate);
    }

    /**
     * Reads the next character as an Unicode code point. Unless end-of-file has been reached, the returned value is
     * between {@value java.lang.Character#MIN_CODE_POINT} and {@value java.lang.Character#MAX_CODE_POINT} inclusive.
     *
     * @param  in  the reader from which to read code point.
     * @return the next code point, or -1 on end of file.
     * @throws IOException if an error occurred while reading characters.
     *
     * @since 0.8
     */
    public static int readCodePoint(final Reader in) throws IOException {
        int c = in.read();
        while (c >= Character.MIN_HIGH_SURROGATE && c <= Character.MAX_HIGH_SURROGATE) {
            final int low = in.read();
            if (low >= Character.MIN_LOW_SURROGATE && low <= Character.MAX_LOW_SURROGATE) {
                c = Character.toCodePoint((char) c, (char) low);
                break;
            } else {
                c = low;        // Discard orphan high surrogate and take the next character.
            }
        }
        return c;
    }

    /**
     * Returns the error message for a file that can not be parsed.
     * The error message will contain the line number if available.
     *
     * @param  locale    the language for the error message.
     * @param  format    abbreviation of the file format (e.g. "CSV", "GML", "WKT", <i>etc</i>).
     * @param  filename  name of the file or the data store.
     * @param  store     the input or output object, or {@code null}.
     * @return the parameters for a localized error message for a file that can not be processed.
     *
     * @since 0.8
     */
    public static String canNotReadFile(final Locale locale, final String format, final String filename, final Object store) {
        final Object[] parameters = errorMessageParameters(format, filename, store);
        return Resources.forLocale(locale).getString(errorMessageKey(parameters), parameters);
    }

    /**
     * Returns the {@link Resources.Keys} value together with the parameters given by {@code errorMessageParameters(…)}.
     *
     * @param   parameters  the result of {@code errorMessageParameters(…)} method call.
     * @return  the {@link Resources.Keys} value to use for formatting the error message.
     *
     * @since 0.8
     */
    public static short errorMessageKey(final Object[] parameters) {
        return (parameters.length == 2) ? Resources.Keys.CanNotReadFile_2 :
               (parameters.length == 3) ? Resources.Keys.CanNotReadFile_3 :
                                          Resources.Keys.CanNotReadFile_4;
    }

    /**
     * Returns the parameters for an error message saying that an error occurred while processing a file.
     * This method uses the information provided by methods like {@link LineNumberReader#getLineNumber()}
     * or {@link XMLStreamReader#getLocation()} if the given {@code store} is one of the supported types.
     *
     * @param  format    abbreviation of the file format (e.g. "CSV", "GML", "WKT", <i>etc</i>).
     * @param  filename  name of the file or the data store.
     * @param  store     the input or output object, or {@code null}.
     * @return the parameters for a localized error message for a file that can not be processed.
     *
     * @since 0.8
     */
    @SuppressWarnings("fallthrough")
    public static Object[] errorMessageParameters(final String format, final String filename, final Object store) {
        int line   = 0;
        int column = 0;
        if (store instanceof XMLStreamReader) {
            final Location location = ((XMLStreamReader) store).getLocation();
            line   = location.getLineNumber()   + 1;
            column = location.getColumnNumber() + 1;
        } else if (store instanceof LineNumberReader) {
            line = ((LineNumberReader) store).getLineNumber();
        }
        final Object[] params = new Object[(line == 0) ? 2 : (column == 0) ? 3 : 4];
        switch (params.length) {
            default: // Fallthrough everywhere
            case 4:  params[3] = column;
            case 3:  params[2] = line;
            case 2:  params[1] = filename;
            case 1:  params[0] = format;
            case 0:  break;
        }
        return params;
    }
}
