/*
 * 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.commons.vfs2.provider;

import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.cert.Certificate;
import java.util.Collections;
import java.util.Map;
import java.util.Set;

import org.apache.commons.vfs2.FileContent;
import org.apache.commons.vfs2.FileContentInfo;
import org.apache.commons.vfs2.FileContentInfoFactory;
import org.apache.commons.vfs2.FileObject;
import org.apache.commons.vfs2.FileSystemException;
import org.apache.commons.vfs2.RandomAccessContent;
import org.apache.commons.vfs2.util.MonitorInputStream;
import org.apache.commons.vfs2.util.MonitorOutputStream;
import org.apache.commons.vfs2.util.MonitorRandomAccessContent;
import org.apache.commons.vfs2.util.RandomAccessMode;
import org.apache.commons.vfs2.util.RawMonitorInputStream;

/**
 * The content of a file.
 */
public final class DefaultFileContent implements FileContent {

    /*
     * static final int STATE_NONE = 0; static final int STATE_READING = 1; static final int STATE_WRITING = 2; static
     * final int STATE_RANDOM_ACCESS = 3;
     */

    static final int STATE_CLOSED = 0;
    static final int STATE_OPENED = 1;

    /**
     * The default buffer size for {@link #write(OutputStream)}
     */
    private static final int WRITE_BUFFER_SIZE = 4096;

    private final AbstractFileObject fileObject;
    private Map<String, Object> attrs;
    private Map<String, Object> roAttrs;
    private FileContentInfo fileContentInfo;
    private final FileContentInfoFactory fileContentInfoFactory;

    private final ThreadLocal<FileContentThreadData> threadLocal = new ThreadLocal<>();
    private boolean resetAttributes;

    /**
     * Counts open streams for this file.
     */
    private int openStreams;

    public DefaultFileContent(final AbstractFileObject file, final FileContentInfoFactory fileContentInfoFactory) {
        this.fileObject = file;
        this.fileContentInfoFactory = fileContentInfoFactory;
    }

    private FileContentThreadData getOrCreateThreadData() {
        FileContentThreadData data = this.threadLocal.get();
        if (data == null) {
            data = new FileContentThreadData();
            this.threadLocal.set(data);
        }
        return data;
    }

    void streamOpened() {
        synchronized (this) {
            openStreams++;
        }
        ((AbstractFileSystem) fileObject.getFileSystem()).streamOpened();
    }

    void streamClosed() {
        synchronized (this) {
            if (openStreams > 0) {
                openStreams--;
                if (openStreams < 1) {
                    fileObject.notifyAllStreamsClosed();
                }
            }
        }
        ((AbstractFileSystem) fileObject.getFileSystem()).streamClosed();
    }

    /**
     * Returns the file that this is the content of.
     *
     * @return the FileObject.
     */
    @Override
    public FileObject getFile() {
        return fileObject;
    }

    /**
     * Returns the size of the content (in bytes).
     *
     * @return The size of the content (in bytes).
     * @throws FileSystemException if an error occurs.
     */
    @Override
    public long getSize() throws FileSystemException {
        // Do some checking
        if (!fileObject.getType().hasContent()) {
            throw new FileSystemException("vfs.provider/get-size-not-file.error", fileObject);
        }
        /*
         * if (getThreadData().getState() == STATE_WRITING || getThreadData().getState() == STATE_RANDOM_ACCESS) { throw
         * new FileSystemException("vfs.provider/get-size-write.error", file); }
         */

        try {
            // Get the size
            return fileObject.doGetContentSize();
        } catch (final Exception exc) {
            throw new FileSystemException("vfs.provider/get-size.error", exc, fileObject);
        }
    }

    /**
     * Returns the last-modified timestamp.
     *
     * @return The last modified timestamp.
     * @throws FileSystemException if an error occurs.
     */
    @Override
    public long getLastModifiedTime() throws FileSystemException {
        /*
         * if (getThreadData().getState() == STATE_WRITING || getThreadData().getState() == STATE_RANDOM_ACCESS) { throw
         * new FileSystemException("vfs.provider/get-last-modified-writing.error", file); }
         */
        if (!fileObject.getType().hasAttributes()) {
            throw new FileSystemException("vfs.provider/get-last-modified-no-exist.error", fileObject);
        }
        try {
            return fileObject.doGetLastModifiedTime();
        } catch (final Exception e) {
            throw new FileSystemException("vfs.provider/get-last-modified.error", fileObject, e);
        }
    }

    /**
     * Sets the last-modified timestamp.
     *
     * @param modTime The last modified timestamp.
     * @throws FileSystemException if an error occurs.
     */
    @Override
    public void setLastModifiedTime(final long modTime) throws FileSystemException {
        /*
         * if (getThreadData().getState() == STATE_WRITING || getThreadData().getState() == STATE_RANDOM_ACCESS) { throw
         * new FileSystemException("vfs.provider/set-last-modified-writing.error", file); }
         */
        if (!fileObject.getType().hasAttributes()) {
            throw new FileSystemException("vfs.provider/set-last-modified-no-exist.error", fileObject);
        }
        try {
            if (!fileObject.doSetLastModifiedTime(modTime)) {
                throw new FileSystemException("vfs.provider/set-last-modified.error", fileObject);
            }
        } catch (final Exception e) {
            throw new FileSystemException("vfs.provider/set-last-modified.error", fileObject, e);
        }
    }

    /**
     * Checks if an attribute exists.
     *
     * @param attrName The name of the attribute to check.
     * @return true if the attribute is associated with the file.
     * @throws FileSystemException if an error occurs.
     * @since 2.0
     */
    @Override
    public boolean hasAttribute(final String attrName) throws FileSystemException {
        if (!fileObject.getType().hasAttributes()) {
            throw new FileSystemException("vfs.provider/exists-attributes-no-exist.error", fileObject);
        }
        getAttributes();
        return attrs.containsKey(attrName);
    }

    /**
     * Returns a read-only map of this file's attributes.
     *
     * @return a Map of the file's attributes.
     * @throws FileSystemException if an error occurs.
     */
    @Override
    public Map<String, Object> getAttributes() throws FileSystemException {
        if (!fileObject.getType().hasAttributes()) {
            throw new FileSystemException("vfs.provider/get-attributes-no-exist.error", fileObject);
        }
        if (resetAttributes || roAttrs == null) {
            try {
                synchronized (this) {
                    attrs = fileObject.doGetAttributes();
                    roAttrs = Collections.unmodifiableMap(attrs);
                    resetAttributes = false;
                }
            } catch (final Exception e) {
                throw new FileSystemException("vfs.provider/get-attributes.error", fileObject, e);
            }
        }
        return roAttrs;
    }

    /**
     * Used internally to flag situations where the file attributes should be reretrieved.
     *
     * @since 2.0
     */
    public void resetAttributes() {
        resetAttributes = true;
    }

    /**
     * Lists the attributes of this file.
     *
     * @return An array of attribute names.
     * @throws FileSystemException if an error occurs.
     */
    @Override
    public String[] getAttributeNames() throws FileSystemException {
        getAttributes();
        final Set<String> names = attrs.keySet();
        return names.toArray(new String[names.size()]);
    }

    /**
     * Gets the value of an attribute.
     *
     * @param attrName The attribute name.
     * @return The value of the attribute or null.
     * @throws FileSystemException if an error occurs.
     */
    @Override
    public Object getAttribute(final String attrName) throws FileSystemException {
        getAttributes();
        return attrs.get(attrName);
    }

    /**
     * Sets the value of an attribute.
     *
     * @param attrName The name of the attribute to add.
     * @param value The value of the attribute.
     * @throws FileSystemException if an error occurs.
     */
    @Override
    public void setAttribute(final String attrName, final Object value) throws FileSystemException {
        if (!fileObject.getType().hasAttributes()) {
            throw new FileSystemException("vfs.provider/set-attribute-no-exist.error", attrName, fileObject);
        }
        try {
            fileObject.doSetAttribute(attrName, value);
        } catch (final Exception e) {
            throw new FileSystemException("vfs.provider/set-attribute.error", e, attrName, fileObject);
        }

        if (attrs != null) {
            attrs.put(attrName, value);
        }
    }

    /**
     * Removes an attribute.
     *
     * @param attrName The name of the attribute to remove.
     * @throws FileSystemException if an error occurs.
     * @since 2.0
     */
    @Override
    public void removeAttribute(final String attrName) throws FileSystemException {
        if (!fileObject.getType().hasAttributes()) {
            throw new FileSystemException("vfs.provider/remove-attribute-no-exist.error", fileObject);
        }

        try {
            fileObject.doRemoveAttribute(attrName);
        } catch (final Exception e) {
            throw new FileSystemException("vfs.provider/remove-attribute.error", e, attrName, fileObject);
        }

        if (attrs != null) {
            attrs.remove(attrName);
        }
    }

    /**
     * Returns the certificates used to sign this file.
     *
     * @return An array of Certificates.
     * @throws FileSystemException if an error occurs.
     */
    @Override
    public Certificate[] getCertificates() throws FileSystemException {
        if (!fileObject.exists()) {
            throw new FileSystemException("vfs.provider/get-certificates-no-exist.error", fileObject);
        }
        /*
         * if (getThreadData().getState() == STATE_WRITING || getThreadData().getState() == STATE_RANDOM_ACCESS) { throw
         * new FileSystemException("vfs.provider/get-certificates-writing.error", file); }
         */

        try {
            final Certificate[] certs = fileObject.doGetCertificates();
            if (certs != null) {
                return certs;
            }
            return new Certificate[0];
        } catch (final Exception e) {
            throw new FileSystemException("vfs.provider/get-certificates.error", fileObject, e);
        }
    }

    /**
     * Returns an input stream for reading the content.
     *
     * @return The InputStream
     * @throws FileSystemException if an error occurs.
     */
    @Override
    public InputStream getInputStream() throws FileSystemException {
        return buildInputStream(0);
    }

    /**
     * Returns an input stream for reading the content.
     *
     * @param bufferSize The buffer size to use.
     * @return The InputStream
     * @throws FileSystemException if an error occurs.
     * @since 2.4
     */
    @Override
    public InputStream getInputStream(final int bufferSize) throws FileSystemException {
        return buildInputStream(bufferSize);
    }

    /**
     * Returns an input/output stream to use to read and write the content of the file in an random manner.
     *
     * @param mode The RandomAccessMode.
     * @return A RandomAccessContent object to access the file.
     * @throws FileSystemException if an error occurs.
     */
    @Override
    public RandomAccessContent getRandomAccessContent(final RandomAccessMode mode) throws FileSystemException {
        /*
         * if (getThreadData().getState() != STATE_NONE) { throw new
         * FileSystemException("vfs.provider/read-in-use.error", file); }
         */

        // Get the content
        final RandomAccessContent rastr = fileObject.getRandomAccessContent(mode);

        final FileRandomAccessContent rac = new FileRandomAccessContent(fileObject, rastr);

        getOrCreateThreadData().addRastr(rac);
        streamOpened();

        return rac;
    }

    /**
     * Returns an output stream for writing the content.
     *
     * @return The OutputStream for the file.
     * @throws FileSystemException if an error occurs.
     */
    @Override
    public OutputStream getOutputStream() throws FileSystemException {
        return getOutputStream(false);
    }

    /**
     * Returns an output stream for writing the content in append mode.
     *
     * @param bAppend true if the data written should be appended.
     * @return The OutputStream for the file.
     * @throws FileSystemException if an error occurs.
     */
    @Override
    public OutputStream getOutputStream(final boolean bAppend) throws FileSystemException {
        return buildOutputStream(bAppend, 0);
    }

    /**
     * Returns an output stream for writing the content.
     *
     * @param bufferSize The buffer size to use.
     * @return The OutputStream for the file.
     * @throws FileSystemException if an error occurs.
     * @since 2.4
     */
    @Override
    public OutputStream getOutputStream(final int bufferSize) throws FileSystemException {
        return buildOutputStream(false, bufferSize);
    }

    /**
     * Returns an output stream for writing the content in append mode.
     *
     * @param bAppend true if the data written should be appended.
     * @param bufferSize The buffer size to use.
     * @return The OutputStream for the file.
     * @throws FileSystemException if an error occurs.
     * @since 2.4
     */
    @Override
    public OutputStream getOutputStream(final boolean bAppend, final int bufferSize) throws FileSystemException {
        return buildOutputStream(bAppend, bufferSize);
    }

    /**
     * Closes all resources used by the content, including all streams, readers and writers.
     *
     * @throws FileSystemException if an error occurs.
     */
    @Override
    public void close() throws FileSystemException {
        FileSystemException caught = null;
        try {
            final FileContentThreadData fileContentThreadData = getOrCreateThreadData();

            // Close the input stream
            while (fileContentThreadData.getInstrsSize() > 0) {
                final InputStream inputStream = fileContentThreadData.removeInputStream(0);
                try {
                    if (inputStream instanceof FileContentInputStream) {
                        ((FileContentInputStream) inputStream).close();
                    } else if (inputStream instanceof RawFileContentInputStream) {
                        ((RawFileContentInputStream) inputStream).close();
                    } else {
                       caught = new FileSystemException("Unsupported InputStream type: " + inputStream);
                    }
                } catch (final FileSystemException ex) {
                    caught = ex;

                }
            }

            // Close the randomAccess stream
            while (fileContentThreadData.getRastrsSize() > 0) {
                final FileRandomAccessContent randomAccessContent = (FileRandomAccessContent) fileContentThreadData
                        .removeRastr(0);
                try {
                    randomAccessContent.close();
                } catch (final FileSystemException ex) {
                    caught = ex;
                }
            }

            // Close the output stream
            final FileContentOutputStream outputStream = fileContentThreadData.getOutstr();
            if (outputStream != null) {
                fileContentThreadData.setOutstr(null);
                try {
                    outputStream.close();
                } catch (final FileSystemException ex) {
                    caught = ex;
                }
            }
        } finally {
            threadLocal.remove();
        }

        // throw last error (out >> rac >> input) after all closes have been tried
        if (caught != null) {
            throw caught;
        }
    }

    private InputStream buildInputStream(final int bufferSize) throws FileSystemException {
        /*
         * if (getThreadData().getState() == STATE_WRITING || getThreadData().getState() == STATE_RANDOM_ACCESS) { throw
         * new FileSystemException("vfs.provider/read-in-use.error", file); }
         */
        // Get the raw input stream
        // @formatter:off
        final InputStream inputStream = bufferSize == 0
                ? fileObject.getInputStream()
                : fileObject.getInputStream(bufferSize);
        // @formatter:on
        // Double buffering may take place here.
//        final InputStream wrappedInputStream = bufferSize == 0
//                    ? new FileContentInputStream(fileObject, inputStream)
//                    : new FileContentInputStream(fileObject, inputStream, bufferSize);

        InputStream wrappedInputStream;
        if (inputStream instanceof BufferedInputStream) {
            // Don't double buffer.
            wrappedInputStream = new RawFileContentInputStream(fileObject, inputStream);
        } else
        {
            // @formatter:off
            wrappedInputStream = bufferSize == 0
                    ? new FileContentInputStream(fileObject, inputStream)
                    : new FileContentInputStream(fileObject, inputStream, bufferSize);
            // @formatter:on
        }
        getOrCreateThreadData().addInstr(wrappedInputStream);
        streamOpened();

        return wrappedInputStream;
    }

    private OutputStream buildOutputStream(final boolean bAppend, final int bufferSize) throws FileSystemException {
        /*
         * if (getThreadData().getState() != STATE_NONE)
         */
        final FileContentThreadData streams = getOrCreateThreadData();

        if (streams.getOutstr() != null) {
            throw new FileSystemException("vfs.provider/write-in-use.error", fileObject);
        }

        // Get the raw output stream
        final OutputStream outstr = fileObject.getOutputStream(bAppend);

        // Create and set wrapper
        final FileContentOutputStream wrapped = bufferSize == 0 ?
            new FileContentOutputStream(fileObject, outstr) :
            new FileContentOutputStream(fileObject, outstr, bufferSize);
        streams.setOutstr(wrapped);
        streamOpened();

        return wrapped;
    }

    /**
     * Handles the end of input stream.
     */
    private void endInput(final InputStream instr) {
        final FileContentThreadData fileContentThreadData = threadLocal.get();
        if (fileContentThreadData != null) {
            fileContentThreadData.removeInstr(instr);
        }
        if (fileContentThreadData == null || !fileContentThreadData.hasStreams()) {
            // remove even when no value is set to remove key
            threadLocal.remove();
        }
        streamClosed();
    }

    /**
     * Handles the end of random access.
     */
    private void endRandomAccess(final RandomAccessContent rac) {
        final FileContentThreadData fileContentThreadData = threadLocal.get();
        if (fileContentThreadData != null) {
            fileContentThreadData.removeRastr(rac);
        }
        if (fileContentThreadData == null || !fileContentThreadData.hasStreams()) {
            // remove even when no value is set to remove key
            threadLocal.remove();
        }
        streamClosed();
    }

    /**
     * Handles the end of output stream.
     */
    private void endOutput() throws Exception {
        final FileContentThreadData fileContentThreadData = threadLocal.get();
        if (fileContentThreadData != null) {
            fileContentThreadData.setOutstr(null);
        }
        if (fileContentThreadData == null || !fileContentThreadData.hasStreams()) {
            // remove even when no value is set to remove key
            threadLocal.remove();
        }
        streamClosed();
        fileObject.endOutput();
    }

    /**
     * Checks if a input and/or output stream is open.
     * <p>
     * This checks only the scope of the current thread.
     * </p>
     *
     * @return true if this is the case
     */
    @Override
    public boolean isOpen() {
        final FileContentThreadData fileContentThreadData = threadLocal.get();
        if (fileContentThreadData != null && fileContentThreadData.hasStreams()) {
            return true;
        }
        // threadData.get() created empty entry
        threadLocal.remove();
        return false;
    }

    /**
     * Checks if an input or output stream is open. This checks all threads.
     *
     * @return true if this is the case
     */
    public boolean isOpenGlobal() {
        synchronized (this) {
            return openStreams > 0;
        }
    }

    /**
     * An input stream for reading content. Provides buffering, and end-of-stream monitoring.
     */
    private final class FileContentInputStream extends MonitorInputStream {
        // avoid gc
        private final FileObject file;

        FileContentInputStream(final FileObject file, final InputStream instr) {
            super(instr);
            this.file = file;
        }

        FileContentInputStream(final FileObject file, final InputStream instr, final int bufferSize) {
            super(instr, bufferSize);
            this.file = file;
        }

        /**
         * Closes this input stream.
         */
        @Override
        public void close() throws FileSystemException {
            try {
                super.close();
            } catch (final IOException e) {
                throw new FileSystemException("vfs.provider/close-instr.error", file, e);
            }
        }

        /**
         * Called after the stream has been closed.
         */
        @Override
        protected void onClose() throws IOException {
            try {
                super.onClose();
            } finally {
                endInput(this);
            }
        }
    }

    /**
     * An input stream for reading content. Provides buffering, and end-of-stream monitoring.
     * <p>
     * This is the same as {@link FileContentInputStream} but without the buffering.
     * </p>
     */
    private final class RawFileContentInputStream extends RawMonitorInputStream {
        // avoid gc
        private final FileObject file;

        RawFileContentInputStream(final FileObject file, final InputStream instr) {
            super(instr);
            this.file = file;
        }

        /**
         * Closes this input stream.
         */
        @Override
        public void close() throws FileSystemException {
            try {
                super.close();
            } catch (final IOException e) {
                throw new FileSystemException("vfs.provider/close-instr.error", file, e);
            }
        }

        /**
         * Called after the stream has been closed.
         */
        @Override
        protected void onClose() throws IOException {
            try {
                super.onClose();
            } finally {
                endInput(this);
            }
        }
    }

    /**
     * An input/output stream for reading/writing content on random positions
     */
    private final class FileRandomAccessContent extends MonitorRandomAccessContent {
        // also avoids gc
        private final FileObject file;

        FileRandomAccessContent(final FileObject file, final RandomAccessContent content) {
            super(content);
            this.file = file;
        }

        /**
         * Called after the stream has been closed.
         */
        @Override
        protected void onClose() throws IOException {
            try {
                super.onClose();
            } finally {
                endRandomAccess(this);
            }
        }

        @Override
        public void close() throws FileSystemException {
            try {
                super.close();
            } catch (final IOException e) {
                throw new FileSystemException("vfs.provider/close-rac.error", file, e);
            }
        }
    }

    /**
     * An output stream for writing content.
     */
    final class FileContentOutputStream extends MonitorOutputStream {
        // avoid gc
        private final FileObject file;

        FileContentOutputStream(final FileObject file, final OutputStream outstr) {
            super(outstr);
            this.file = file;
        }

        FileContentOutputStream(final FileObject file, final OutputStream outstr, final int bufferSize) {
            super(outstr, bufferSize);
            this.file = file;
        }

        /**
         * Closes this output stream.
         */
        @Override
        public void close() throws FileSystemException {
            try {
                super.close();
            } catch (final IOException e) {
                throw new FileSystemException("vfs.provider/close-outstr.error", file, e);
            }
        }

        /**
         * Called after this stream is closed.
         */
        @Override
        protected void onClose() throws IOException {
            try {
                super.onClose();
            } finally {
                try {
                    endOutput();
                } catch (final Exception e) {
                    throw new FileSystemException("vfs.provider/close-outstr.error", file, e);
                }
            }
        }
    }

    /**
     * Gets the FileContentInfo which describes the content-type, content-encoding
     *
     * @return The FileContentInfo.
     * @throws FileSystemException if an error occurs.
     */
    @Override
    public FileContentInfo getContentInfo() throws FileSystemException {
        if (fileContentInfo == null) {
            fileContentInfo = fileContentInfoFactory.create(this);
        }

        return fileContentInfo;
    }

    /**
     * Writes this content to another FileContent.
     *
     * @param fileContent The target FileContent.
     * @return the total number of bytes written
     * @throws IOException if an error occurs writing the content.
     * @since 2.1
     */
    @Override
    public long write(final FileContent fileContent) throws IOException {
        final OutputStream output = fileContent.getOutputStream();
        try {
            return this.write(output);
        } finally {
            output.close();
        }
    }

    /**
     * Writes this content to another FileObject.
     *
     * @param file The target FileObject.
     * @return the total number of bytes written
     * @throws IOException if an error occurs writing the content.
     * @since 2.1
     */
    @Override
    public long write(final FileObject file) throws IOException {
        return write(file.getContent());
    }

    /**
     * Writes this content to an OutputStream.
     *
     * @param output The target OutputStream.
     * @return the total number of bytes written
     * @throws IOException if an error occurs writing the content.
     * @since 2.1
     */
    @Override
    public long write(final OutputStream output) throws IOException {
        return write(output, WRITE_BUFFER_SIZE);
    }

    /**
     * Writes this content to an OutputStream.
     *
     * @param output The target OutputStream.
     * @param bufferSize The buffer size to write data chunks.
     * @return the total number of bytes written
     * @throws IOException if an error occurs writing the file.
     * @since 2.1
     */
    @Override
    public long write(final OutputStream output, final int bufferSize) throws IOException {
        final InputStream input = this.getInputStream();
        long count = 0;
        try {
            // This read/write code from Apache Commons IO
            final byte[] buffer = new byte[bufferSize];
            int n = 0;
            while (-1 != (n = input.read(buffer))) {
                output.write(buffer, 0, n);
                count += n;
            }
        } finally {
            input.close();
        }
        return count;
    }
}
