package com.jscape.filetransfer;

import com.jscape.util.ByteFormat;
import com.jscape.util.FileTools;
import com.jscape.util.log.DebugStreamLogger;

import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.text.MessageFormat;
import java.util.*;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.ExecutorService;
import java.util.logging.Level;
import java.util.logging.Logger;

import static com.jscape.filetransfer.FileTransferException.wrap;
import static com.jscape.util.Parameters.assertNotNull;
import static com.jscape.util.Parameters.assertTrue;

/**
 * @author Alex Usun
 */
public abstract class AbstractFileTransfer
        implements FileTransfer {

    protected static final File DEFAULT_LOCAL_DIRECTORY = new File(".");

    protected static final String FILE_LISTING_TEMPLATE = "<{0,choice,0#DIR|1#FILE}> {1} {2,choice,0#readable|1#non-readable} {3,choice,0#writable|1#non-writable} {4} {5,date,MM-dd-yyyy HH:mm:ss}\r\n";

    protected String proxyType;
    protected String proxyHost;
    protected int proxyPort;
    protected String proxyUsername;
    protected String proxyPassword;
    protected String hostname;
    protected int port;
    protected long timeout;
    protected String username;
    protected String password;
    protected int transferBufferSizeBytes;
    protected boolean passiveTransferModeRequired;
    protected int transferMode;
    protected String wireEncoding;
    protected boolean uploadedFileTimestampPreservingRequired;
    protected boolean downloadedFileTimestampPreservingRequired;
    protected boolean checksumVerificationRequired;
    protected TimeZone timeZone;
    protected Path currentLocalDirectory;
    protected final Logger logger;
    protected final Set<FileTransferListener> listeners;
    protected ExecutorService executorService;
    protected boolean connected;
    protected volatile boolean interrupted;

    protected AbstractFileTransfer(String proxyType,
                                   String proxyHost,
                                   int proxyPort,
                                   String proxyUsername,
                                   String proxyPassword,
                                   String hostname,
                                   int port,
                                   long timeout,
                                   String username,
                                   String password,
                                   int transferBufferSizeBytes,
                                   boolean passiveTransferModeRequired,
                                   int transferMode,
                                   String wireEncoding,
                                   boolean uploadedFileTimestampPreservingRequired,
                                   boolean downloadedFileTimestampPreservingRequired,
                                   boolean checksumVerificationRequired,
                                   TimeZone timeZone,
                                   File currentLocalDirectory,
                                   Logger logger,
                                   Set<FileTransferListener> listeners) {
        this.proxyType = proxyType;
        this.proxyHost = proxyHost;
        this.proxyPort = proxyPort;
        this.proxyUsername = proxyUsername;
        this.proxyPassword = proxyPassword;
        this.hostname = hostname;
        this.port = port;
        this.timeout = timeout;
        this.username = username;
        this.password = password;
        this.transferBufferSizeBytes = transferBufferSizeBytes;
        this.passiveTransferModeRequired = passiveTransferModeRequired;
        this.transferMode = transferMode;
        this.wireEncoding = wireEncoding;
        this.uploadedFileTimestampPreservingRequired = uploadedFileTimestampPreservingRequired;
        this.downloadedFileTimestampPreservingRequired = downloadedFileTimestampPreservingRequired;
        this.checksumVerificationRequired = checksumVerificationRequired;

        assertNotNull(timeZone);
        this.timeZone = timeZone;

        assertTrue(currentLocalDirectory.isDirectory(), "Invalid local directory.");
        this.currentLocalDirectory = currentLocalDirectory.toPath().normalize().toAbsolutePath();

        assertNotNull(logger);
        this.logger = logger;

        assertNotNull(listeners);
        this.listeners = new CopyOnWriteArraySet<>(listeners);
    }

    @Override
    public FileTransfer setProxyType(String value) {
        this.proxyType = value;
        return this;
    }

    @Override
    public FileTransfer setProxyHost(String host, int port) {
        this.proxyHost = host;
        this.proxyPort = port;
        return this;
    }

    @Override
    public FileTransfer setProxyAuthentication(String username, String password) {
        this.proxyUsername = username;
        this.proxyPassword = password;
        return this;
    }

    @Override
    public void clearProxySettings() {
        this.proxyType = null;
        this.proxyHost = null;
        this.proxyPort = 0;
        this.proxyUsername = null;
        this.proxyPassword = null;
    }

    @Override
    public String getHostname() {
        return this.hostname;
    }

    @Override
    public FileTransfer setHostname(String value) {
        assertNotNull(value);
        this.hostname = value;
        return this;
    }

    @Override
    public int getPort() {
        return this.port;
    }

    @Override
    public FileTransfer setPort(int value) {
        this.port = value;
        return this;
    }

    @Override
    public long getTimeout() {
        return this.timeout;
    }

    @Override
    public FileTransfer setTimeout(long value) {
        this.timeout = value;
        return this;
    }

    @Override
    public String getUsername() {
        return this.username;
    }

    @Override
    public FileTransfer setUsername(String value) {
        this.username = value;
        return this;
    }

    @Override
    public String getPassword() {
        return this.password;
    }

    @Override
    public FileTransfer setPassword(String value) {
        this.password = value;
        return this;
    }

    @Override
    public int getBlockTransferSize() {
        return this.transferBufferSizeBytes;
    }

    @Override
    public FileTransfer setBlockTransferSize(int value) {
        this.transferBufferSizeBytes = value;
        return this;
    }

    @Override
    public boolean getPassive() {
        return this.passiveTransferModeRequired;
    }

    @Override
    public FileTransfer setPassive(boolean value) {
        this.passiveTransferModeRequired = value;
        return this;
    }

    @Override
    public int getMode() {
        return this.transferMode;
    }

    @Override
    public FileTransfer setAscii() {
        this.transferMode = ASCII;
        return this;
    }

    @Override
    public FileTransfer setBinary() {
        this.transferMode = BINARY;
        return this;
    }

    @Override
    public FileTransfer setAuto(boolean value) {
        this.transferMode = value ? AUTO : BINARY;
        return this;
    }

    @Override
    public String getWireEncoding() {
        return this.wireEncoding;
    }

    @Override
    public FileTransfer setWireEncoding(String value) {
        this.wireEncoding = value;
        return this;
    }

    @Override
    public FileTransfer setPreserveFileUploadTimestamp(boolean value) {
        this.uploadedFileTimestampPreservingRequired = value;
        return this;
    }

    @Override
    public FileTransfer setPreserveFileDownloadTimestamp(boolean value) {
        this.downloadedFileTimestampPreservingRequired = value;
        return this;
    }

    @Override
    public boolean isChecksumVerificationRequired() {
        return this.checksumVerificationRequired;
    }

    @Override
    public FileTransfer setChecksumVerificationRequired(boolean value) {
        this.checksumVerificationRequired = value;
        return this;
    }

    @Override
    public FileTransfer setTimeZone(TimeZone value) {
        this.timeZone = value;
        return this;
    }

    @Override
    public boolean getDebug() {
        return this.logger.getLevel() == Level.FINEST;
    }

    @Override
    public FileTransfer setDebug(boolean value) {
        this.logger.setLevel(value ? Level.FINEST : Level.OFF);
        return this;
    }

    @Override
    public PrintStream getDebugStream() {
        return this.logger instanceof DebugStreamLogger ? (PrintStream) ((DebugStreamLogger) this.logger).stream() : null;
    }

    @Override
    public FileTransfer setDebugStream(PrintStream value) {
        if (this.logger instanceof DebugStreamLogger) {
            DebugStreamLogger debugStreamLogger = (DebugStreamLogger) this.logger;
            debugStreamLogger.stream(value);
        }

        return this;
    }

    @Override
    public void addFileTransferListener(FileTransferListener listener) {
        assertNotNull(listener);
        this.listeners.add(listener);
    }

    @Override
    public void removeFileTransferListener(FileTransferListener listener) {
        this.listeners.remove(listener);
    }

    @Override
    public File getLocalDir() {
        return this.currentLocalDirectory.toFile();
    }

    @Override
    public FileTransfer setLocalDir(File value) {
        assertTrue(value.isDirectory(), "Invalid local directory.");
        this.currentLocalDirectory = value.toPath().normalize().toAbsolutePath();
        return this;
    }

    @SuppressWarnings("unchecked")
    public Enumeration getLocalDirListing(final String regex) {
        File directory = getLocalDir();
        File[] files = directory.listFiles(new FileFilter() {
            @Override
            public boolean accept(File pathname) {
                return pathname.getName().matches(regex);
            }
        });

        return files != null ? Collections.enumeration(Arrays.asList(files)) : new Vector<File>().elements();
    }

    @Override
    public Enumeration getLocalDirListing() {
        return getLocalDirListing(".+");
    }

    @Override
    public File makeLocalDir(String localDirectory)
            throws FileTransferException {
        File path = resolveLocalPathFor(localDirectory);

        try {
            return FileTools.createDirectory(path);
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public void close() {
        try {
            disconnect();
        } catch (Exception e) {
            // ignore
        }
    }

    @Override
    public String getDirListingAsString(String regex)
            throws FileTransferException {
        Enumeration entries = getDirListing(regex);
        StringBuilder buffer = new StringBuilder();

        while (entries.hasMoreElements()) {
            FileTransferRemoteFile entry = (FileTransferRemoteFile) entries.nextElement();
            String data = formatRemoteFile(entry);
            buffer.append(data);
        }

        return buffer.toString();
    }

    @Override
    public Enumeration<FileTransferRemoteFile> getDirListing()
            throws FileTransferException {
        return getDirListing(".+");
    }

    @Override
    public String getDirListingAsString()
            throws FileTransferException {
        return getDirListingAsString(".+");
    }

    @Override
    public Enumeration getNameListing(String regex)
            throws FileTransferException {
        Enumeration entries = getDirListing(regex);
        Vector<String> ret = new Vector<>();

        while (entries.hasMoreElements()) {
            FileTransferRemoteFile entry = (FileTransferRemoteFile) entries.nextElement();
            ret.add(entry.getFilename());
        }

        return ret.elements();
    }

    @Override
    public Enumeration getNameListing()
            throws FileTransferException {
        return getNameListing(".+");
    }

    @Override
    public Vector<FileTransferRemoteFile> getRemoteFileList(String remoteDirectory)
            throws FileTransferException {
        return new Vector<>(new RecursiveFileListOperation(remoteDirectory).applyTo(this).files());
    }

    @Override
    public long getRecursiveDirectorySize(String remoteDirectory)
            throws FileTransferException {
        Enumeration entries = getRemoteFileList(remoteDirectory).elements();
        long size = 0L;

        while (entries.hasMoreElements()) {
            FileTransferRemoteFile entry = (FileTransferRemoteFile) entries.nextElement();
            size += entry.getFilesize();
        }

        return size;
    }

    @Override
    public int getRecursiveDirectoryFileCount(String remoteDirectory)
            throws FileTransferException {
        Enumeration entries = getRemoteFileList(remoteDirectory).elements();
        int count = 0;

        while (entries.hasMoreElements()) {
            FileTransferRemoteFile entry = (FileTransferRemoteFile) entries.nextElement();
            if (!entry.isDirectory()) {
                ++count;
            }
        }

        return count;
    }

    @Override
    public FileTransfer setFileModificationTime(String remoteFile, Date modificationTime)
            throws FileTransferException {
        setFileTimestamp(remoteFile, modificationTime);
        return this;
    }

    @Override
    public void makeDir(String remoteDirectory)
            throws FileTransferException {
        makeDirRecursive(remoteDirectory);
    }

    @Override
    public void deleteDir(String remoteDirectory)
            throws FileTransferException {
        deleteDir(remoteDirectory, false);
    }

    @Override
    public void mdelete(String regex)
            throws FileTransferException {
        Enumeration entries = getDirListing(regex);

        while (entries.hasMoreElements()) {
            FileTransferRemoteFile entry = (FileTransferRemoteFile) entries.nextElement();

            if (entry.isDirectory()) {
                deleteDir(entry.getFilename(), true);
            } else {
                deleteFile(entry.getFilename());
            }
        }
    }

    @Override
    public void download(OutputStream out, String remoteFile)
            throws FileTransferException {
        resumeDownload(out, remoteFile, 0);
    }

    @Override
    public File download(String remoteFile)
            throws FileTransferException {
        return download(remoteFile, remoteFile);
    }

    @Override
    public void resumeDownload(String remoteFile, long remoteFileOffset)
            throws FileTransferException {
        resumeDownload(remoteFile, remoteFile, remoteFileOffset);
    }

    @Override
    public void mdownload(Enumeration remoteFiles)
            throws FileTransferException {
        while (remoteFiles.hasMoreElements() && !this.interrupted) {
            String remoteFile = (String) remoteFiles.nextElement();
            download(remoteFile);
        }
    }

    @Override
    public void mdownload(String regex)
            throws FileTransferException {
        Enumeration entries = getDirListing(regex);

        while (entries.hasMoreElements() && !this.interrupted) {
            FileTransferRemoteFile entry = (FileTransferRemoteFile) entries.nextElement();

            if (!entry.isDirectory()) {
                download(entry.getFilename());
            }
        }
    }

    @Override
    public void downloadDir(String remoteDirectory, int retryAttempts, boolean checksumVerificationRequired)
            throws FileTransferException {
        downloadDir(remoteDirectory, retryAttempts, checksumVerificationRequired, 0);
    }

    @Override
    public void downloadDir(String remoteDirectory)
            throws FileTransferException {
        downloadDir(remoteDirectory, 0, false);
    }

    @Override
    public void upload(InputStream in, long length, String remoteFile, boolean shouldAppend)
            throws FileTransferException {
        resumeUpload(in, length, remoteFile, shouldAppend ? safeSizeOf(remoteFile) : 0);
    }

    @Override
    public void upload(byte[] data, String remoteFile, boolean shouldAppend)
            throws FileTransferException {
        upload(new ByteArrayInputStream(data), data.length, remoteFile, shouldAppend);
    }

    @Override
    public void upload(byte[] data, String remoteFile)
            throws FileTransferException {
        upload(data, remoteFile, false);
    }

    @Override
    public void upload(File localFile, String remoteFile)
            throws FileTransferException {
        upload(localFile, remoteFile, false);
    }

    @Override
    public void upload(File localFile, boolean shouldAppend)
            throws FileTransferException {
        upload(localFile, localFile.getName(), shouldAppend);
    }

    @Override
    public void upload(String tempFileExtension, File localFile)
            throws FileTransferException {
        String remoteFile = localFile.getName() + tempFileExtension;
        upload(localFile, remoteFile);
        renameFile(remoteFile, localFile.getName());
    }

    @Override
    public void upload(File localFile)
            throws FileTransferException {
        upload(localFile, false);
    }

    @Override
    public void upload(String localFile, String remoteFile, boolean shouldAppend)
            throws FileTransferException {
        File source = resolveLocalPathFor(localFile);
        upload(source, remoteFile, shouldAppend);
    }

    @Override
    public void upload(String localFile, String remoteFile)
            throws FileTransferException {
        upload(localFile, remoteFile, false);
    }

    @Override
    public void upload(String localFile, boolean shouldAppend)
            throws FileTransferException {
        File source = resolveLocalPathFor(localFile);
        upload(source, source.getName(), shouldAppend);
    }

    @Override
    public void upload(String localFile)
            throws FileTransferException {
        upload(localFile, false);
    }

    @Override
    public void resumeUpload(File localFile, long localFileOffset)
            throws FileTransferException {
        resumeUpload(localFile, localFile.getName(), localFileOffset);
    }

    @Override
    public void resumeUpload(String localFile, long localFileOffset)
            throws FileTransferException {
        File source = resolveLocalPathFor(localFile);
        resumeUpload(source, localFileOffset);
    }

    @Override
    public String uploadUnique(File localFile)
            throws FileTransferException {
        try (InputStream in = Files.newInputStream(localFile.toPath())) {
            return uploadUnique(in, localFile.getName());
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public String uploadUnique(String localFile)
            throws FileTransferException {
        File source = resolveLocalPathFor(localFile);
        return uploadUnique(source);
    }

    @Override
    public void mupload(Enumeration localFiles)
            throws FileTransferException {
        while (localFiles.hasMoreElements() && !this.interrupted) {
            String filename = (String) localFiles.nextElement();
            upload(filename);
        }
    }

    @Override
    public void mupload(String regex)
            throws FileTransferException {
        Enumeration entries = getLocalDirListing(regex);

        while (entries.hasMoreElements() && !this.interrupted) {
            File entry = (File) entries.nextElement();

            if (!entry.isDirectory()) {
                upload(entry);
            }
        }
    }

    @Override
    public void uploadDir(File localDirectory, int retryAttempts, boolean checksumVerificationRequired, String tempFileExtension)
            throws FileTransferException {
        uploadDir(localDirectory, retryAttempts, checksumVerificationRequired, tempFileExtension, 0);
    }

    @Override
    public void uploadDir(File localDirectory, String tempFileExtension)
            throws FileTransferException {
        uploadDir(localDirectory, 0, false, tempFileExtension);
    }

    @Override
    public void uploadDir(File localDirectory)
            throws FileTransferException {
        uploadDir(localDirectory, "");
    }

    @Override
    public InputStream getInputStream(String remoteFile, long remoteFileOffset)
            throws FileTransferException {
        try {
            FileTransferPipedInputStream in = FileTransferPipedInputStream.streamFor(this, remoteFile, remoteFileOffset);
            this.executorService.submit(in);

            return in;
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public OutputStream getOutputStream(String remoteFile, long remoteFileOffset)
            throws FileTransferException {
        try {
            FileTransferPipedOutputStream out = FileTransferPipedOutputStream.streamFor(this, remoteFile, remoteFileOffset, 0);
            this.executorService.submit(out);

            return out;
        } catch (Exception e) {
            throw wrap(e);
        }
    }

    @Override
    public void interrupt() {
        this.interrupted = true;
    }

    @Override
    public boolean interrupted() {
        return this.interrupted;
    }

    @Override
    public void reset() {
        this.interrupted = false;
    }

    protected void disposeExecutorService() {
        if (this.executorService != null) {
            this.executorService.shutdownNow();
            this.executorService = null;
        }
    }

    protected File resolveLocalPathFor(String path) {
        return this.currentLocalDirectory.resolve(path).normalize().toAbsolutePath().toFile();
    }

    protected void safeSetModificationTime(File file, long time) {
        if (!this.downloadedFileTimestampPreservingRequired) {
            return;
        }

        try {
            Files.setLastModifiedTime(file.toPath(), FileTime.fromMillis(time));
        } catch (Exception e) {
            logError(e);
        }
    }

    protected void safeSetModificationTime(String remoteFile, File localFile) {
        if (!this.downloadedFileTimestampPreservingRequired) {
            return;
        }

        try {
            Date timestamp = getFileTimestamp(remoteFile);
            Files.setLastModifiedTime(localFile.toPath(), FileTime.fromMillis(timestamp.getTime()));
        } catch (Exception e) {
            logError(e);
        }
    }

    protected void safeSetModificationTime(File localFile, String remoteFile) {
        if (!this.uploadedFileTimestampPreservingRequired) {
            return;
        }

        try {
            Date timestamp = new Date(localFile.lastModified());
            setFileTimestamp(remoteFile, timestamp);
        } catch (Exception e) {
            logError(e);
        }
    }

    protected void safeCheckChecksum(File localFile, String remoteFile)
            throws Exception {
        if (!this.checksumVerificationRequired) {
            return;
        }

        try {
            boolean sameChecksum = sameChecksum(localFile, remoteFile);

            if (!sameChecksum) {
                throw new Exception(String.format("File checksum mismatch for local file '%s' and remote file '%s'.", localFile, remoteFile));
            }
        } catch (FileTransferException e) {
            logError(e);
        }
    }

    protected long safeSizeOf(String remoteFile) {
        try {
            return getFilesize(remoteFile);
        } catch (Exception e) {
            return 0L;
        }
    }

    protected String formatRemoteFile(FileTransferRemoteFile file) {
        return MessageFormat.format(FILE_LISTING_TEMPLATE,
                                    file.isDirectory() ? 0 : 1,
                                    file.getFilename(),
                                    file.isReadable() ? 0 : 1,
                                    file.isWritable() ? 0 : 1,
                                    ByteFormat.format(file.getFilesize()),
                                    file.getFileDate());
    }

    protected void raiseEvent(FileTransferEvent event) {
        for (FileTransferListener listener : this.listeners) {
            event.accept(listener);
        }
    }

    protected void logError(Exception e) {
        if (this.logger.isLoggable(Level.WARNING)) {
            this.logger.log(Level.WARNING, "Operation error.", e);
        }
    }
}
