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

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Calendar;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.atomic.AtomicBoolean;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.commons.net.ftp.FTPFile;
import org.apache.commons.vfs2.FileName;
import org.apache.commons.vfs2.FileNotFolderException;
import org.apache.commons.vfs2.FileObject;
import org.apache.commons.vfs2.FileSystemException;
import org.apache.commons.vfs2.FileType;
import org.apache.commons.vfs2.RandomAccessContent;
import org.apache.commons.vfs2.provider.AbstractFileName;
import org.apache.commons.vfs2.provider.AbstractFileObject;
import org.apache.commons.vfs2.provider.UriParser;
import org.apache.commons.vfs2.util.FileObjectUtils;
import org.apache.commons.vfs2.util.Messages;
import org.apache.commons.vfs2.util.MonitorInputStream;
import org.apache.commons.vfs2.util.MonitorOutputStream;
import org.apache.commons.vfs2.util.RandomAccessMode;

/**
 * An FTP file.
 */
public class FtpFileObject extends AbstractFileObject<FtpFileSystem> {

    private static final long DEFAULT_TIMESTAMP = 0L;
    private static final Map<String, FTPFile> EMPTY_FTP_FILE_MAP = Collections
            .unmodifiableMap(new TreeMap<String, FTPFile>());
    private static final FTPFile UNKNOWN = new FTPFile();
    private static final Log log = LogFactory.getLog(FtpFileObject.class);

    private final String relPath;

    // Cached info
    private volatile FTPFile fileInfo;
    private volatile Map<String, FTPFile> children;
    private volatile FileObject linkDestination;
    private final AtomicBoolean inRefresh = new AtomicBoolean();

    protected FtpFileObject(final AbstractFileName name, final FtpFileSystem fileSystem, final FileName rootName)
            throws FileSystemException {
        super(name, fileSystem);
        final String relPath = UriParser.decode(rootName.getRelativeName(name));
        if (".".equals(relPath)) {
            // do not use the "." as path against the ftp-server
            // e.g. the uu.net ftp-server do a recursive listing then
            // this.relPath = UriParser.decode(rootName.getPath());
            // this.relPath = ".";
            this.relPath = null;
        } else {
            this.relPath = relPath;
        }
    }

    /**
     * Called by child file objects, to locate their ftp file info.
     *
     * @param name the file name in its native form ie. without URI stuff (%nn)
     * @param flush recreate children cache
     */
    private FTPFile getChildFile(final String name, final boolean flush) throws IOException {
        /*
         * If we should flush cached children, clear our children map unless we're in the middle of a refresh in which
         * case we've just recently refreshed our children. No need to do it again when our children are refresh()ed,
         * calling getChildFile() for themselves from within getInfo(). See getChildren().
         */
        if (flush && !inRefresh.get()) {
            children = null;
        }

        // List the children of this file
        doGetChildren();

        // Look for the requested child
        // VFS-210 adds the null check.
        return children != null ? children.get(name) : null;
    }

    /**
     * Fetches the children of this file, if not already cached.
     */
    private void doGetChildren() throws IOException {
        if (children != null) {
            return;
        }

        final FtpClient client = getAbstractFileSystem().getClient();
        try {
            final String path = fileInfo != null && fileInfo.isSymbolicLink()
                    ? getFileSystem().getFileSystemManager().resolveName(getParent().getName(), fileInfo.getLink())
                            .getPath()
                    : relPath;
            final FTPFile[] tmpChildren = client.listFiles(path);
            if (tmpChildren == null || tmpChildren.length == 0) {
                children = EMPTY_FTP_FILE_MAP;
            } else {
                children = new TreeMap<>();

                // Remove '.' and '..' elements
                for (int i = 0; i < tmpChildren.length; i++) {
                    final FTPFile child = tmpChildren[i];
                    if (child == null) {
                        if (log.isDebugEnabled()) {
                            log.debug(Messages.getString("vfs.provider.ftp/invalid-directory-entry.debug",
                                    Integer.valueOf(i), relPath));
                        }
                        continue;
                    }
                    if (!".".equals(child.getName()) && !"..".equals(child.getName())) {
                        children.put(child.getName(), child);
                    }
                }
            }
        } finally {
            getAbstractFileSystem().putClient(client);
        }
    }

    /**
     * Attaches this file object to its file resource.
     */
    @Override
    protected void doAttach() throws IOException {
        // Get the parent folder to find the info for this file
        // VFS-210 getInfo(false);
    }

    /**
     * Fetches the info for this file.
     */
    private void getInfo(final boolean flush) throws IOException {
        synchronized (getFileSystem()) {
            final FtpFileObject parent = (FtpFileObject) FileObjectUtils.getAbstractFileObject(getParent());
            FTPFile newFileInfo;
            if (parent != null) {
                newFileInfo = parent.getChildFile(UriParser.decode(getName().getBaseName()), flush);
            } else {
                // Assume the root is a directory and exists
                newFileInfo = new FTPFile();
                newFileInfo.setType(FTPFile.DIRECTORY_TYPE);
            }

            if (newFileInfo == null) {
                this.fileInfo = UNKNOWN;
            } else {
                this.fileInfo = newFileInfo;
            }
        }}

    /**
     * @throws FileSystemException if an error occurs.
     */
    @Override
    public void refresh() throws FileSystemException {
        if (inRefresh.compareAndSet(false, true)) {
            try {
                super.refresh();
                synchronized (getFileSystem()) {
                    this.fileInfo = null;
                }
                /*
                 * VFS-210 try { // this will tell the parent to recreate its children collection getInfo(true); } catch
                 * (IOException e) { throw new FileSystemException(e); }
                 */
            } finally {
                inRefresh.set(false);
            }
        }
    }

    /**
     * Detaches this file object from its file resource.
     */
    @Override
    protected void doDetach() {
        synchronized (getFileSystem()) {
            this.fileInfo = null;
            this.children = null;
        }
    }

    /**
     * Called when the children of this file change.
     */
    @Override
    protected void onChildrenChanged(final FileName child, final FileType newType) {
        if (children != null && newType.equals(FileType.IMAGINARY)) {
            try {
                children.remove(UriParser.decode(child.getBaseName()));
            } catch (final FileSystemException e) {
                throw new RuntimeException(e.getMessage());
            }
        } else {
            // if child was added we have to rescan the children
            // TODO - get rid of this
            children = null;
        }
    }

    /**
     * Called when the type or content of this file changes.
     */
    @Override
    protected void onChange() throws IOException {
        children = null;

        if (getType().equals(FileType.IMAGINARY)) {
            // file is deleted, avoid server lookup
            synchronized (getFileSystem()) {
                this.fileInfo = UNKNOWN;
            }
            return;
        }

        getInfo(true);
    }

    /**
     * Determines the type of the file, returns null if the file does not exist.
     */
    @Override
    protected FileType doGetType() throws Exception {
        // VFS-210
        synchronized (getFileSystem()) {
            if (this.fileInfo == null) {
                getInfo(false);
            }

            if (this.fileInfo == UNKNOWN) {
                return FileType.IMAGINARY;
            } else if (this.fileInfo.isDirectory()) {
                return FileType.FOLDER;
            } else if (this.fileInfo.isFile()) {
                return FileType.FILE;
            } else if (this.fileInfo.isSymbolicLink()) {
                final FileObject linkDest = getLinkDestination();
                // VFS-437: We need to check if the symbolic link links back to the symbolic link itself
                if (this.isCircular(linkDest)) {
                    // If the symbolic link links back to itself, treat it as an imaginary file to prevent following
                    // this link. If the user tries to access the link as a file or directory, the user will end up with
                    // a FileSystemException warning that the file cannot be accessed. This is to prevent the infinite
                    // call back to doGetType() to prevent the StackOverFlow
                    return FileType.IMAGINARY;
                }
                return linkDest.getType();

            }
        }
        throw new FileSystemException("vfs.provider.ftp/get-type.error", getName());
    }

    private FileObject getLinkDestination() throws FileSystemException {
        if (linkDestination == null) {
            final String path;
            synchronized (getFileSystem()) {
                path = this.fileInfo == null ? null : this.fileInfo.getLink();
            }
            final FileName parent = getName().getParent();
            final FileName relativeTo = parent == null ? getName() : parent;
            final FileName linkDestinationName = getFileSystem().getFileSystemManager().resolveName(relativeTo, path);
            linkDestination = getFileSystem().resolveFile(linkDestinationName);
        }
        return linkDestination;
    }

    @Override
    protected FileObject[] doListChildrenResolved() throws Exception {
        synchronized (getFileSystem()) {
            if (this.fileInfo != null && this.fileInfo.isSymbolicLink()) {
                final FileObject linkDest = getLinkDestination();
                // VFS-437: Try to avoid a recursion loop.
                if (this.isCircular(linkDest)) {
                    return null;
                }
                return linkDest.getChildren();
            }
        }
        return null;
    }

    /**
     * Returns the file's list of children.
     *
     * @return The list of children
     * @throws FileSystemException If there was a problem listing children
     * @see AbstractFileObject#getChildren()
     * @since 2.0
     */
    @Override
    public FileObject[] getChildren() throws FileSystemException {
        try {
            if (doGetType() != FileType.FOLDER) {
                throw new FileNotFolderException(getName());
            }
        } catch (final Exception ex) {
            throw new FileNotFolderException(getName(), ex);
        }

        try {
            /*
             * Wrap our parent implementation, noting that we're refreshing so that we don't refresh() ourselves and
             * each of our parents for each children. Note that refresh() will list children. Meaning, if if this file
             * has C children, P parents, there will be (C * P) listings made with (C * (P + 1)) refreshes, when there
             * should really only be 1 listing and C refreshes.
             */
            this.inRefresh.set(true);
            return super.getChildren();
        } finally {
            this.inRefresh.set(false);
        }
    }

    /**
     * Lists the children of the file.
     */
    @Override
    protected String[] doListChildren() throws Exception {
        // List the children of this file
        doGetChildren();

        // VFS-210
        if (children == null) {
            return null;
        }

        // TODO - get rid of this children stuff
        final String[] childNames = new String[children.size()];
        int childNum = -1;
        final Iterator<FTPFile> iterChildren = children.values().iterator();
        while (iterChildren.hasNext()) {
            childNum++;
            final FTPFile child = iterChildren.next();
            childNames[childNum] = child.getName();
        }

        return UriParser.encode(childNames);
    }

    /**
     * Deletes the file.
     */
    @Override
    protected void doDelete() throws Exception {
        synchronized (getFileSystem()) {
            if (this.fileInfo != null) {
                final boolean ok;
                final FtpClient ftpClient = getAbstractFileSystem().getClient();
                try {
                    if (this.fileInfo.isDirectory()) {
                        ok = ftpClient.removeDirectory(relPath);
                    } else {
                        ok = ftpClient.deleteFile(relPath);
                    }
                } finally {
                    getAbstractFileSystem().putClient(ftpClient);
                }

                if (!ok) {
                    throw new FileSystemException("vfs.provider.ftp/delete-file.error", getName());
                }
                this.fileInfo = null;
            }
            this.children = EMPTY_FTP_FILE_MAP;
        }
    }

    /**
     * Renames the file
     */
    @Override
    protected void doRename(final FileObject newFile) throws Exception {
        synchronized (getFileSystem()) {
            final boolean ok;
            final FtpClient ftpClient = getAbstractFileSystem().getClient();
            try {
                final String oldName = relPath;
                final String newName = ((FtpFileObject) FileObjectUtils.getAbstractFileObject(newFile)).getRelPath();
                ok = ftpClient.rename(oldName, newName);
            } finally {
                getAbstractFileSystem().putClient(ftpClient);
            }

            if (!ok) {
                throw new FileSystemException("vfs.provider.ftp/rename-file.error", getName().toString(), newFile);
            }
            this.fileInfo = null;
            this.children = EMPTY_FTP_FILE_MAP;
        }
    }

    /**
     * Creates this file as a folder.
     */
    @Override
    protected void doCreateFolder() throws Exception {
        final boolean ok;
        final FtpClient client = getAbstractFileSystem().getClient();
        try {
            ok = client.makeDirectory(relPath);
        } finally {
            getAbstractFileSystem().putClient(client);
        }

        if (!ok) {
            throw new FileSystemException("vfs.provider.ftp/create-folder.error", getName());
        }
    }

    /**
     * Returns the size of the file content (in bytes).
     */
    @Override
    protected long doGetContentSize() throws Exception {
        synchronized (getFileSystem()) {
            if (this.fileInfo == null) {
                return 0;
            }
            if (this.fileInfo.isSymbolicLink()) {
                final FileObject linkDest = getLinkDestination();
                // VFS-437: Try to avoid a recursion loop.
                if (this.isCircular(linkDest)) {
                    return this.fileInfo.getSize();
                }
                return linkDest.getContent().getSize();
            }
            return this.fileInfo.getSize();
        }
    }

    /**
     * get the last modified time on an ftp file
     *
     * @see org.apache.commons.vfs2.provider.AbstractFileObject#doGetLastModifiedTime()
     */
    @Override
    protected long doGetLastModifiedTime() throws Exception {
        synchronized (getFileSystem()) {
            if (this.fileInfo == null) {
                return DEFAULT_TIMESTAMP;
            }
            if (this.fileInfo.isSymbolicLink()) {
                final FileObject linkDest = getLinkDestination();
                // VFS-437: Try to avoid a recursion loop.
                if (this.isCircular(linkDest)) {
                    return getTimestamp();
                }
                return linkDest.getContent().getLastModifiedTime();
            }
            return getTimestamp();
        }
    }

    /**
     * Creates an input stream to read the file content from.
     */
    @Override
    protected InputStream doGetInputStream(final int bufferSize) throws Exception {
        final FtpClient client = getAbstractFileSystem().getClient();
        try {
            final InputStream inputStream = client.retrieveFileStream(relPath, 0);
            // VFS-210
            if (inputStream == null) {
                throw new FileNotFoundException(getName().toString());
            }
            return new FtpInputStream(client, inputStream, bufferSize);
        } catch (final Exception e) {
            getAbstractFileSystem().putClient(client);
            throw e;
        }
    }

    @Override
    protected RandomAccessContent doGetRandomAccessContent(final RandomAccessMode mode) throws Exception {
        return new FtpRandomAccessContent(this, mode);
    }

    /**
     * Creates an output stream to write the file content to.
     */
    @Override
    protected OutputStream doGetOutputStream(final boolean bAppend) throws Exception {
        final FtpClient client = getAbstractFileSystem().getClient();
        try {
            OutputStream out = null;
            if (bAppend) {
                out = client.appendFileStream(relPath);
            } else {
                out = client.storeFileStream(relPath);
            }

            FileSystemException.requireNonNull(out, "vfs.provider.ftp/output-error.debug", this.getName(),
                    client.getReplyString());

            return new FtpOutputStream(client, out);
        } catch (final Exception e) {
            getAbstractFileSystem().putClient(client);
            throw e;
        }
    }

    String getRelPath() {
        return relPath;
    }

    private long getTimestamp() {
        final Calendar timestamp = this.fileInfo != null ? this.fileInfo.getTimestamp() : null;
        return timestamp == null ? DEFAULT_TIMESTAMP : timestamp.getTime().getTime();
    }

    /**
     * This is an over simplistic implementation for VFS-437.
     */
    private boolean isCircular(final FileObject linkDest) throws FileSystemException {
        return linkDest.getName().getPathDecoded().equals(this.getName().getPathDecoded());
    }

    FtpInputStream getInputStream(final long filePointer) throws IOException {
        final FtpClient client = getAbstractFileSystem().getClient();
        try {
            final InputStream instr = client.retrieveFileStream(relPath, filePointer);
            FileSystemException.requireNonNull(instr, "vfs.provider.ftp/input-error.debug", this.getName(),
                    client.getReplyString());
            return new FtpInputStream(client, instr);
        } catch (final IOException e) {
            getAbstractFileSystem().putClient(client);
            throw e;
        }
    }

    /**
     * An InputStream that monitors for end-of-file.
     */
    class FtpInputStream extends MonitorInputStream {
        private final FtpClient client;

        public FtpInputStream(final FtpClient client, final InputStream in) {
            super(in);
            this.client = client;
        }

        public FtpInputStream(final FtpClient client, final InputStream in, final int bufferSize) {
            super(in, bufferSize);
            this.client = client;
        }

        void abort() throws IOException {
            client.abort();
            close();
        }

        /**
         * Called after the stream has been closed.
         */
        @Override
        protected void onClose() throws IOException {
            final boolean ok;
            try {
                ok = client.completePendingCommand() || isTransferAbortedOkReplyCode();
            } finally {
                getAbstractFileSystem().putClient(client);
            }

            if (!ok) {
                throw new FileSystemException("vfs.provider.ftp/finish-get.error", getName());
            }
        }

        private boolean isTransferAbortedOkReplyCode() throws IOException {
            final List<Integer> transferAbortedOkReplyCodes = FtpFileSystemConfigBuilder
                .getInstance()
                .getTransferAbortedOkReplyCodes(getAbstractFileSystem().getFileSystemOptions());
            return transferAbortedOkReplyCodes != null && transferAbortedOkReplyCodes.contains(client.getReplyCode());
        }
    }

    /**
     * An OutputStream that monitors for end-of-file.
     */
    private class FtpOutputStream extends MonitorOutputStream {
        private final FtpClient client;

        public FtpOutputStream(final FtpClient client, final OutputStream outstr) {
            super(outstr);
            this.client = client;
        }

        /**
         * Called after this stream is closed.
         */
        @Override
        protected void onClose() throws IOException {
            final boolean ok;
            try {
                ok = client.completePendingCommand();
            } finally {
                getAbstractFileSystem().putClient(client);
            }

            if (!ok) {
                throw new FileSystemException("vfs.provider.ftp/finish-put.error", getName());
            }
        }
    }
}
