/*
 * Dumbster - a dummy SMTP server
 * Copyright 2016 Joachim Nicolay
 * Copyright 2004 Jason Paul Kitchen
 *
 * 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.dumbster.smtp;

import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Scanner;
import java.util.regex.Pattern;

/** Dummy SMTP server for testing purposes. */
@Slf4j
public final class SimpleSmtpServer implements AutoCloseable {

	/** Default SMTP port is 25. */
	public static final int DEFAULT_SMTP_PORT = 25;

	/** pick any free port. */
	public static final int AUTO_SMTP_PORT = 0;

	/** When stopping wait this long for any still ongoing transmission */
	private static final int STOP_TIMEOUT = 20000;

	private static final Pattern CRLF = Pattern.compile("\r\n");

	/** Stores all of the email received since this instance started up. */
	private final List<SmtpMessage> receivedMail;

	/** The server socket this server listens to. */
	private final ServerSocket serverSocket;

	/** Thread that does the work. */
	private final Thread workerThread;

	/** Indicates the server thread that it should stop */
	private volatile boolean stopped = false;

	/**
	 * Creates an instance of a started SimpleSmtpServer.
	 *
	 * @param port port number the server should listen to
	 * @return a reference to the running SMTP server
	 * @throws IOException when listening on the socket causes one
	 */
	public static SimpleSmtpServer start(int port) throws IOException {
		return new SimpleSmtpServer(new ServerSocket(Math.max(port, 0)));
	}

	/**
	 * private constructor because factory method {@link #start(int)} better indicates that
	 * the created server is already running
	 * @param serverSocket socket to listen on
	 */
	private SimpleSmtpServer(ServerSocket serverSocket) {
		this.receivedMail = new ArrayList<>();
		this.serverSocket = serverSocket;
		this.workerThread = new Thread(
				new Runnable() {
					@Override
					public void run() {
						performWork();
					}
				});
		this.workerThread.start();
	}

	/**
	 * @return the port the server is listening on
	 */
	public int getPort() {
		return serverSocket.getLocalPort();
	}

	/**
	 * @return list of {@link SmtpMessage}s received by since start up or last reset.
	 */
	public List<SmtpMessage> getReceivedEmails() {
		synchronized (receivedMail) {
			return Collections.unmodifiableList(new ArrayList<>(receivedMail));
		}
	}

	/**
	 * forgets all received emails
	 */
	public void reset() {
		synchronized (receivedMail) {
			receivedMail.clear();
		}
	}

	/**
	 * Stops the server. Server is shutdown after processing of the current request is complete.
	 */
	public void stop() {
		if (stopped) {
			return;
		}
		// Mark us closed
		stopped = true;
		try {
			// Kick the server accept loop
			serverSocket.close();
		} catch (IOException e) {
			log.warn("trouble closing the server socket", e);
		}
		// and block until worker is finished
		try {
			workerThread.join(STOP_TIMEOUT);
		} catch (InterruptedException e) {
			log.warn("interrupted when waiting for worker thread to finish", e);
		}
	}

	/**
	 * synonym for {@link #stop()}
	 */
	@Override
	public void close() {
		stop();
	}

	/**
	 * Main loop of the SMTP server.
	 */
	private void performWork() {
		try {
			// Server: loop until stopped
			while (!stopped) {
				// Start server socket and listen for client connections
				//noinspection resource
				try (Socket socket = serverSocket.accept();
				     Scanner input = new Scanner(new InputStreamReader(socket.getInputStream(), StandardCharsets.ISO_8859_1)).useDelimiter(CRLF);
				     PrintWriter out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.ISO_8859_1));) {

					synchronized (receivedMail) {
						/*
						 * We synchronize over the handle method and the list update because the client call completes inside
						 * the handle method and we have to prevent the client from reading the list until we've updated it.
						 */
						receivedMail.addAll(handleTransaction(out, input));
					}
				}
			}
		} catch (Exception e) {
			// SocketException expected when stopping the server
			if (!stopped) {
				log.error("hit exception when running server", e);
				try {
					serverSocket.close();
				} catch (IOException ex) {
					log.error("and one when closing the port", ex);
				}
			}
		}
	}

	/**
	 * Handle an SMTP transaction, i.e. all activity between initial connect and QUIT command.
	 *
	 * @param out   output stream
	 * @param input input stream
	 * @return List of SmtpMessage
	 * @throws IOException
	 */
	private static List<SmtpMessage> handleTransaction(PrintWriter out, Iterator<String> input) throws IOException {
		// Initialize the state machine
		SmtpState smtpState = SmtpState.CONNECT;
		SmtpRequest smtpRequest = new SmtpRequest(SmtpActionType.CONNECT, "", smtpState);

		// Execute the connection request
		SmtpResponse smtpResponse = smtpRequest.execute();

		// Send initial response
		sendResponse(out, smtpResponse);
		smtpState = smtpResponse.getNextState();

		List<SmtpMessage> msgList = new ArrayList<>();
		SmtpMessage msg = new SmtpMessage();

		while (smtpState != SmtpState.CONNECT) {
			String line = input.next();

			if (line == null) {
				break;
			}

			// Create request from client input and current state
			SmtpRequest request = SmtpRequest.createRequest(line, smtpState);
			// Execute request and create response object
			SmtpResponse response = request.execute();
			// Move to next internal state
			smtpState = response.getNextState();
			// Send response to client
			sendResponse(out, response);

			// Store input in message
			String params = request.params;
			msg.store(response, params);

			// If message reception is complete save it
			if (smtpState == SmtpState.QUIT) {
				msgList.add(msg);
				msg = new SmtpMessage();
			}
		}

		return msgList;
	}

	/**
	 * Send response to client.
	 *
	 * @param out          socket output stream
	 * @param smtpResponse response object
	 */
	private static void sendResponse(PrintWriter out, SmtpResponse smtpResponse) {
		if (smtpResponse.getCode() > 0) {
			int code = smtpResponse.getCode();
			String message = smtpResponse.getMessage();
			out.print(code + " " + message + "\r\n");
			out.flush();
		}
	}
}