/*
 * @author Ranides Atterwim {@literal <ranides@gmail.com>}
 * @copyright Ranides Atterwim
 * @license WTFPL
 * @url http://ranides.net/projects/assira
 */
package net.ranides.assira.io.uri.impl;

import net.ranides.assira.generic.Ref;
import org.apache.commons.net.ftp.FTPClient;
import org.apache.commons.net.ftp.FTPClientConfig;
import org.apache.commons.net.ftp.FTPFile;
import org.apache.commons.net.ftp.FTPFileFilters;

import net.ranides.assira.collection.prototype.GenericMap;
import net.ranides.assira.collection.query.CQuery;
import net.ranides.assira.credentials.UserProperty;
import net.ranides.assira.credentials.UserStore;
import net.ranides.assira.generic.HashUtils;
import net.ranides.assira.io.IOStreams;
import net.ranides.assira.io.uri.URIFlags;
import net.ranides.assira.io.uri.URIHandle;
import net.ranides.assira.io.uri.URIHandler;
import net.ranides.assira.io.uri.URIResolver;
import net.ranides.assira.io.uri.URITime;
import net.ranides.assira.io.uri.URIUtils;
import net.ranides.assira.text.StringUtils;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.time.Instant;
import java.util.Arrays;
import java.util.Collection;
import java.util.Set;

/**
 * Supported schemes: ftp
 *
 * Please note:
 * As every URIHandler, it should not be constructed directly, but connected with some instance of URIResolver.
 *
 * Supported query parameters:
 *      charset = UTF-8
 *      timeout = 1000
 *
 * @author Ranides Atterwim {@literal <ranides@gmail.com>}
 */
public class CFTPHandler implements URIHandler {

    private static final int CONNECT_TIMEOUT = 1000;
    
    private final URIResolver resolver;
    
    private final FTPManager man = new FTPManager();


    /**
     * Default constructor
     */
    public CFTPHandler() {
        this.resolver = URIResolver.DEFAULT;
    }

    /**
     * Constructor used by ServiceLoader inside static initialization of {@link URIResolver}
     * @param resolver owner
     */
    public CFTPHandler(URIResolver resolver) {
        this.resolver = resolver;
    }
    
    @Override
    public URIHandle resolve(URI uri) {
        return new CFTPHandle(uri);
    }
    
    @Override
    public Collection<String> schemes() {
        return Arrays.asList("ftp");
    }
    
    private class CFTPHandle extends CHandle {
        
        private final URI uri;
        private final String path;
        private final Charset charset;
        private final boolean text;
        private final FTPHostName hostname;
        
        public CFTPHandle(URI uri) {
            this(uri, uri.getPath(), URIUtils.getParam(uri, "charset"));
        }
        
        public CFTPHandle(URI uri, String path, Ref<String> charset) {
            this.uri = uri;
            this.path = StringUtils.rtrim(path,'/').replace('\\', '/');
            this.charset = Charset.forName(charset.orElse("UTF-8"));
            this.text = charset.isPresent();
            this.hostname = new FTPHostName(uri, resolver.users());
            man.open(hostname);
        }

        @Override
        public void close() throws IOException {
            man.close(hostname);
            super.close();
        }

        @Override
        public URIResolver resolver() {
            return resolver;
        }

        @Override
        public URI uri() {
            return uri;
        }

        @Override
        public void move(URIHandle target) throws IOException {
            if(isSameHost(target)) {
                man.shared(hostname).rename(path, target.uri().getPath());
            } else {
                super.move(target);
            }
        }

        @Override
        public void create() throws IOException {
            man.shared(hostname).storeFile(path, IOStreams.EMPTY);
        }

        @Override
        public void delete() throws IOException {
            man.shared(hostname).deleteFile(path);
        }

        @Override
        public OutputStream ostream() throws IOException {
            FTPClient fc = man.fork(hostname);
            return IOStreams.onClose(fc.storeFileStream(path), () -> {
                man.close(hostname, fc);
            });
        }

        @Override
        public InputStream istream(long begin, long end) throws IOException {
            FTPClient fc = man.fork(hostname);
            fc.setRestartOffset(begin);
            return IOStreams.onClose(IOStreams.limit(fc.retrieveFileStream(path), 0, end), () -> {
                man.close(hostname, fc);
            });
        }

        @Override
        public InputStream istream() throws IOException {
            return istream(0);
        }

        @Override
        public CQuery<URIHandle> scan() throws IOException {
            FTPFile[] files = man.shared(hostname).listFiles(path, FTPFileFilters.NON_NULL);
            return CQuery.from().array(files)
                .filter(e -> !".".equals(e.getName()) && !"..".equals(e.getName()))
                .map(f -> new CFTPHandle(ireplace(uri, path + "/" + f.getName())));
        }
        
        
        @Override
        public URIHandle parent() {
            return new CFTPHandle(iparent(uri));
        }

        @Override
        public long size() throws IOException {
            FTPFile info = stat(path);
            return info == null ? -1 : info.isDirectory() ? info.getSize() - 2 : info.getSize();
        }

        @Override
        public Instant time(URITime ut) throws IOException {
            if(URITime.MODIFIED.equals(ut)) {
                FTPFile info = stat(path);
                return info == null ? Instant.MIN : info.getTimestamp().toInstant();
            }
            return super.time(ut);
        }

        @Override
        public boolean exists() throws IOException {
            return null != stat(path);
        }

        @Override
        public Set<URIFlags> flags() throws IOException {
            FTPFile stat = stat(path);
            Set<URIFlags> re = URIFlags.collect();
            if(null == stat) {
                return re;
            }
            
            re.add(URIFlags.EXISTS);
            re.add(URIFlags.SIZE);

            if(stat.isDirectory()) {
                re.add(URIFlags.DIR);
            }
            if(stat.isFile()) {
                re.add(URIFlags.FILE);
                re.add(URIFlags.SIZE);

                if(hasPermission(stat, FTPFile.READ_PERMISSION)) {
                    re.add(URIFlags.READABLE);
                    re.add(URIFlags.SEEK);
                }
                if(hasPermission(stat, FTPFile.WRITE_PERMISSION)) {
                    re.add(URIFlags.WRITABLE);
                    re.add(URIFlags.SEEK);
                }
                if(hasPermission(stat, FTPFile.EXECUTE_PERMISSION)) {
                    re.add(URIFlags.EXECUTE);
                }

                if(text) {
                    re.add(URIFlags.TEXT);
                } else {
                    re.add(URIFlags.BINARY);
                }
            }

            return re;
        }
        
        @Override
        public Charset charset() {
            return charset;
        }

        private FTPFile stat(String path) throws IOException {
            FTPFile ret = man.shared(hostname).mlistFile(path);
            if(null == ret || !ret.isValid()) {
                return null;
            }
            if(ret.isSymbolicLink()) {
                return stat(ret.getLink());
            }
            return ret;
        }
        
        private boolean hasPermission(FTPFile file, int perm) {
            return
                file.hasPermission(FTPFile.WORLD_ACCESS, perm) ||
                file.hasPermission(FTPFile.GROUP_ACCESS, perm) ||
                file.hasPermission(FTPFile.USER_ACCESS, perm);
        }

        private boolean isSameHost(URIHandle target) {
            return (target instanceof CFTPHandle) && ((CFTPHandle)target).hostname.equals(hostname);
        }

    }

    private static URI iparent(URI uri) {
        String path = StringUtils.rtrim(uri.getPath(),'/').replace('\\', '/');

        int last = path.lastIndexOf("/");
        if(last < 0) {
            return uri;
        }
        return ireplace(uri, path.substring(0, last));
    }

    private static URI ireplace(URI uri, String path) {
        try {
            return new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), uri.getPort(), path, null, null);
        } catch (URISyntaxException cause) {
            throw new AssertionError("Invalid URI: " + path, cause);
        }
    }
    
    private static final class FTPHostName {

        private final int hash;
        private final String host;
        private final Ref<String> user;
        private final Ref<String> pass;
        private final Ref<Integer> port;
        private final int timeout;

        public FTPHostName(URI uri, UserStore users) {
            host = uri.getHost();
            Ref<GenericMap<String>> uis = users.find(uri).last();
            if(uis.isPresent()) {
                GenericMap<String> ui = uis.get();
                user = ui.getOptional(UserProperty.LOGIN);
                pass = ui.getOptional(UserProperty.PASSWORD);
            } else {
                user = Ref.empty();
                pass = Ref.empty();
            }
            port = URIUtils.getPort(uri);
            timeout = URIUtils.getParam(uri, "timeout").map(Integer::valueOf).orElse(CONNECT_TIMEOUT);
            hash = HashUtils.hashValues(5, 23, host, user, pass, port);
        }

        @Override
        public boolean equals(Object object) {
            if(null == object || !FTPHostName.class.equals(object.getClass())) {
                return false;
            }
            FTPHostName that = (FTPHostName)object;
            return 
                host.equals(that.host) &&
                user.equals(that.user) &&
                pass.equals(that.pass) &&
                port.equals(that.port);
        }

        @Override
        public int hashCode() {
            return hash;
        }
        
        public FTPClient connect() throws IOException {
            FTPClient ftp = new FTPClient();
            FTPClientConfig config = new FTPClientConfig();
            ftp.configure(config);

            if(port.isPresent()) {
                ftp.setConnectTimeout(timeout);
                ftp.connect(host, port.get());
            } else {
                ftp.setConnectTimeout(timeout);
                ftp.connect(host);
            }
            ftp.enterLocalPassiveMode();

            if(user.isPresent()) {
                if(!ftp.login(user.orElse(null),pass.orElse(null))) {
                    ftp.disconnect();
                    throw new IOException("FTP E" + ftp.getReplyCode() +": " + ftp.getReplyString());
                }
            }

            return ftp;
        }
        
    }
    
    private static final class FTPManager extends CHandleManager<FTPHostName, FTPClient> {
        
        @Override
        protected void disconnect(FTPClient host) throws IOException {
            host.disconnect();
        }

        @Override
        protected FTPClient reset(FTPClient host) throws IOException {
            host.completePendingCommand();
            return host;
        }

        @Override
        protected FTPClient connect(FTPHostName hostname) throws IOException {
            return hostname.connect();
        }
        
    }
    
}
