import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;


/**
 * File request handler for HTTP/1.1 GET requests.
 */
public class FileRequestHandler {

    private final Path documentRoot;
    private static final String NEW_LINE = System.lineSeparator();

    public FileRequestHandler(Path documentRoot) {
        this.documentRoot = documentRoot;
    }

    /**
     * Called to handle an HTTP/1.1 GET request: first, the status code of the
     * request is determined and a corresponding response header is sent.
     * If the status code is 200, the requested document root path is sent
     * back to the client. In case the path points to a file, the file is sent,
     * and in case the path points to a directory, a listing of the contained
     * files is sent.
     *
     * @param request Client request
     * @param response Server response
     * @throws IOException
     */
    public void handle(String request, OutputStream response) throws IOException {
        // Get the status code for the current request.
        int statusCode = getStatusCode(request);

        // Create response header
        String statusLine = getStatusLine(statusCode);
        response.write(statusLine.getBytes());
        response.write(NEW_LINE.getBytes());

        // If the request can be processed further (i.e. the status code is 200)
        // determine the header fields for the response and append the proper
        // response body
        if (statusCode == 200) {
            Path path = stringToPath(request.split(" ")[1]);
            setHeaderFields(path, response);

            // Properly terminate the response header with a CRLF.
            response.write(NEW_LINE.getBytes());

            // Check if the path location is a directory or a file and
            // create the response body accordingly
            if (Files.isDirectory(path)) {
                String directoryListing = getDirectoryListing(path);
                response.write(directoryListing.getBytes());
            } else {
                Files.copy(path, response);
            }
        }
    }

    /**
     * Process a string as an HTTP request in order to determine the status code.
     * See RFC 7231 for a list of status codes and their definitions:
     * https://tools.ietf.org/html/rfc7231#section-6.1
     *
     * @param request Client request
     * @return the status code …<br>
     *         200 (OK) for a valid HTTP 1.1 GET request<br>
     *         400 (Bad Request) for a syntactically malformed request<br>
     *         404 (Not Found) for a non-existing path location<br>
     *         501 (Not Implemented) for an HTTP method that is not GET<br>
     *         505 (HTTP Version Not Supported) for an HTTP version that is
     *         not 1.1
     * @throws IOException
     */
    private int getStatusCode(String request) {
        // Check if the request string consists of three arguments (method,
        // path and http version).
        String[] requestArray = request.split(" ");
        if (requestArray.length != 3) {
            System.out.println("Error: Request does not have three arguments.");
            return 400;
        }

        String method = requestArray[0];
        if (!method.equals("GET")) {
            return 501;
        }

        // Check if the location of the path exists. Note that `Files.exists`
        // returns true both for existing files *and* directories which is
        // exactly what we want.
        Path path = stringToPath(requestArray[1]);
        if (!Files.exists(path)) {
            return 404;
        }

        // Validate the syntax of the http version argument by splitting at the
        // slash character. Only two values should be in the array.
        String[] protocolVersion = requestArray[2].split("/");
        if (protocolVersion.length != 2) {
            return 400;
        }

        // The part before the slash has to be equal to `HTTP`.
        String protocol = protocolVersion[0];
        if (!protocol.equals("HTTP")) {
            return 400;
        }

        // The part after the slash can be `0.9`, `1.0`, `1.1` or `2` but this
        // exercise only deals with HTTP/1.1
        String version = protocolVersion[1];
        if (!version.equals("1.1")) {
            return 505;
        }

        return 200;
    }

    /**
     * Write the status line and header fields for an HTTP response to the
     * output stream (the response).
     * <p>
     * Content-Length: https://tools.ietf.org/html/rfc7230#section-3.3.2<br>
     * Content-Type: https://tools.ietf.org/html/rfc7231#section-3.1.1.5<br>
     * Date: https://tools.ietf.org/html/rfc7231#section-7.1.1.2<br>
     * Last-Modified: https://tools.ietf.org/html/rfc7232#section-2.2<br>
     *
     * @param path Path to the location from the request
     * @param response Response object to set the header fields on
     * @throws IOException
     */
    private static void setHeaderFields(Path path, OutputStream response) throws IOException {
        List<String> headerFields = new ArrayList<>();

        // Proper date format as per RFC 2616 (HTTP/1.1):
        // https://tools.ietf.org/html/rfc2616#section-3.3.1
        final String httpDate = "EEE, dd MMM yyyy HH:mm:ss zzz";

        DateFormat dateFormat = new SimpleDateFormat(httpDate);
        headerFields.add("Date: " + dateFormat.format(new Date()));

        if (!Files.isDirectory(path)) {
            // Complicated for directories. There is a deprecated MIME type
            // `text/directory` in the RFC 2425, but various operating systems
            // have different answers for this question. Out of simplicity,
            // only the MIME type for files is delivered.
            headerFields.add("Content-Type: " + Files.probeContentType(path));

            // The length of the response body in bytes.
            headerFields.add("Content-Length: " + Files.size(path));
        }

        headerFields.add("Last-Modified: "
            + dateFormat.format(Files.getLastModifiedTime(path).toMillis()));

        for (String headerField : headerFields) {
            response.write(headerField.getBytes());
            response.write(NEW_LINE.getBytes());
        }
    }

    /**
     * Generates a listing of the contents of a directory.
     *
     * @param  directoryPath  Directory whose contents should be listed
     * @return a directory listing for the contents of the directory
     * @throws IOException
     */
    private static String getStatusLine(int statusCode) {
        String statusMessage = getStatusMessage(statusCode);
        return "HTTP/1.1 " + statusCode + " " + statusMessage;
    }

    /**
     * Convert a string to a Path object.
     *
     * @param  pathStr  String of a relative path
     * @return Path object
     */
    private static String getStatusMessage(int statusCode) {
        String statusMessage;
        switch (statusCode) {
            case 200:
                statusMessage = "OK";
                break;
            case 400:
                statusMessage = "Bad Request";
                break;
            case 404:
                statusMessage = "Not Found";
                break;
            case 501:
                statusMessage = "Not Implemented";
                break;
            case 505:
                statusMessage = "HTTP Version Not Supported";
                break;
            default:
                statusMessage = "Unknown";
                break;
        }
        return statusMessage;
    }

    /**
     * @param  dirPath  Directory that should be listed
     * @return a directory listing
     * @throws IOException
     */
    private String getDirectoryListing(Path dirPath) throws IOException {
        // directly write to output stream
        StringBuilder buffer = new StringBuilder();
        buffer.append(
            "listing /" + documentRoot.relativize(dirPath) + NEW_LINE
        );
        Files.list(dirPath).forEachOrdered(path -> {
            buffer.append(path.getFileName() + NEW_LINE);
        });
        return buffer.toString();
    }

    /**
     * Convert a string to a Path object.
     * @param  pathStr  String of a relative path
     * @return Path object
     */
    private Path stringToPath(String pathStr) {
        while (pathStr.startsWith("/")) {
            pathStr = pathStr.substring(1);
        }
        return documentRoot.resolve(pathStr).normalize();
    }
}
