/*
 * 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.servlet.handlers;

import static io.undertow.servlet.handlers.ServletPathMatch.Type.REDIRECT;
import static io.undertow.servlet.handlers.ServletPathMatch.Type.REWRITE;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.http.MappingMatch;

import io.undertow.UndertowLogger;
import io.undertow.server.HandlerWrapper;
import io.undertow.server.HttpHandler;
import io.undertow.server.handlers.cache.LRUCache;
import io.undertow.server.handlers.resource.CachingResourceManager;
import io.undertow.server.handlers.resource.Resource;
import io.undertow.server.handlers.resource.ResourceChangeEvent;
import io.undertow.server.handlers.resource.ResourceChangeListener;
import io.undertow.server.handlers.resource.ResourceManager;
import io.undertow.servlet.UndertowServletMessages;
import io.undertow.servlet.api.Deployment;
import io.undertow.servlet.api.DeploymentInfo;
import io.undertow.servlet.api.FilterMappingInfo;
import io.undertow.servlet.api.ServletInfo;
import io.undertow.servlet.core.ManagedFilter;
import io.undertow.servlet.core.ManagedFilters;
import io.undertow.servlet.core.ManagedServlet;
import io.undertow.servlet.core.ManagedServlets;
import io.undertow.servlet.handlers.security.ServletSecurityRoleHandler;

/**
 * Facade around {@link ServletPathMatchesData}. This facade is responsible for re-generating the matches if anything changes.
 *
 * @author Stuart Douglas
 */
public class ServletPathMatches {

    public static final String DEFAULT_SERVLET_NAME = "default";
    private final Deployment deployment;

    private volatile String[] welcomePages;
    private final ResourceManager resourceManager;

    private volatile ServletPathMatchesData data;

    // cache for fixed data matches (no welcome files involved)
    private final LRUCache<String, ServletPathMatch> pathMatchCacheFixed;

    // cache for resource matches that can change
    private final LRUCache<String, ServletPathMatch> pathMatchCacheResources;

    public ServletPathMatches(final Deployment deployment) {
        this.deployment = deployment;
        this.welcomePages = deployment.getDeploymentInfo().getWelcomePages().toArray(new String[deployment.getDeploymentInfo().getWelcomePages().size()]);
        this.resourceManager = deployment.getDeploymentInfo().getResourceManager();
        this.pathMatchCacheFixed = new LRUCache<>(1000, -1, true);
        this.pathMatchCacheResources = new LRUCache<>(1000,
                resourceManager instanceof CachingResourceManager? ((CachingResourceManager) resourceManager).getMaxAge() : -1, true);
        // add change listener for welcome pages
        if (this.resourceManager.isResourceChangeListenerSupported()) {
            try {
                this.resourceManager.registerResourceChangeListener(new ResourceChangeListener() {

                    @Override
                    public void handleChanges(Collection<ResourceChangeEvent> changes) {
                        for (ResourceChangeEvent change : changes) {
                            // only check added or removed resources that can change welcome files
                            if (change.getType() != ResourceChangeEvent.Type.MODIFIED) {
                                String path = "/" + change.getResource();
                                // remove direct match and directory match
                                pathMatchCacheResources.remove(path);
                                pathMatchCacheResources.remove(path + "/");
                                // check welcome pages
                                for (String welcomePage: welcomePages) {
                                    if (path.endsWith("/" + welcomePage)) {
                                        String pathToUpdate = path.substring(0, path.length() - welcomePage.length());
                                        pathMatchCacheResources.remove(pathToUpdate);
                                    }
                                }
                            }
                        }
                    }
                });
            } catch (Exception e) {
                UndertowLogger.ROOT_LOGGER.couldNotRegisterChangeListener(e);
            }
        }
    }

    public void initData(){
        getData();
    }

    public ServletChain getServletHandlerByName(final String name) {
        return getData().getServletHandlerByName(name);
    }

    public ServletPathMatch getServletHandlerByPath(final String path) {
        ServletPathMatch existing = pathMatchCacheFixed.get(path);
        if (existing == null) {
            existing = pathMatchCacheResources.get(path);
        }
        if(existing != null) {
            return existing;
        }

        ServletPathMatch match = getData().getServletHandlerByPath(path);
        if (!match.isRequiredWelcomeFileMatch()) {
            pathMatchCacheFixed.add(path, match);
            return match;
        }
        try {

            String remaining = match.getRemaining() == null ? match.getMatched() : match.getRemaining();
            Resource resource = resourceManager.getResource(remaining);
            if (resource == null || !resource.isDirectory()) {
                pathMatchCacheResources.add(path, match);
                return match;
            }

            boolean pathEndsWithSlash = remaining.endsWith("/");
            final String pathWithTrailingSlash = pathEndsWithSlash ? remaining : remaining + "/";

            ServletPathMatch welcomePage = findWelcomeFile(pathWithTrailingSlash, !pathEndsWithSlash);

            if (welcomePage != null) {
                pathMatchCacheResources.add(path, welcomePage);
                return welcomePage;
            } else {
                welcomePage = findWelcomeServlet(pathWithTrailingSlash, !pathEndsWithSlash);
                if (welcomePage != null) {
                    pathMatchCacheResources.add(path, welcomePage);
                    return welcomePage;
                } else if(pathEndsWithSlash) {
                    pathMatchCacheResources.add(path, match);
                    return match;
                } else {
                    ServletPathMatch redirect = new ServletPathMatch(match.getServletChain(), match.getMatched(), match.getRemaining(), REDIRECT, "/");
                    pathMatchCacheResources.add(path, redirect);
                    return redirect;
                }
            }

        } catch (IOException e) {
            throw new RuntimeException(e);
        }

    }

    public void invalidate() {
        this.data = null;
        this.pathMatchCacheResources.clear();
        this.pathMatchCacheFixed.clear();
    }

    private ServletPathMatchesData getData() {
        ServletPathMatchesData data = this.data;
        if (data != null) {
            return data;
        }
        synchronized (this) {
            if (this.data != null) {
                return this.data;
            }
            return this.data = setupServletChains();
        }
    }

    private ServletPathMatch findWelcomeFile(final String path, boolean requiresRedirect) {
        if(File.separatorChar != '/' && path.contains(File.separator)) {
            return null;
        }
        StringBuilder sb = new StringBuilder();
        for (String i : welcomePages) {
            try {
                sb.append(path);
                sb.append(i);
                final String mergedPath = sb.toString();
                sb.setLength(0);
                Resource resource = resourceManager.getResource(mergedPath);
                if (resource != null) {
                    final ServletPathMatch handler = data.getServletHandlerByPath(mergedPath);
                    return new ServletPathMatch(handler.getServletChain(), mergedPath, null, requiresRedirect ? REDIRECT : REWRITE, mergedPath);
                }
            } catch (IOException e) {
            }
        }
        return null;
    }

    private ServletPathMatch findWelcomeServlet(final String path, boolean requiresRedirect) {
        StringBuilder sb = new StringBuilder();
        for (String i : welcomePages) {
            sb.append(path);
            sb.append(i);
            final String mergedPath = sb.toString();
            sb.setLength(0);
            final ServletPathMatch handler = data.getServletHandlerByPath(mergedPath);
            if (handler != null && !handler.isRequiredWelcomeFileMatch()) {
                return new ServletPathMatch(handler.getServletChain(), handler.getMatched(), handler.getRemaining(), requiresRedirect ? REDIRECT : REWRITE, mergedPath);
            }
        }
        return null;
    }

    public void setWelcomePages(List<String> welcomePages) {
        this.welcomePages = welcomePages.toArray(new String[welcomePages.size()]);
    }

    /**
     * Sets up the handlers in the servlet chain. We setup a chain for every path + extension match possibility.
     * (i.e. if there a m path mappings and n extension mappings we have n*m chains).
     * <p>
     * If a chain consists of only the default servlet then we add it as an async handler, so that resources can be
     * served up directly without using blocking operations.
     * <p>
     * TODO: this logic is a bit convoluted at the moment, we should look at simplifying it
     */
    private ServletPathMatchesData setupServletChains() {
        //create the default servlet
        ServletHandler defaultServlet = null;
        final ManagedServlets servlets = deployment.getServlets();
        final ManagedFilters filters = deployment.getFilters();

        final Map<String, ServletHandler> extensionServlets = new HashMap<>();
        final Map<String, ServletHandler> pathServlets = new HashMap<>();

        final Set<String> pathMatches = new HashSet<>();
        final Set<String> extensionMatches = new HashSet<>();

        DeploymentInfo deploymentInfo = deployment.getDeploymentInfo();

        //loop through all filter mappings, and add them to the set of known paths
        for (FilterMappingInfo mapping : deploymentInfo.getFilterMappings()) {
            if (mapping.getMappingType() == FilterMappingInfo.MappingType.URL) {
                String path = mapping.getMapping();
                if (path.equals("*")) {
                    //UNDERTOW-95, support this non-standard filter mapping
                    path = "/*";
                }
                if (!path.startsWith("*.")) {
                    pathMatches.add(path);
                } else {
                    extensionMatches.add(path.substring(2));
                }
            }
        }

        //now loop through all servlets.
        for (Map.Entry<String, ServletHandler> entry : servlets.getServletHandlers().entrySet()) {
            final ServletHandler handler = entry.getValue();
            //add the servlet to the appropriate path maps
            for (String path : handler.getManagedServlet().getServletInfo().getMappings()) {
                if (path.equals("/")) {
                    //the default servlet
                    pathMatches.add("/*");
                    if (defaultServlet != null) {
                        throw UndertowServletMessages.MESSAGES.twoServletsWithSameMapping(path);
                    }
                    defaultServlet = handler;
                } else if (!path.startsWith("*.")) {
                    //either an exact or a /* based path match
                    if (path.isEmpty()) {
                        path = "/";
                    }
                    pathMatches.add(path);
                    if (pathServlets.containsKey(path)) {
                        throw UndertowServletMessages.MESSAGES.twoServletsWithSameMapping(path);
                    }
                    pathServlets.put(path, handler);
                } else {
                    //an extension match based servlet
                    String ext = path.substring(2);
                    extensionMatches.add(ext);
                    if(extensionServlets.containsKey(ext)) {
                        throw UndertowServletMessages.MESSAGES.twoServletsWithSameMapping(path);
                    }
                    extensionServlets.put(ext, handler);
                }
            }
        }
        ServletHandler managedDefaultServlet = servlets.getServletHandler(DEFAULT_SERVLET_NAME);
        if(managedDefaultServlet == null) {
            //we always create a default servlet, even if it is not going to have any path mappings registered
            managedDefaultServlet = servlets.addServlet(new ServletInfo(DEFAULT_SERVLET_NAME, DefaultServlet.class));
        }

        if (defaultServlet == null) {
            //no explicit default servlet was specified, so we register our mapping
            pathMatches.add("/*");
            defaultServlet = managedDefaultServlet;
        }

        final ServletPathMatchesData.Builder builder = ServletPathMatchesData.builder();

        //we now loop over every path in the application, and build up the patches based on this path
        //these paths contain both /* and exact matches.
        for (final String path : pathMatches) {
            //resolve the target servlet, will return null if this is the default servlet
            MatchData targetServletMatch = resolveServletForPath(path, pathServlets, extensionServlets, defaultServlet);

            final Map<DispatcherType, List<ManagedFilter>> noExtension = new EnumMap<>(DispatcherType.class);
            final Map<String, Map<DispatcherType, List<ManagedFilter>>> extension = new HashMap<>();
            //initialize the extension map. This contains all the filers in the noExtension map, plus
            //any filters that match the extension key
            for (String ext : extensionMatches) {
                extension.put(ext, new EnumMap<>(DispatcherType.class));
            }

            //loop over all the filters, and add them to the appropriate map in the correct order
            for (final FilterMappingInfo filterMapping : deploymentInfo.getFilterMappings()) {
                ManagedFilter filter = filters.getManagedFilter(filterMapping.getFilterName());
                if (filterMapping.getMappingType() == FilterMappingInfo.MappingType.SERVLET) {
                    if (targetServletMatch.handler != null) {
                        if (filterMapping.getMapping().equals(targetServletMatch.handler.getManagedServlet().getServletInfo().getName()) || filterMapping.getMapping().equals("*")) {
                            addToListMap(noExtension, filterMapping.getDispatcher(), filter);
                        }
                    }
                    for (Map.Entry<String, Map<DispatcherType, List<ManagedFilter>>> entry : extension.entrySet()) {
                        ServletHandler pathServlet = targetServletMatch.handler;
                        boolean defaultServletMatch = targetServletMatch.defaultServlet;
                        if (defaultServletMatch && extensionServlets.containsKey(entry.getKey())) {
                            pathServlet = extensionServlets.get(entry.getKey());
                        }

                        if (filterMapping.getMapping().equals(pathServlet.getManagedServlet().getServletInfo().getName()) || filterMapping.getMapping().equals("*")) {
                            addToListMap(extension.get(entry.getKey()), filterMapping.getDispatcher(), filter);
                        }
                    }
                } else {
                    if (filterMapping.getMapping().isEmpty() || !filterMapping.getMapping().startsWith("*.")) {
                        if (isFilterApplicable(path, filterMapping.getMapping())) {
                            addToListMap(noExtension, filterMapping.getDispatcher(), filter);
                            for (Map<DispatcherType, List<ManagedFilter>> l : extension.values()) {
                                addToListMap(l, filterMapping.getDispatcher(), filter);
                            }
                        }
                    } else {
                        addToListMap(extension.get(filterMapping.getMapping().substring(2)), filterMapping.getDispatcher(), filter);
                    }
                }
            }
            //resolve any matches and add them to the builder
            if (path.endsWith("/*")) {
                String prefix = path.substring(0, path.length() - 2);
                //add the default non-extension match
                builder.addPrefixMatch(prefix, createHandler(deploymentInfo, targetServletMatch.handler, noExtension, targetServletMatch.matchedPath, targetServletMatch.defaultServlet, targetServletMatch.mappingMatch, targetServletMatch.userPath), targetServletMatch.defaultServlet || targetServletMatch.handler.getManagedServlet().getServletInfo().isRequireWelcomeFileMapping());

                //build up the chain for each non-extension match
                for (Map.Entry<String, Map<DispatcherType, List<ManagedFilter>>> entry : extension.entrySet()) {
                    ServletHandler pathServlet = targetServletMatch.handler;
                    String pathMatch = targetServletMatch.matchedPath;

                    final boolean defaultServletMatch;
                    final String servletMatchPattern;
                    final MappingMatch mappingMatch;
                    if (targetServletMatch.defaultServlet) {
                        // Path matches always take precedence over extension matches, however the default servlet is matched
                        // at a lower priority, after extension matches. The "/*" pattern is applied implicitly onto the
                        // default servlet. If there's an extension match in addition to a non-default servlet path match,
                        // the servlet path match is higher priority. However if the path match is the default servlets
                        // default catch-all path, the extension match is a higher priority.
                        ServletHandler extensionServletHandler = extensionServlets.get(entry.getKey());
                        if (extensionServletHandler != null) {
                            defaultServletMatch = false;
                            pathServlet = extensionServletHandler;
                            servletMatchPattern = "*." + entry.getKey();
                            mappingMatch = MappingMatch.EXTENSION;
                        } else {
                            defaultServletMatch = true;
                            servletMatchPattern = "/";
                            mappingMatch = MappingMatch.DEFAULT;
                        }
                    } else {
                        defaultServletMatch = false;
                        servletMatchPattern = path;
                        mappingMatch = MappingMatch.PATH;
                    }
                    HttpHandler handler = pathServlet;
                    if (!entry.getValue().isEmpty()) {
                        handler = new FilterHandler(entry.getValue(), deploymentInfo.isAllowNonStandardWrappers(), handler);
                    }
                    builder.addExtensionMatch(prefix, entry.getKey(), servletChain(handler, pathServlet.getManagedServlet(), entry.getValue(), pathMatch, deploymentInfo, defaultServletMatch, mappingMatch, servletMatchPattern));
                }
            } else if (path.isEmpty()) {
                //the context root match
                builder.addExactMatch("/", createHandler(deploymentInfo, targetServletMatch.handler, noExtension, targetServletMatch.matchedPath, targetServletMatch.defaultServlet, targetServletMatch.mappingMatch, targetServletMatch.userPath));
            } else {
                //we need to check for an extension match, so paths like /exact.txt will have the correct filter applied
                int lastSegmentIndex = path.lastIndexOf('/');
                String lastSegment;
                if(lastSegmentIndex > 0) {
                    lastSegment = path.substring(lastSegmentIndex);
                } else {
                    lastSegment = path;
                }
                if (lastSegment.contains(".")) {
                    String ext = lastSegment.substring(lastSegment.lastIndexOf('.') + 1);
                    if (extension.containsKey(ext)) {
                        Map<DispatcherType, List<ManagedFilter>> extMap = extension.get(ext);
                        builder.addExactMatch(path, createHandler(deploymentInfo, targetServletMatch.handler, extMap, targetServletMatch.matchedPath, targetServletMatch.defaultServlet, targetServletMatch.mappingMatch, targetServletMatch.userPath));
                    } else {
                        builder.addExactMatch(path, createHandler(deploymentInfo, targetServletMatch.handler, noExtension, targetServletMatch.matchedPath, targetServletMatch.defaultServlet, targetServletMatch.mappingMatch, targetServletMatch.userPath));
                    }
                } else {
                    builder.addExactMatch(path, createHandler(deploymentInfo, targetServletMatch.handler, noExtension, targetServletMatch.matchedPath, targetServletMatch.defaultServlet, targetServletMatch.mappingMatch, targetServletMatch.userPath));
                }

            }
        }

        //now setup name based mappings
        //these are used for name based dispatch
        for (Map.Entry<String, ServletHandler> entry : servlets.getServletHandlers().entrySet()) {
            final Map<DispatcherType, List<ManagedFilter>> filtersByDispatcher = new EnumMap<>(DispatcherType.class);
            for (final FilterMappingInfo filterMapping : deploymentInfo.getFilterMappings()) {
                ManagedFilter filter = filters.getManagedFilter(filterMapping.getFilterName());
                if (filterMapping.getMappingType() == FilterMappingInfo.MappingType.SERVLET) {
                    if (filterMapping.getMapping().equals(entry.getKey())) {
                        addToListMap(filtersByDispatcher, filterMapping.getDispatcher(), filter);
                    }
                }
            }
            if (filtersByDispatcher.isEmpty()) {
                builder.addNameMatch(entry.getKey(), servletChain(entry.getValue(), entry.getValue().getManagedServlet(), filtersByDispatcher, null, deploymentInfo, false, MappingMatch.EXACT, ""));
            } else {
                builder.addNameMatch(entry.getKey(), servletChain(new FilterHandler(filtersByDispatcher, deploymentInfo.isAllowNonStandardWrappers(), entry.getValue()), entry.getValue().getManagedServlet(), filtersByDispatcher, null, deploymentInfo, false, MappingMatch.EXACT, ""));
            }
        }

        return builder.build();
    }

    private ServletChain createHandler(final DeploymentInfo deploymentInfo, final ServletHandler targetServlet, final Map<DispatcherType, List<ManagedFilter>> noExtension, final String servletPath, final boolean defaultServlet, MappingMatch mappingMatch, String pattern) {
        final ServletChain initialHandler;
        if (noExtension.isEmpty()) {
            initialHandler = servletChain(targetServlet, targetServlet.getManagedServlet(), noExtension, servletPath, deploymentInfo, defaultServlet, mappingMatch, pattern);
        } else {
            FilterHandler handler = new FilterHandler(noExtension, deploymentInfo.isAllowNonStandardWrappers(), targetServlet);
            initialHandler = servletChain(handler, targetServlet.getManagedServlet(), noExtension, servletPath, deploymentInfo, defaultServlet, mappingMatch, pattern);
        }
        return initialHandler;
    }

    private static MatchData resolveServletForPath(final String path, final Map<String, ServletHandler> pathServlets, final Map<String, ServletHandler> extensionServlets, ServletHandler defaultServlet) {
        if (pathServlets.containsKey(path)) {
            if (path.endsWith("/*")) {
                final String base = path.substring(0, path.length() - 2);
                return new MatchData(pathServlets.get(path), base, path, MappingMatch.PATH, false);
            } else {
                if(path.equals("/")) {
                    return new MatchData(pathServlets.get(path), path, "", MappingMatch.CONTEXT_ROOT, false);
                }
                return new MatchData(pathServlets.get(path), path, path, MappingMatch.EXACT, false);
            }
        }
        String match = null;
        ServletHandler servlet = null;
        String userPath = "";
        for (final Map.Entry<String, ServletHandler> entry : pathServlets.entrySet()) {
            String key = entry.getKey();
            if (key.endsWith("/*")) {
                final String base = key.substring(0, key.length() - 1);
                if (match == null || base.length() > match.length()) {
                    if (path.startsWith(base) || path.equals(base.substring(0, base.length() - 1))) {
                        match = base.substring(0, base.length() - 1);
                        servlet = entry.getValue();
                        userPath = key;
                    }
                }
            }
        }
        if (servlet != null) {
            return new MatchData(servlet, match, userPath, MappingMatch.PATH, false);
        }
        int index = path.lastIndexOf('.');
        if (index != -1) {
            String ext = path.substring(index + 1);
            servlet = extensionServlets.get(ext);
            if (servlet != null) {
                return new MatchData(servlet, null, "*." + ext, MappingMatch.EXTENSION, false);
            }
        }

        return new MatchData(defaultServlet, null, "/", MappingMatch.DEFAULT, true);
    }

    private static boolean isFilterApplicable(final String path, final String filterPath) {
        String modifiedPath;
        if (filterPath.equals("*")) {
            modifiedPath = "/*";
        } else {
            modifiedPath = filterPath;
        }
        if (path.isEmpty()) {
            return modifiedPath.equals("/*") || modifiedPath.equals("/");
        }
        if (modifiedPath.endsWith("/*")) {
            String baseFilterPath = modifiedPath.substring(0, modifiedPath.length() - 1);
            String exactFilterPath = modifiedPath.substring(0, modifiedPath.length() - 2);
            return path.startsWith(baseFilterPath) || path.equals(exactFilterPath);
        } else {
            return modifiedPath.equals(path);
        }
    }

    private static <K, V> void addToListMap(final Map<K, List<V>> map, final K key, final V value) {
        List<V> list = map.get(key);
        if (list == null) {
            map.put(key, list = new ArrayList<>());
        }
        list.add(value);
    }

    private static ServletChain servletChain(HttpHandler next, final ManagedServlet managedServlet, Map<DispatcherType, List<ManagedFilter>> filters, final String servletPath, final DeploymentInfo deploymentInfo, boolean defaultServlet, MappingMatch mappingMatch, String pattern) {
        HttpHandler servletHandler = next;
        if(!deploymentInfo.isSecurityDisabled()) {
            servletHandler = new ServletSecurityRoleHandler(servletHandler, deploymentInfo.getAuthorizationManager());
        }
        servletHandler = wrapHandlers(servletHandler, managedServlet.getServletInfo().getHandlerChainWrappers());
        return new ServletChain(servletHandler, managedServlet, servletPath, defaultServlet, mappingMatch, pattern, filters);
    }

    private static HttpHandler wrapHandlers(final HttpHandler wrapee, final List<HandlerWrapper> wrappers) {
        HttpHandler current = wrapee;
        for (HandlerWrapper wrapper : wrappers) {
            current = wrapper.wrap(current);
        }
        return current;
    }

    private static class MatchData {
        final ServletHandler handler;
        final String matchedPath;
        final String userPath;
        final MappingMatch mappingMatch;
        final boolean defaultServlet;

        private MatchData(final ServletHandler handler, final String matchedPath, String userPath, MappingMatch mappingMatch, boolean defaultServlet) {
            this.handler = handler;
            this.matchedPath = matchedPath;
            this.userPath = userPath;
            this.mappingMatch = mappingMatch;
            this.defaultServlet = defaultServlet;
        }
    }
}
