/*
 * Copyright 2017 Guilherme Chaguri
 *
 * Licensed 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 com.guichaguri.minimalftp;

import com.guichaguri.minimalftp.api.CommandInfo;
import com.guichaguri.minimalftp.api.CommandInfo.ArgsArrayCommand;
import com.guichaguri.minimalftp.api.CommandInfo.Command;
import com.guichaguri.minimalftp.api.CommandInfo.NoArgsCommand;
import com.guichaguri.minimalftp.api.IFileSystem;
import com.guichaguri.minimalftp.api.IUserAuthenticator;
import com.guichaguri.minimalftp.api.ResponseException;
import com.guichaguri.minimalftp.handler.ConnectionHandler;
import com.guichaguri.minimalftp.handler.FileHandler;
import java.io.*;
import java.net.InetAddress;
import java.net.Socket;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;

/**
 * Represents a FTP user connected to the server
 * @author Guilherme Chaguri
 */
public class FTPConnection implements Closeable {

    protected final Map<String, CommandInfo> commands = new HashMap<>();
    protected final Map<String, CommandInfo> siteCommands = new HashMap<>();
    protected final List<String> features = new ArrayList<>();
    protected final Map<String, String> options = new HashMap<>();

    protected final FTPServer server;
    protected Socket con;
    protected BufferedReader reader;
    protected BufferedWriter writer;
    protected final ConnectionThread thread;
    protected final ArrayDeque<Socket> dataConnections = new ArrayDeque<>();

    protected ConnectionHandler conHandler;
    protected FileHandler fileHandler;

    protected long bytesTransferred = 0;
    protected boolean responseSent = true;
    protected int timeout = 0;
    protected int bufferSize = 0;
    protected long lastUpdate = 0;

    /**
     * Creates a new FTP connection.
     *
     * Initialized by a {@link FTPServer}
     *
     * @param server The server which received the connection
     * @param con The connection socket
     * @param idleTimeout The timeout in milliseconds
     * @param bufferSize The buffer size in bytes
     * @throws IOException When an I/O error occurs
     */
    public FTPConnection(FTPServer server, Socket con, int idleTimeout, int bufferSize) throws IOException {
        this.server = server;
        this.con = con;
        this.reader = new BufferedReader(new InputStreamReader(con.getInputStream()));
        this.writer = new BufferedWriter(new OutputStreamWriter(con.getOutputStream()));

        this.timeout = idleTimeout;
        this.bufferSize = bufferSize;
        this.lastUpdate = System.currentTimeMillis();
        con.setSoTimeout(timeout);

        this.conHandler = new ConnectionHandler(this);
        this.fileHandler = new FileHandler(this);

        this.thread = new ConnectionThread();
        this.thread.start();

        registerCommand("SITE", "SITE <command>", this::site);
        registerCommand("FEAT", "FEAT", this::feat, false);
        registerCommand("OPTS", "OPTS <option> [value]", this::opts);

        registerFeature("feat"); // Feature Commands (RFC 5797)
        registerFeature("UTF8");
        registerOption("UTF8", "ON");

        this.conHandler.registerCommands();
        this.fileHandler.registerCommands();
        this.conHandler.onConnected();
    }

    /**
     * Creates a new FTP connection.
     *
     * @param server The server which received the connection
     * @param con The connection socket
     * @param idleTimeout The timeout in milliseconds
     * @throws IOException When an I/O error occurs
     * @deprecated Use {@link #FTPConnection(FTPServer, Socket, int, int)} instead
     */
    @Deprecated
    public FTPConnection(FTPServer server, Socket con, int idleTimeout) throws IOException {
        this(server, con, idleTimeout, 1024);
    }

    /**
     * Gets the server which the connection belongs
     * @return The {@link FTPServer} that received this connection
     */
    public FTPServer getServer() {
        return server;
    }

    /**
     * Gets the connection address
     * @return The {@link InetAddress} of this connection
     */
    public InetAddress getAddress() {
        return con.getInetAddress();
    }

    /**
     * Gets the amount of bytes sent or received
     * @return The number of bytes
     */
    public long getBytesTransferred() {
        return bytesTransferred;
    }

    /**
     * Gets whether the connection is authenticated
     * @return {@code true} when it's authenticated, {@code false} otherwise
     */
    public boolean isAuthenticated() {
        return conHandler.isAuthenticated();
    }

    /**
     * Gets the username of the connection.
     * @return The username or {@code null}
     */
    public String getUsername() {
        return conHandler.getUsername();
    }

    /**
     * Whether the connection is in ASCII instead of Binary
     * @return {@code true} for ASCII, {@code false} for Binary
     */
    public boolean isAsciiMode() {
        return conHandler.isAsciiMode();
    }

    /**
     * The file system of the connection. May be {@code null} when it's still authenticating
     * @return The current file system
     */
    public IFileSystem getFileSystem() {
        return fileHandler.getFileSystem();
    }

    /**
     * Sets the new file system for this connection.
     *
     * Calling this method can result into desynchronization for the connection.
     *
     * Use an {@link IUserAuthenticator} for custom file systems.
     *
     * @param fs The new file system
     */
    public void setFileSystem(IFileSystem fs) {
        fileHandler.setFileSystem(fs);
    }

    public boolean isSSLEnabled() {
        return con instanceof SSLSocket;
    }

    public void enableSSL(SSLContext context) throws IOException {
        SSLSocketFactory factory = context.getSocketFactory();
        con = factory.createSocket(con, con.getInetAddress().getHostAddress(), con.getPort(), true);
        ((SSLSocket)con).setUseClientMode(false);

        reader = new BufferedReader(new InputStreamReader(con.getInputStream()));
        writer = new BufferedWriter(new OutputStreamWriter(con.getOutputStream()));
    }

    /**
     * Sends a response to the connection
     * @param code The response code
     * @param response The response message
     */
    public void sendResponse(int code, String response) {
        if(con.isClosed()) return;

        if(response == null || response.isEmpty()) {
            response = "Unknown";
        }

        try {
            if(response.charAt(0) == '-') {
                writer.write(code + response + "\r\n");
            } else {
                writer.write(code + " " + response + "\r\n");
            }
            writer.flush();
        } catch(IOException ex) {
            Utils.closeQuietly(this);
        }
        responseSent = true;
    }

    /**
     * Sends an array of bytes through a data connection
     * @param data The data to be sent
     * @throws ResponseException When an error occurs
     */
    public void sendData(byte[] data) throws ResponseException {
        if(con.isClosed()) return;

        Socket socket = null;
        try {
            socket = conHandler.createDataSocket();
            dataConnections.add(socket);
            OutputStream out = socket.getOutputStream();

            Utils.write(out, data, data.length, conHandler.isAsciiMode());
            bytesTransferred += data.length;

            out.flush();
            Utils.closeQuietly(out);
            Utils.closeQuietly(socket);
        } catch(SocketException ex) {
            throw new ResponseException(426, "Transfer aborted");
        } catch(IOException ex) {
            throw new ResponseException(425, "An error occurred while transferring the data");
        } finally {
            onUpdate();
            if(socket != null) dataConnections.remove(socket);
        }
    }

    /**
     * Sends a stream through a data connection
     * @param in The input stream
     * @throws ResponseException When an error occurs
     */
    public void sendData(InputStream in) throws ResponseException {
        if(con.isClosed()) return;

        Socket socket = null;
        try {
            socket = conHandler.createDataSocket();
            dataConnections.add(socket);
            OutputStream out = socket.getOutputStream();

            byte[] buffer = new byte[bufferSize];
            int len;
            while((len = in.read(buffer)) != -1) {
                Utils.write(out, buffer, len, conHandler.isAsciiMode());
                bytesTransferred += len;
            }

            out.flush();
            Utils.closeQuietly(out);
            Utils.closeQuietly(in);
            Utils.closeQuietly(socket);
        } catch(SocketException ex) {
            throw new ResponseException(426, "Transfer aborted");
        } catch(IOException ex) {
            throw new ResponseException(425, "An error occurred while transferring the data");
        } finally {
            onUpdate();
            if(socket != null) dataConnections.remove(socket);
        }
    }

    /**
     * Receives a stream through the data connection
     * @param out The output stream
     * @throws ResponseException When an error occurs
     */
    public void receiveData(OutputStream out) throws ResponseException {
        if(con.isClosed()) return;

        Socket socket = null;
        try {
            socket = conHandler.createDataSocket();
            dataConnections.add(socket);
            InputStream in = socket.getInputStream();

            byte[] buffer = new byte[bufferSize];
            int len;
            while((len = in.read(buffer)) != -1) {
                out.write(buffer, 0, len);
                bytesTransferred += len;
            }

            out.flush();
            Utils.closeQuietly(out);
            Utils.closeQuietly(in);
            Utils.closeQuietly(socket);
        } catch(SocketException ex) {
            throw new ResponseException(426, "Transfer aborted");
        } catch(IOException ex) {
            throw new ResponseException(425, "An error occurred while transferring the data");
        } finally {
            onUpdate();
            if(socket != null) dataConnections.remove(socket);
        }
    }

    /**
     * Aborts all data transfers
     */
    public void abortDataTransfers() {
        while(!dataConnections.isEmpty()) {
            Socket socket = dataConnections.poll();
            if(socket != null) Utils.closeQuietly(socket);
        }
    }

    /**
     * Registers a feature line for the FEAT command
     * @param feat The feature name
     */
    public void registerFeature(String feat) {
        if(!features.contains(feat)) {
            features.add(feat);
        }
    }

    /**
     * Registers an option for the OPTS command
     * @param option The option name
     * @param value The default value
     */
    public void registerOption(String option, String value) {
        options.put(option.toUpperCase(), value);
    }

    /**
     * Gets an option which may be modified by a OPTS command
     * @param option The option name
     * @return The option value
     */
    public String getOption(String option) {
        return options.get(option.toUpperCase());
    }

    public void registerSiteCommand(String label, String help, Command cmd) {
        addSiteCommand(label, help, cmd);
    }

    public void registerSiteCommand(String label, String help, NoArgsCommand cmd) {
        addSiteCommand(label, help, cmd);
    }

    public void registerSiteCommand(String label, String help, ArgsArrayCommand cmd) {
        addSiteCommand(label, help, cmd);
    }

    public void registerCommand(String label, String help, Command cmd) {
        addCommand(label, help, cmd, true);
    }

    public void registerCommand(String label, String help, NoArgsCommand cmd) {
        addCommand(label, help, cmd, true);
    }

    public void registerCommand(String label, String help, ArgsArrayCommand cmd) {
        addCommand(label, help, cmd, true);
    }

    public void registerCommand(String label, String help, Command cmd, boolean needsAuth) {
        addCommand(label, help, cmd, needsAuth);
    }

    public void registerCommand(String label, String help, NoArgsCommand cmd, boolean needsAuth) {
        addCommand(label, help, cmd, needsAuth);
    }

    public void registerCommand(String label, String help, ArgsArrayCommand cmd, boolean needsAuth) {
        addCommand(label, help, cmd, needsAuth);
    }

    /**
     * Internally registers a SITE sub-command
     * @param label The command name
     * @param help The help message
     * @param cmd The command function
     */
    protected void addSiteCommand(String label, String help, Command cmd) {
        siteCommands.put(label.toUpperCase(), new CommandInfo(cmd, help, true));
    }

    /**
     * Internally registers a command
     * @param label The command name
     * @param help The help message
     * @param cmd The command function
     * @param needsAuth Whether authentication is required to run this command
     */
    protected void addCommand(String label, String help, Command cmd, boolean needsAuth) {
        commands.put(label.toUpperCase(), new CommandInfo(cmd, help, needsAuth));
    }

    /**
     * Gets the help message from a SITE command
     * @param label The command name
     * @return The help message or {@code null} if the command was not found
     */
    public String getSiteHelpMessage(String label) {
        CommandInfo info = siteCommands.get(label);
        return info != null ? info.help : null;
    }

    /**
     * Gets the help message from a command
     * @param label The command name
     * @return The help message or {@code null} if the command was not found
     */
    public String getHelpMessage(String label) {
        CommandInfo info = commands.get(label);
        return info != null ? info.help : null;
    }

    protected void onUpdate() {
        lastUpdate = System.currentTimeMillis();
    }

    /**
     * Processes commands
     * @param cmd The command and its arguments
     */
    protected void process(String cmd) {
        int firstSpace = cmd.indexOf(' ');
        if(firstSpace < 0) firstSpace = cmd.length();

        CommandInfo info = commands.get(cmd.substring(0, firstSpace).toUpperCase());

        if(info == null) {
            sendResponse(502, "Unknown command");
            return;
        }

        processCommand(info, firstSpace != cmd.length() ? cmd.substring(firstSpace + 1) : "");
    }

    /**
     * SITE command
     * @param cmd The command and its arguments
     */
    protected void site(String cmd) {
        int firstSpace = cmd.indexOf(' ');
        if(firstSpace < 0) firstSpace = cmd.length();

        CommandInfo info = siteCommands.get(cmd.substring(0, firstSpace).toUpperCase());

        if(info == null) {
            sendResponse(504, "Unknown site command");
            return;
        }

        processCommand(info, firstSpace != cmd.length() ? cmd.substring(firstSpace + 1) : "");
    }

    /**
     * FEAT command
     */
    protected void feat() {
        StringBuilder list = new StringBuilder();
        list.append("- Supported Features:\r\n");

        for(String feat : features) {
            list.append(' ').append(feat).append("\r\n");
        }

        sendResponse(211, list.toString());
        sendResponse(211, "End");
    }

    /**
     * OPTS command
     * @param opts The option
     */
    protected void opts(String[] opts) {
        if(opts.length < 1) {
            sendResponse(501, "Missing parameters");
            return;
        }

        String option = opts[0].toUpperCase();

        if(!options.containsKey(option)) {
            sendResponse(501, "No option found");
        } else if(opts.length < 2) {
            sendResponse(200, options.get(option));
        } else {
            options.put(option, opts[1].toUpperCase());
            sendResponse(200, "Option updated");
        }
    }

    protected void processCommand(CommandInfo info, String args) {
        if(info.needsAuth && !conHandler.isAuthenticated()) {
            sendResponse(530, "Needs authentication");
            return;
        }

        responseSent = false;

        try {
            info.command.run(info, args);
        } catch(ResponseException ex) {
            sendResponse(ex.getCode(), ex.getMessage());
        } catch(FileNotFoundException ex) {
            sendResponse(550, ex.getMessage());
        } catch(IOException ex) {
            sendResponse(450, ex.getMessage());
        } catch(Exception ex) {
            sendResponse(451, ex.getMessage());
            ex.printStackTrace();
        }

        if(!responseSent) sendResponse(200, "Done");
    }

    /**
     * Updates the connection
     */
    protected void update() {
        if(conHandler.shouldStop()) {
            Utils.closeQuietly(this);
            return;
        }

        String line;

        try {
            line = reader.readLine();
        } catch(SocketTimeoutException ex) {
            // Check if the socket has timed out
            if(!dataConnections.isEmpty() && (System.currentTimeMillis() - lastUpdate) >= timeout) {
                Utils.closeQuietly(this);
            }
            return;
        } catch(SocketException e) {
            Utils.closeQuietly(this);
            return;
        } catch(IOException ex) {
            return;
        }

        if(line == null) {
            Utils.closeQuietly(this);
            return;
        }

        if(line.isEmpty()) return;

        process(line);
    }

    /**
     * Stops the connection, but does not removes it from the list.
     *
     * For a complete cleanup, use {@link #close()} instead
     * @param close Whether it will close the connection
     * @throws IOException When an I/O error occurs
     */
    protected void stop(boolean close) throws IOException {
        if(!thread.isInterrupted()) {
            thread.interrupt();
        }

        conHandler.onDisconnected();

        if(close) con.close();
    }

    /**
     * Interrupts and disposes the connection
     * @param close Whether it will close the connection
     * @throws IOException When an I/O error occurs
     */
    protected void close(boolean close) throws IOException {
        stop(close);

        server.removeConnection(this);
    }

    /**
     * Interrupts and disposes the connection
     * @throws IOException When an I/O error occurs
     */
    @Override
    public void close() throws IOException {
        close(true);
    }

    /**
     * Thread that processes this connection
     */
    private class ConnectionThread extends Thread {
        @Override
        public void run() {
            while(!con.isClosed()) {
                update();
            }

            try {
                close(false);
            } catch(IOException ex) {
                ex.printStackTrace();
            }
        }
    }
}