/*
 * Copyright 2007 the original author or authors.
 * 
 * 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 org.xbib.files.ftp.mock.core.server;

import java.util.logging.Level;
import java.util.logging.Logger;
import org.xbib.files.ftp.mock.core.MockFtpServerException;
import org.xbib.files.ftp.mock.core.command.Command;
import org.xbib.files.ftp.mock.core.command.CommandHandler;
import org.xbib.files.ftp.mock.core.session.DefaultSession;
import org.xbib.files.ftp.mock.core.session.Session;
import org.xbib.files.ftp.mock.core.socket.DefaultServerSocketFactory;
import org.xbib.files.ftp.mock.core.socket.ServerSocketFactory;
import org.xbib.files.ftp.mock.core.util.Assert;

import java.io.IOException;
import java.net.*;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.ResourceBundle;
import org.xbib.files.ftp.mock.core.command.ReplyTextBundleAware;
import org.xbib.files.ftp.mock.core.util.AssertFailedException;
import org.xbib.files.ftp.mock.fake.FakeFtpServer;
import org.xbib.files.ftp.mock.stub.StubFtpServer;

/**
 * This is the abstract superclass for "mock" implementations of an FTP Server,
 * suitable for testing FTP client code or standing in for a live FTP server. It supports
 * the main FTP commands by defining handlers for each of the corresponding low-level FTP
 * server commands (e.g. RETR, DELE, LIST). These handlers implement the {@link CommandHandler}
 * interface.
 *
 * <p>By default, mock FTP Servers bind to the server control port of 21. You can use a different server control
 * port by setting the <code>serverControlPort</code> property. If you specify a value of <code>0</code>, 
 * then a free port number will be chosen automatically; call <code>getServerControlPort()</code> AFTER
 * <code>start()</code> has been called to determine the actual port number being used. Using a non-default
 * port number is usually necessary when running on Unix or some other system where that port number is
 * already in use or cannot be bound from a user process.
 *
 * <p><b>Command Handlers</b></p>
 * You can set the existing {@link CommandHandler} defined for an FTP server command
 * by calling the {@link #setCommandHandler(String, CommandHandler)} method, passing
 * in the FTP server command name and {@link CommandHandler} instance.
 * You can also replace multiple command handlers at once by using the {@link #setCommandHandlers(Map)}
 * method. That is especially useful when configuring the server through the <b>Spring Framework</b>.
 *
 * <p>You can retrieve the existing {@link CommandHandler} defined for an FTP server command by
 * calling the {@link #getCommandHandler(String)} method, passing in the FTP server command name.
 *
 * <p><b>FTP Command Reply Text ResourceBundle</b></p>
 * The default text asociated with each FTP command reply code is contained within the
 * "ReplyText.properties" ResourceBundle file. You can customize these messages by providing a
 * locale-specific ResourceBundle file on the CLASSPATH, according to the normal lookup rules of
 * the ResourceBundle class (e.g., "ReplyText_de.properties"). Alternatively, you can
 * completely replace the ResourceBundle file by calling the calling the
 * {@link #setReplyTextBaseName(String)} method.
 *
 * @author Chris Mair
 * @see FakeFtpServer
 * @see StubFtpServer
 */
public abstract class AbstractFtpServer implements Runnable {

    /**
     * Default basename for reply text ResourceBundle
     */
    public static final String REPLY_TEXT_BASENAME = "org.xbib.files.ftp.mock.core.server.ReplyText";
    private static final int DEFAULT_SERVER_CONTROL_PORT = 21;

    protected Logger LOG = Logger.getLogger(getClass().getName());

    // Simple value object that holds the socket and thread for a single session
    private static class SessionInfo {
        Socket socket;
        Thread thread;
    }

    protected ServerSocketFactory serverSocketFactory = new DefaultServerSocketFactory();
    private ServerSocket serverSocket = null;
    private ResourceBundle replyTextBundle;
    private volatile boolean terminate = false;
    private Map commandHandlers;
    private Thread serverThread;
    private int serverControlPort = DEFAULT_SERVER_CONTROL_PORT;
    private final Object startLock = new Object();

    // Map of Session -> SessionInfo
    private Map sessions = new HashMap();

    /**
     * Create a new instance. Initialize the default command handlers and
     * reply text ResourceBundle.
     */
    public AbstractFtpServer() {
        replyTextBundle = ResourceBundle.getBundle(REPLY_TEXT_BASENAME);
        commandHandlers = new HashMap();
    }

    /**
     * Start a new Thread for this server instance
     */
    public void start() {
        serverThread = new Thread(this);

        synchronized (startLock) {
            try {
                // Start here in case server thread runs faster than main thread.
                // See https://sourceforge.net/tracker/?func=detail&atid=1006533&aid=1925590&group_id=208647
                serverThread.start();

                // Wait until the server thread is initialized
                startLock.wait();
            }
            catch (InterruptedException e) {
                e.printStackTrace();
                throw new MockFtpServerException(e);
            }
        }
    }

    /**
     * The logic for the server thread
     *
     * @see Runnable#run()
     */
    public void run() {
        try {
            LOG.info("Starting the server on port " + serverControlPort);
            serverSocket = serverSocketFactory.createServerSocket(serverControlPort);
            if (serverControlPort == 0) {
                this.serverControlPort = serverSocket.getLocalPort();
                LOG.info("Actual server port is " + this.serverControlPort);
            }

            // Notify to allow the start() method to finish and return
            synchronized (startLock) {
                startLock.notify();
            }

            while (!terminate) {
                try {
                    cleanupClosedSessions();
                    Socket clientSocket = serverSocket.accept();
                    LOG.info("Connection accepted from host " + clientSocket.getInetAddress());

                    Session session = createSession(clientSocket);
                    Thread sessionThread = new Thread(session);
                    sessionThread.start();

                    SessionInfo sessionInfo = new SessionInfo();
                    sessionInfo.socket = clientSocket;
                    sessionInfo.thread = sessionThread;
                    sessions.put(session, sessionInfo);
                }
                catch (SocketException e) {
                    LOG.log(Level.FINE, "Socket exception: " + e.toString());
                }
            }
        }
        catch (IOException e) {
            LOG.log(Level.SEVERE, "Error", e);
        }
        finally {

            LOG.log(Level.FINE, "Cleaning up server...");

            // Ensure that the start() method is not still blocked
            synchronized (startLock) {
                startLock.notifyAll();
            }

            try {
                if (serverSocket != null) {
                    serverSocket.close();
                }
                closeSessions();
            }
            catch (IOException e) {
                LOG.log(Level.SEVERE, "Error cleaning up server", e);
            }
            catch (InterruptedException e) {
                LOG.log(Level.SEVERE, "Error cleaning up server", e);
            }
            LOG.info("Server stopped.");
            terminate = false;
        }
    }

    /**
     * Stop this server instance and wait for it to terminate.
     */
    public void stop() {

        LOG.log(Level.FINE, "Stopping the server...");
        terminate = true;

        if (serverSocket != null) {
            try {
                serverSocket.close();
            } catch (IOException e) {
                throw new MockFtpServerException(e);
            }
        }

        try {
            if (serverThread != null) {
                serverThread.join();
            }
        }
        catch (InterruptedException e) {
            e.printStackTrace();
            throw new MockFtpServerException(e);
        }
    }

    /**
     * Return the CommandHandler defined for the specified command name
     *
     * @param name - the command name
     * @return the CommandHandler defined for name
     */
    public CommandHandler getCommandHandler(String name) {
        return (CommandHandler) commandHandlers.get(Command.normalizeName(name));
    }

    /**
     * Override the default CommandHandlers with those in the specified Map of
     * commandName:CommandHandler. This will only override the default CommandHandlers
     * for the keys in <code>commandHandlerMapping</code>. All other default CommandHandler
     * mappings remain unchanged.
     *
     * @param commandHandlerMapping - the Map of commandName:CommandHandler; these override the defaults
     * @throws AssertFailedException
     *          - if the commandHandlerMapping is null
     */
    public void setCommandHandlers(Map commandHandlerMapping) {
        Assert.notNull(commandHandlerMapping, "commandHandlers");
        for (Iterator iter = commandHandlerMapping.keySet().iterator(); iter.hasNext();) {
            String commandName = (String) iter.next();
            setCommandHandler(commandName, (CommandHandler) commandHandlerMapping.get(commandName));
        }
    }

    /**
     * Set the CommandHandler for the specified command name. If the CommandHandler implements
     * the {@link ReplyTextBundleAware} interface and its <code>replyTextBundle</code> attribute
     * is null, then set its <code>replyTextBundle</code> to the <code>replyTextBundle</code> of
     * this StubFtpServer.
     *
     * @param commandName    - the command name to which the CommandHandler will be associated
     * @param commandHandler - the CommandHandler
     * @throws AssertFailedException
     *          - if the commandName or commandHandler is null
     */
    public void setCommandHandler(String commandName, CommandHandler commandHandler) {
        Assert.notNull(commandName, "commandName");
        Assert.notNull(commandHandler, "commandHandler");
        commandHandlers.put(Command.normalizeName(commandName), commandHandler);
        initializeCommandHandler(commandHandler);
    }

    /**
     * Set the reply text ResourceBundle to a new ResourceBundle with the specified base name,
     * accessible on the CLASSPATH. See {@link ResourceBundle#getBundle(String)}.
     *
     * @param baseName - the base name of the resource bundle, a fully qualified class name
     */
    public void setReplyTextBaseName(String baseName) {
        replyTextBundle = ResourceBundle.getBundle(baseName);
    }

    /**
     * Return the ReplyText ResourceBundle. Set the bundle through the  {@link #setReplyTextBaseName(String)}  method.
     *
     * @return the reply text ResourceBundle
     */
    public ResourceBundle getReplyTextBundle() {
        return replyTextBundle;
    }

    /**
     * Set the port number to which the server control connection socket will bind. The default value is 21.
     *
     * @param serverControlPort - the port number for the server control connection ServerSocket
     */
    public void setServerControlPort(int serverControlPort) {
        this.serverControlPort = serverControlPort;
    }

    /**
     * Return the port number to which the server control connection socket will bind. The default value is 21.
     *
     * @return the port number for the server control connection ServerSocket
     */
    public int getServerControlPort() {
        return serverControlPort;
    }

    /**
     * Return true if this server is fully shutdown -- i.e., there is no active (alive) threads and
     * all sockets are closed. This method is intended for testing only.
     *
     * @return true if this server is fully shutdown
     */
    public boolean isShutdown() {
        boolean shutdown = !serverThread.isAlive() && serverSocket.isClosed();

        for (Iterator iter = sessions.values().iterator(); iter.hasNext();) {
            SessionInfo sessionInfo = (SessionInfo) iter.next();
            shutdown = shutdown && sessionInfo.socket.isClosed() && !sessionInfo.thread.isAlive();
        }
        return shutdown;
    }

    /**
     * Return true if this server has started -- i.e., there is an active (alive) server threads
     * and non-null server socket. This method is intended for testing only.
     *
     * @return true if this server has started
     */
    public boolean isStarted() {
        return serverThread != null && serverThread.isAlive() && serverSocket != null;
    }

    //-------------------------------------------------------------------------
    // Internal Helper Methods
    //-------------------------------------------------------------------------

    /**
     * Create a new Session instance for the specified client Socket
     *
     * @param clientSocket - the Socket associated with the client
     * @return a Session
     */
    public Session createSession(Socket clientSocket) {
        return new DefaultSession(clientSocket, commandHandlers);
    }

    private void cleanupClosedSessions() {
        Iterator iter = sessions.keySet().iterator();
        while(iter.hasNext()) {
            Session session = (Session) iter.next();
            if (session.isClosed()) {
                iter.remove();
            }
        }
    }

    // For testing
    public int numberOfSessions() {
        return sessions.size();
    }

    private void closeSessions() throws InterruptedException, IOException {
        for (Iterator iter = sessions.entrySet().iterator(); iter.hasNext();) {
            Map.Entry entry = (Map.Entry) iter.next();
            Session session = (Session) entry.getKey();
            SessionInfo sessionInfo = (SessionInfo) entry.getValue();
            session.close();
            sessionInfo.thread.join(500L);
            Socket sessionSocket = sessionInfo.socket;
            if (sessionSocket != null) {
                sessionSocket.close();
            }
        }
    }

    //------------------------------------------------------------------------------------
    // Abstract method declarations
    //------------------------------------------------------------------------------------

    /**
     * Initialize a CommandHandler that has been registered to this server. What "initialization"
     * means is dependent on the subclass implementation.
     *
     * @param commandHandler - the CommandHandler to initialize
     */
    protected abstract void initializeCommandHandler(CommandHandler commandHandler);

}
