/*
 * JBoss, Home of Professional Open Source.
 * Copyright 2014 Red Hat, Inc., and individual contributors
 * as indicated by the @author tags.
 *
 * 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 io.undertow.server.handlers.resource;

import java.io.File;
import java.io.IOException;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;

import io.undertow.UndertowLogger;
import io.undertow.predicate.Predicate;
import io.undertow.predicate.Predicates;
import io.undertow.server.HandlerWrapper;
import io.undertow.server.HttpHandler;
import io.undertow.server.HttpServerExchange;
import io.undertow.server.handlers.ResponseCodeHandler;
import io.undertow.server.handlers.builder.HandlerBuilder;
import io.undertow.util.ByteRange;
import io.undertow.util.CanonicalPathUtils;
import io.undertow.util.DateUtils;
import io.undertow.util.ETag;
import io.undertow.util.ETagUtils;
import io.undertow.httpcore.HttpHeaderNames;
import io.undertow.httpcore.HttpMethodNames;
import io.undertow.util.MimeMappings;
import io.undertow.util.RedirectBuilder;
import io.undertow.httpcore.StatusCodes;

/**
 * @author Stuart Douglas
 */
public class ResourceHandler implements HttpHandler {

    /**
     * Set of methods prescribed by HTTP 1.1. If request method is not one of those, handler will
     * return NOT_IMPLEMENTED.
     */
    private static final Set<String> KNOWN_METHODS = new HashSet<>();

    static {
        KNOWN_METHODS.add(HttpMethodNames.OPTIONS);
        KNOWN_METHODS.add(HttpMethodNames.GET);
        KNOWN_METHODS.add(HttpMethodNames.HEAD);
        KNOWN_METHODS.add(HttpMethodNames.POST);
        KNOWN_METHODS.add(HttpMethodNames.PUT);
        KNOWN_METHODS.add(HttpMethodNames.DELETE);
        KNOWN_METHODS.add(HttpMethodNames.TRACE);
        KNOWN_METHODS.add(HttpMethodNames.CONNECT);
    }

    private final List<String> welcomeFiles = new CopyOnWriteArrayList<>(new String[]{"index.html", "index.htm", "default.html", "default.htm"});
    /**
     * If directory listing is enabled.
     */
    private volatile boolean directoryListingEnabled = false;

    /**
     * If the canonical version of paths should be passed into the resource manager.
     */
    private volatile boolean canonicalizePaths = true;

    /**
     * The mime mappings that are used to determine the content type.
     */
    private volatile MimeMappings mimeMappings = MimeMappings.DEFAULT;
    private volatile Predicate allowed = Predicates.truePredicate();
    private volatile ResourceSupplier resourceSupplier;
    private volatile ResourceManager resourceManager;

    /**
     * Handler that is called if no resource is found
     */
    private final HttpHandler next;

    public ResourceHandler(ResourceManager resourceSupplier) {
        this(resourceSupplier, ResponseCodeHandler.HANDLE_404);
    }

    public ResourceHandler(ResourceManager resourceManager, HttpHandler next) {
        this.resourceSupplier = new DefaultResourceSupplier(resourceManager);
        this.resourceManager = resourceManager;
        this.next = next;
    }

    public ResourceHandler(ResourceSupplier resourceSupplier) {
        this(resourceSupplier, ResponseCodeHandler.HANDLE_404);
    }

    public ResourceHandler(ResourceSupplier resourceManager, HttpHandler next) {
        this.resourceSupplier = resourceManager;
        this.next = next;
    }


    /**
     * You should use {@link ResourceHandler(ResourceManager)} instead.
     */
    @Deprecated
    public ResourceHandler() {
        this.next = ResponseCodeHandler.HANDLE_404;
    }

    @Override
    public void handleRequest(final HttpServerExchange exchange) throws Exception {
        if (exchange.getRequestMethod().equals(HttpMethodNames.GET) ||
                exchange.getRequestMethod().equals(HttpMethodNames.POST)) {
            serveResource(exchange, true);
        } else if (exchange.getRequestMethod().equals(HttpMethodNames.HEAD)) {
            serveResource(exchange, false);
        } else {
            if (KNOWN_METHODS.contains(exchange.getRequestMethod())) {
                exchange.setStatusCode(StatusCodes.METHOD_NOT_ALLOWED);
                exchange.addResponseHeader(HttpHeaderNames.ALLOW,
                        String.join(", ", HttpMethodNames.GET, HttpMethodNames.HEAD, HttpMethodNames.POST));
            } else {
                exchange.setStatusCode(StatusCodes.NOT_IMPLEMENTED);
            }
            exchange.endExchange();
        }
    }

    private void serveResource(final HttpServerExchange exchange, final boolean sendContent) throws Exception {

        if (DirectoryUtils.sendRequestedBlobs(exchange)) {
            return;
        }

        if (!allowed.resolve(exchange)) {
            exchange.setStatusCode(StatusCodes.FORBIDDEN);
            exchange.endExchange();
            return;
        }

        //we now dispatch to a worker thread
        //as resource manager methods are potentially blocking
        HttpHandler dispatchTask = new HttpHandler() {
            @Override
            public void handleRequest(HttpServerExchange exchange) throws Exception {
                Resource resource = null;
                try {
                    if (File.separatorChar == '/' || !exchange.getRelativePath().contains(File.separator)) {
                        //we don't process resources that contain the sperator character if this is not /
                        //this prevents attacks where people use windows path seperators in file URLS's
                        resource = resourceSupplier.getResource(exchange, canonicalize(exchange.getRelativePath()));
                    }
                } catch (IOException e) {
                    clearCacheHeaders(exchange);
                    UndertowLogger.REQUEST_IO_LOGGER.ioException(e);
                    exchange.setStatusCode(StatusCodes.INTERNAL_SERVER_ERROR);
                    exchange.endExchange();
                    return;
                }
                if (resource == null) {
                    clearCacheHeaders(exchange);
                    //usually a 404 handler
                    next.handleRequest(exchange);
                    return;
                }

                if (resource.isDirectory()) {
                    Resource indexResource;
                    try {
                        indexResource = getIndexFiles(exchange, resourceSupplier, resource.getPath(), welcomeFiles);
                    } catch (IOException e) {
                        UndertowLogger.REQUEST_IO_LOGGER.ioException(e);
                        exchange.setStatusCode(StatusCodes.INTERNAL_SERVER_ERROR);
                        exchange.endExchange();
                        return;
                    }
                    if (indexResource == null) {
                        if (directoryListingEnabled) {
                            DirectoryUtils.renderDirectoryListing(exchange, resource);
                            return;
                        } else {
                            exchange.setStatusCode(StatusCodes.FORBIDDEN);
                            exchange.endExchange();
                            return;
                        }
                    } else if (!exchange.getRequestPath().endsWith("/")) {
                        exchange.setStatusCode(StatusCodes.FOUND);
                        exchange.setResponseHeader(HttpHeaderNames.LOCATION, RedirectBuilder.redirect(exchange, exchange.getRelativePath() + "/", true));
                        exchange.endExchange();
                        return;
                    }
                    resource = indexResource;
                } else if (exchange.getRelativePath().endsWith("/")) {
                    //UNDERTOW-432
                    exchange.setStatusCode(StatusCodes.NOT_FOUND);
                    exchange.endExchange();
                    return;
                }

                final ETag etag = resource.getETag();
                final Date lastModified = resource.getLastModified();
                if (!ETagUtils.handleIfMatch(exchange, etag, false) ||
                        !DateUtils.handleIfUnmodifiedSince(exchange, lastModified)) {
                    exchange.setStatusCode(StatusCodes.PRECONDITION_FAILED);
                    exchange.endExchange();
                    return;
                }
                if (!ETagUtils.handleIfNoneMatch(exchange, etag, true) ||
                        !DateUtils.handleIfModifiedSince(exchange, lastModified)) {
                    exchange.setStatusCode(StatusCodes.NOT_MODIFIED);
                    exchange.endExchange();
                    return;
                }
                Long contentLength = resource.getContentLength();

                if (contentLength != null && !exchange.containsResponseHeader(HttpHeaderNames.TRANSFER_ENCODING)) {
                    exchange.setResponseContentLength(contentLength);
                }
                ByteRange.RangeResponseResult rangeResponse = null;
                long start = -1, end = -1;
                if (resource instanceof RangeAwareResource && ((RangeAwareResource) resource).isRangeSupported() && contentLength != null) {

                    exchange.setResponseHeader(HttpHeaderNames.ACCEPT_RANGES, "bytes");
                    //TODO: figure out what to do with the content encoded resource manager
                    ByteRange range = ByteRange.parse(exchange.getRequestHeader(HttpHeaderNames.RANGE));
                    if (range != null && range.getRanges() == 1 && resource.getContentLength() != null) {
                        rangeResponse = range.getResponseResult(resource.getContentLength(), exchange.getRequestHeader(HttpHeaderNames.IF_RANGE), resource.getLastModified(), resource.getETag() == null ? null : resource.getETag().getTag());
                        if (rangeResponse != null) {
                            start = rangeResponse.getStart();
                            end = rangeResponse.getEnd();
                            exchange.setStatusCode(rangeResponse.getStatusCode());
                            exchange.setResponseHeader(HttpHeaderNames.CONTENT_RANGE, rangeResponse.getContentRange());
                            long length = rangeResponse.getContentLength();
                            exchange.setResponseContentLength(length);
                            if (rangeResponse.getStatusCode() == StatusCodes.REQUEST_RANGE_NOT_SATISFIABLE) {
                                return;
                            }
                        }
                    }
                }
                //we are going to proceed. Set the appropriate headers

                if (!exchange.containsResponseHeader(HttpHeaderNames.CONTENT_TYPE)) {
                    final String contentType = resource.getContentType(mimeMappings);
                    if (contentType != null) {
                        exchange.setResponseHeader(HttpHeaderNames.CONTENT_TYPE, contentType);
                    } else {
                        exchange.setResponseHeader(HttpHeaderNames.CONTENT_TYPE, "application/octet-stream");
                    }
                }
                if (lastModified != null) {
                    exchange.setResponseHeader(HttpHeaderNames.LAST_MODIFIED, resource.getLastModifiedString());
                }
                if (etag != null) {
                    exchange.setResponseHeader(HttpHeaderNames.ETAG, etag.toString());
                }

                if (!sendContent) {
                    exchange.endExchange();
                } else if (rangeResponse != null) {
                    ((RangeAwareResource) resource).serveRangeAsync(exchange.getOutputChannel(), exchange, start, end);
                } else {
                    resource.serveAsync(exchange.getOutputChannel(), exchange);
                }
            }
        };
        if (exchange.isInIoThread()) {
            exchange.dispatch(dispatchTask);
        } else {
            dispatchTask.handleRequest(exchange);
        }
    }

    private void clearCacheHeaders(HttpServerExchange exchange) {
        exchange.removeResponseHeader(HttpHeaderNames.CACHE_CONTROL);
        exchange.removeResponseHeader(HttpHeaderNames.EXPIRES);
    }

    private Resource getIndexFiles(HttpServerExchange exchange, ResourceSupplier resourceManager, final String base, List<String> possible) throws IOException {
        String realBase;
        if (base.endsWith("/")) {
            realBase = base;
        } else {
            realBase = base + "/";
        }
        for (String possibility : possible) {
            Resource index = resourceManager.getResource(exchange, canonicalize(realBase + possibility));
            if (index != null) {
                return index;
            }
        }
        return null;
    }

    private String canonicalize(String s) {
        if (canonicalizePaths) {
            return CanonicalPathUtils.canonicalize(s);
        }
        return s;
    }

    public boolean isDirectoryListingEnabled() {
        return directoryListingEnabled;
    }

    public ResourceHandler setDirectoryListingEnabled(final boolean directoryListingEnabled) {
        this.directoryListingEnabled = directoryListingEnabled;
        return this;
    }

    public ResourceHandler addWelcomeFiles(String... files) {
        this.welcomeFiles.addAll(Arrays.asList(files));
        return this;
    }

    public ResourceHandler setWelcomeFiles(String... files) {
        this.welcomeFiles.clear();
        this.welcomeFiles.addAll(Arrays.asList(files));
        return this;
    }

    public MimeMappings getMimeMappings() {
        return mimeMappings;
    }

    public ResourceHandler setMimeMappings(final MimeMappings mimeMappings) {
        this.mimeMappings = mimeMappings;
        return this;
    }

    public Predicate getAllowed() {
        return allowed;
    }

    public ResourceHandler setAllowed(final Predicate allowed) {
        this.allowed = allowed;
        return this;
    }

    public ResourceSupplier getResourceSupplier() {
        return resourceSupplier;
    }

    public ResourceHandler setResourceSupplier(final ResourceSupplier resourceSupplier) {
        this.resourceSupplier = resourceSupplier;
        this.resourceManager = null;
        return this;
    }

    public ResourceManager getResourceManager() {
        return resourceManager;
    }

    public ResourceHandler setResourceManager(final ResourceManager resourceManager) {
        this.resourceManager = resourceManager;
        this.resourceSupplier = new DefaultResourceSupplier(resourceManager);
        return this;
    }

    public boolean isCanonicalizePaths() {
        return canonicalizePaths;
    }

    /**
     * If this handler should use canonicalized paths.
     * <p>
     * WARNING: If this is not true and {@link io.undertow.server.handlers.CanonicalPathHandler} is not installed in
     * the handler chain then is may be possible to perform a directory traversal attack. If you set this to false make
     * sure you have some kind of check in place to control the path.
     *
     * @param canonicalizePaths If paths should be canonicalized
     */
    public void setCanonicalizePaths(boolean canonicalizePaths) {
        this.canonicalizePaths = canonicalizePaths;
    }

    public static class Builder implements HandlerBuilder {

        @Override
        public String name() {
            return "resource";
        }

        @Override
        public Map<String, Class<?>> parameters() {
            Map<String, Class<?>> params = new HashMap<>();
            params.put("location", String.class);
            params.put("allow-listing", boolean.class);
            return params;
        }

        @Override
        public Set<String> requiredParameters() {
            return Collections.singleton("location");
        }

        @Override
        public String defaultParameter() {
            return "location";
        }

        @Override
        public HandlerWrapper build(Map<String, Object> config) {
            return new Wrapper((String) config.get("location"), (Boolean) config.get("allow-listing"));
        }

    }

    private static class Wrapper implements HandlerWrapper {

        private final String location;
        private final boolean allowDirectoryListing;

        private Wrapper(String location, boolean allowDirectoryListing) {
            this.location = location;
            this.allowDirectoryListing = allowDirectoryListing;
        }

        @Override
        public HttpHandler wrap(HttpHandler handler) {
            ResourceManager rm = new PathResourceManager(Paths.get(location), 1024);
            ResourceHandler resourceHandler = new ResourceHandler(rm);
            resourceHandler.setDirectoryListingEnabled(allowDirectoryListing);
            return resourceHandler;
        }
    }
}