/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. The ASF licenses this file
 * to you 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.apache.meecrowave.cxf;

import org.apache.cxf.cdi.CXFCdiServlet;
import org.apache.cxf.common.util.ReflectionUtil;
import org.apache.cxf.endpoint.Endpoint;
import org.apache.cxf.jaxrs.JAXRSServiceFactoryBean;
import org.apache.cxf.jaxrs.model.ApplicationInfo;
import org.apache.cxf.jaxrs.model.ClassResourceInfo;
import org.apache.cxf.jaxrs.model.MethodDispatcher;
import org.apache.cxf.jaxrs.model.OperationResourceInfo;
import org.apache.cxf.jaxrs.model.ProviderInfo;
import org.apache.cxf.jaxrs.provider.ProviderFactory;
import org.apache.cxf.jaxrs.provider.ServerProviderFactory;
import org.apache.cxf.service.model.EndpointInfo;
import org.apache.cxf.transport.ChainInitiationObserver;
import org.apache.cxf.transport.http.DestinationRegistry;
import org.apache.cxf.transport.servlet.ServletDestination;
import org.apache.meecrowave.Meecrowave;
import org.apache.meecrowave.configuration.Configuration;
import org.apache.meecrowave.logging.tomcat.LogFacade;

import javax.servlet.DispatcherType;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.FilterRegistration;
import javax.servlet.ServletConfig;
import javax.servlet.ServletContainerInitializer;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.ws.rs.core.Application;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Enumeration;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Stream;

import static java.util.Optional.ofNullable;

// this look a bit complicated but it just:
// - wraps cxf in a filter to support plain resources when not conflicting with application path
// - logs resources
public class CxfCdiAutoSetup implements ServletContainerInitializer {
    private static final String NAME = "cxf-cdi";

    @Override
    public void onStartup(final Set<Class<?>> c, final ServletContext ctx) {
        final Configuration builder = Configuration.class.cast(ctx.getAttribute("meecrowave.configuration"));
        final MeecrowaveCXFCdiServlet delegate = new MeecrowaveCXFCdiServlet();
        final FilterRegistration.Dynamic jaxrs = ctx.addFilter(NAME, new Filter() {
            private final String servletPath = builder.getJaxrsMapping().endsWith("/*") ?
                    builder.getJaxrsMapping().substring(0, builder.getJaxrsMapping().length() - 2) : builder.getJaxrsMapping();

            @Override
            public void init(final FilterConfig filterConfig) throws ServletException {
                delegate.init(new ServletConfig() {
                    @Override
                    public String getServletName() {
                        return NAME;
                    }

                    @Override
                    public ServletContext getServletContext() {
                        return filterConfig.getServletContext();
                    }

                    @Override
                    public String getInitParameter(final String name) {
                        return filterConfig.getInitParameter(name);
                    }

                    @Override
                    public Enumeration<String> getInitParameterNames() {
                        return filterConfig.getInitParameterNames();
                    }
                });
            }

            @Override
            public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain) throws IOException, ServletException {
                if (!HttpServletRequest.class.isInstance(request)) {
                    chain.doFilter(request, response);
                    return;
                }
                final HttpServletRequest http = HttpServletRequest.class.cast(request);
                final String path = http.getRequestURI().substring(http.getContextPath().length());
                final Optional<String> app = Stream.of(delegate.prefixes).filter(path::startsWith).findAny();
                if (app.isPresent()) {
                    delegate.service(new HttpServletRequestWrapper(http) { // fake servlet pathInfo and path
                        @Override
                        public String getPathInfo() {
                            return path;
                        }

                        @Override
                        public String getServletPath() {
                            return servletPath;
                        }
                    }, response);
                } else {
                    chain.doFilter(request, response);
                }
            }

            @Override
            public void destroy() {
                delegate.destroy();
            }
        });
        jaxrs.setAsyncSupported(true);
        jaxrs.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC), true, builder.getJaxrsMapping());
        ofNullable(builder.getCxfServletParams()).ifPresent(m -> m.forEach(jaxrs::setInitParameter));
    }

    private static class Logs {
        private Logs() {
            // no-op
        }

        private static String forceLength(final String httpMethod, final int l, final boolean right) {
            final String http;
            if (httpMethod == null) { // subresourcelocator implies null http method
                http = "";
            } else {
                http = httpMethod;
            }

            final StringBuilder builder = new StringBuilder();
            if (!right) {
                for (int i = 0; i < l - http.length(); i++) {
                    builder.append(" ");
                }
            }
            builder.append(http);
            if (right) {
                for (int i = 0; i < l - http.length(); i++) {
                    builder.append(" ");
                }
            }
            return builder.toString();
        }

        private static String toSimpleString(final Method mtd) {
            try {
                final StringBuilder sb = new StringBuilder();
                final Type[] typeparms = mtd.getTypeParameters();
                if (typeparms.length > 0) {
                    boolean first = true;
                    sb.append("<");
                    for (Type typeparm : typeparms) {
                        if (!first) {
                            sb.append(",");
                        }
                        sb.append(name(typeparm));
                        first = false;
                    }
                    sb.append("> ");
                }

                final Type genRetType = mtd.getGenericReturnType();
                sb.append(name(genRetType)).append(" ");
                sb.append(mtd.getName()).append("(");
                final Type[] params = mtd.getGenericParameterTypes();
                for (int j = 0; j < params.length; j++) {
                    sb.append(name(params[j]));
                    if (j < (params.length - 1)) {
                        sb.append(", ");
                    }
                }
                sb.append(")");
                final Type[] exceptions = mtd.getGenericExceptionTypes();
                if (exceptions.length > 0) {
                    sb.append(" throws ");
                    for (int k = 0; k < exceptions.length; k++) {
                        sb.append(name(exceptions[k]));
                        if (k < (exceptions.length - 1)) {
                            sb.append(",");
                        }
                    }
                }
                return sb.toString();
            } catch (final Exception e) {
                return "<" + e + ">";
            }
        }

        private static String name(final Type type) {
            if (type instanceof Class<?>) {
                return ((Class) type).getSimpleName().replace("java.lang.", "").replace("java.util", "");
            } else if (type instanceof ParameterizedType) {
                final ParameterizedType pt = (ParameterizedType) type;
                final StringBuilder builder = new StringBuilder();
                builder.append(name(pt.getRawType()));
                final Type[] args = pt.getActualTypeArguments();
                if (args != null) {
                    builder.append("<");
                    for (int i = 0; i < args.length; i++) {
                        builder.append(name(args[i]));
                        if (i < args.length - 1) {
                            builder.append(", ");
                        }
                    }
                    builder.append(">");
                }
                return builder.toString();
            }
            return type.toString();
        }

        private static String singleSlash(final String address, final String value) {
            if (address.endsWith("/") && value.startsWith("/")) {
                return address + value.substring(1);
            }
            if (!address.endsWith("/") && !value.startsWith("/")) {
                return address + '/' + value;
            }
            if ("/".equals(value)) {
                return address;
            }
            return address + value;
        }

        private static class LogOperationEndpointInfo implements Comparable<LogOperationEndpointInfo> {
            private final String http;
            private final String address;
            private final String method;

            private LogOperationEndpointInfo(final String http, final String address, final String method) {
                this.address = address;
                this.method = method;

                if (http != null) {
                    this.http = http;
                } else { // can happen with subresource locators
                    this.http = "";
                }
            }

            @Override
            public int compareTo(final LogOperationEndpointInfo o) {
                int compare = http.compareTo(o.http);
                if (compare != 0) {
                    return compare;
                }

                compare = address.compareTo(o.address);
                if (compare != 0) {
                    return compare;
                }

                return method.compareTo(o.method);
            }
        }

        private static class LogResourceEndpointInfo implements Comparable<LogResourceEndpointInfo> {
            private final String address;
            private final String classname;
            private final List<LogOperationEndpointInfo> operations;
            private final int methodSize;
            private final int methodStrSize;

            private LogResourceEndpointInfo(final String address, final String classname,
                                            final List<LogOperationEndpointInfo> operations,
                                            final int methodSize, final int methodStrSize) {
                this.address = address;
                this.classname = classname;
                this.operations = operations;
                this.methodSize = methodSize;
                this.methodStrSize = methodStrSize;
            }

            @Override
            public int compareTo(final LogResourceEndpointInfo o) {
                final int compare = address.compareTo(o.address);
                if (compare != 0) {
                    return compare;
                }
                return classname.compareTo(o.classname);
            }
        }
    }

    private static class MeecrowaveCXFCdiServlet extends CXFCdiServlet {
        private String[] prefixes;

        @Override
        public void init(final ServletConfig sc) throws ServletException {
            super.init(sc);

            // just logging the endpoints
            final LogFacade log = new LogFacade(CxfCdiAutoSetup.class.getName());
            final String transportId = sc.getInitParameter(TRANSPORT_ID);
            final DestinationRegistry registry = getDestinationRegistryFromBusOrDefault(transportId);
            prefixes = registry.getDestinations().stream()
                    .filter(ServletDestination.class::isInstance)
                    .map(ServletDestination.class::cast)
                    .map(getServletDestinationPath(sc, log))
                    .filter(Objects::nonNull)
                    .toArray(String[]::new);
        }

        private Function<ServletDestination, String> getServletDestinationPath(ServletConfig sc, LogFacade log)
        {
            return sd -> {
                final Endpoint endpoint = ChainInitiationObserver.class.cast(sd.getMessageObserver()).getEndpoint();
                final ApplicationInfo app = ApplicationInfo.class.cast(endpoint.get(Application.class.getName()));
                final JAXRSServiceFactoryBean sfb = JAXRSServiceFactoryBean.class.cast(endpoint.get(JAXRSServiceFactoryBean.class.getName()));
                final String base = sd.getEndpointInfo().getAddress();

                if (sfb != null) {
                    final List<Logs.LogResourceEndpointInfo> resourcesToLog = new ArrayList<>();
                    int classSize = 0;
                    int addressSize = 0;

                    final List<ClassResourceInfo> resources = sfb.getClassResourceInfo();
                    for (final ClassResourceInfo info : resources) {
                        if (info.getResourceClass() == null) { // possible?
                            continue;
                        }

                        final String address = Logs.singleSlash(base, info.getURITemplate().getValue());

                        final String clazz = uproxyName(info.getResourceClass().getName());
                        classSize = Math.max(classSize, clazz.length());
                        addressSize = Math.max(addressSize, address.length());

                        int methodSize = 7;
                        int methodStrSize = 0;

                        final List<Logs.LogOperationEndpointInfo> toLog = new ArrayList<>();

                        final MethodDispatcher md = info.getMethodDispatcher();
                        for (final OperationResourceInfo ori : md.getOperationResourceInfos()) {
                            final String httpMethod = ori.getHttpMethod();
                            final String currentAddress = Logs.singleSlash(address, ori.getURITemplate().getValue());
                            final String methodToStr = Logs.toSimpleString(ori.getMethodToInvoke());
                            toLog.add(new Logs.LogOperationEndpointInfo(httpMethod, currentAddress, methodToStr));

                            if (httpMethod != null) {
                                methodSize = Math.max(methodSize, httpMethod.length());
                            }
                            addressSize = Math.max(addressSize, currentAddress.length());
                            methodStrSize = Math.max(methodStrSize, methodToStr.length());
                        }

                        Collections.sort(toLog);

                        resourcesToLog.add(new Logs.LogResourceEndpointInfo(address, clazz, toLog, methodSize, methodStrSize));
                    }

                    // effective logging
                    log.info("REST Application: " + endpoint.getEndpointInfo().getAddress() + " -> "
                            + ofNullable(app).map(ApplicationInfo::getResourceClass).map(Class::getName).map(CxfCdiAutoSetup::uproxyName).orElse(""));

                    Collections.sort(resourcesToLog);
                    final int fClassSize = classSize;
                    final int fAddressSize = addressSize;
                    resourcesToLog.forEach(resource -> {
                        log.info("     Service URI: "
                                + Logs.forceLength(resource.address, fAddressSize, true) + " -> "
                                + Logs.forceLength(resource.classname, fClassSize, true));

                        resource.operations.forEach(info -> {
                            log.info("          "
                                    + Logs.forceLength(info.http, resource.methodSize, false) + " "
                                    + Logs.forceLength(info.address, fAddressSize, true) + " ->      "
                                    + Logs.forceLength(info.method, resource.methodStrSize, true));
                        });
                        resource.operations.clear();
                    });
                    resourcesToLog.clear();

                    // log @Providers
                    if (Configuration.class.cast(sc.getServletContext().getAttribute("meecrowave.configuration")).isJaxrsLogProviders()) {
                        final ServerProviderFactory spf = ServerProviderFactory.class.cast(endpoint.get(ServerProviderFactory.class.getName()));
                        dump(log, spf, "MessageBodyReaders", "messageReaders");
                        dump(log, spf, "MessageBodyWriters", "messageWriters");
                    }
                } else {
                    final EndpointInfo endpointInfo = endpoint.getEndpointInfo();
                    if (endpointInfo.getName() != null) {
                        log.info("@WebService > " + endpointInfo.getName().toString() + " -> " + base);
                    }
                }

                return base;
            };
        }

        private void dump(final LogFacade log, final ServerProviderFactory spf, final String description, final String fieldName) {
            final Field field = ReflectionUtil.getDeclaredField(ProviderFactory.class, fieldName);
            if (!field.isAccessible()) {
                field.setAccessible(true);
            }
            try {
                final Collection<ProviderInfo<?>> providers = Collection.class.cast(field.get(spf));
                log.info("     " + description);
                providers.stream().map(ProviderInfo::getProvider).forEach(o -> {
                    try {
                        log.info("       - " + o);
                    } catch (final RuntimeException re) {
                        // no-op: maybe cdi context is not active
                    }
                });
            } catch (IllegalAccessException e) {
                // ignore, not that a big deal
            }
        }
    }

    private static String uproxyName(final String clazz) {
        if (clazz.contains("$$")) {
            return clazz.substring(0, clazz.indexOf("$$"));
        }
        return clazz;
    }
}