/**
 * 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.jooby.spec;

import static java.util.Objects.requireNonNull;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.lang.reflect.Type;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;

import org.jooby.Env;
import org.jooby.Jooby;
import org.jooby.Request;
import org.jooby.Response;
import org.jooby.Route;
import org.jooby.Session;
import org.jooby.internal.RouteMetadata;
import org.jooby.internal.mvc.RequestParamNameProviderImpl;
import org.jooby.internal.spec.AppCollector;
import org.jooby.internal.spec.Context;
import org.jooby.internal.spec.ContextImpl;
import org.jooby.internal.spec.DocCollector;
import org.jooby.internal.spec.ResponseTypeCollector;
import org.jooby.internal.spec.RouteCollector;
import org.jooby.internal.spec.RouteParamCollector;
import org.jooby.internal.spec.RouteParamImpl;
import org.jooby.internal.spec.RouteResponseImpl;
import org.jooby.internal.spec.RouteSpecImpl;
import org.jooby.internal.spec.SourceResolver;
import org.jooby.internal.spec.SourceResolverImpl;
import org.jooby.internal.spec.TypeFromDoc;
import org.jooby.internal.spec.TypeResolverImpl;
import org.jooby.mvc.Body;
import org.jooby.mvc.Flash;
import org.jooby.mvc.Header;
import org.jooby.mvc.Local;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.github.javaparser.JavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.Node;
import com.google.common.base.Throwables;
import com.google.common.collect.Sets;
import com.typesafe.config.ConfigFactory;

/**
 * <p>
 * Process and collect {@link RouteSpec} from {@link Jooby} app.
 * </p>
 *
 * @author edgar
 * @since 0.15.0
 */
public class RouteProcessor {

  /** SKIP MVC parameters of type: */
  @SuppressWarnings("rawtypes")
  private static final Set<Class> SKIP = Sets.newHashSet(Request.class, Response.class,
      Session.class, Route.class, Route.Chain.class);

  /** The logging system. */
  private final Logger log = LoggerFactory.getLogger(getClass());

  /**
   * Process a {@link Jooby} application and collect {@link RouteSpec}.
   *
   * @param app A jooby app to process.
   * @return List of route specs.
   */
  public List<RouteSpec> process(final Jooby app) {
    requireNonNull(app, "App is required.");
    return process(app, new File(System.getProperty("user.dir")).toPath());
  }

  /**
   * Process a {@link Jooby} application and collect {@link RouteSpec}.
   *
   * @param app A jooby app to process.
   * @param srcdir Basedir where source code is located. Useful for extracting doc.
   * @return List of route specs.
   */
  public List<RouteSpec> process(final Jooby app, final Path srcdir) {
    return processInternal(app, srcdir, null);
  }

  /**
   * Process a {@link Jooby} application and collect {@link RouteSpec}, but also save a compiled
   * version in the given outdir.
   *
   * @param app A jooby app to process.
   * @param srcdir Basedir where source code is located. Useful for extracting doc.
   * @param outdir Where to save the compiled route specs.
   * @return List of route specs.
   */
  public List<RouteSpec> compile(final Jooby app, final Path srcdir, final Path outdir) {
    requireNonNull(app, "App is required.");
    requireNonNull(srcdir, "Source dir is required.");
    requireNonNull(outdir, "Out dir is required.");

    return processInternal(app, srcdir, outdir);
  }

  /**
   * Process a {@link Jooby} application and collect {@link RouteSpec}.
   *
   * @param appClass A jooby class to process.
   * @param routes Routes to process.
   * @return List of route specs.
   */
  public List<RouteSpec> process(final Class<? extends Jooby> appClass,
      final List<Route.Definition> routes) {
    requireNonNull(appClass, "App class is required.");
    requireNonNull(routes, "Routes are required.");
    return processInternal(appClass, routes, new File(System.getProperty("user.dir")).toPath(),
        null);
  }

  /**
   * Process a {@link Jooby} application and collect {@link RouteSpec}.
   *
   * @param appClass A jooby class to process.
   * @param routes Routes to process.
   * @param srcdir Basedir where source code is located. Useful for extracting doc.
   * @return List of route specs.
   */
  public List<RouteSpec> process(final Class<? extends Jooby> appClass,
      final List<Route.Definition> routes, final Path srcdir) {
    requireNonNull(appClass, "App class is required.");
    requireNonNull(routes, "Routes are required.");
    requireNonNull(srcdir, "Source dir is required.");
    return processInternal(appClass, routes, srcdir, null);
  }

  /**
   * Process a {@link Jooby} application and collect {@link RouteSpec}.
   *
   * @param appClass A jooby app to process.
   * @param routes Routes to process.
   * @param srcdir Basedir where source code is located. Useful for extracting doc.
   * @param outdir Strategy where to save the compiled route specs.
   * @return List of route specs.
   */
  public List<RouteSpec> compile(final Class<? extends Jooby> appClass,
      final List<Route.Definition> routes, final Path srcdir, final Path outdir) {
    requireNonNull(appClass, "App class is required.");
    requireNonNull(routes, "Routes are required.");
    requireNonNull(srcdir, "Source dir is required.");
    requireNonNull(outdir, "Out dir is required.");
    return processInternal(appClass, routes, srcdir, outdir);
  }

  /**
   * Process a {@link Jooby} application and collect {@link RouteSpec}.
   *
   * @param app A jooby app to process.
   * @param srcdir Basedir where source code is located. Useful for extracting doc.
   * @param outdir Where to save the compiled route specs.
   * @return List of route specs.
   */
  private List<RouteSpec> processInternal(final Jooby app, final Path srcdir, final Path outdir) {
    List<Route.Definition> routes = Jooby.exportRoutes(app);
    return processInternal(app.getClass(), routes, srcdir, outdir);
  }

  /**
   * Process a {@link Jooby} application and collect {@link RouteSpec}.
   *
   * @param app A jooby app to process.
   * @param srcdir Basedir where source code is located. Useful for extracting doc.
   * @param outdir Strategy where to save the compiled route specs.
   * @return List of route specs.
   */
  @SuppressWarnings("unchecked")
  private List<RouteSpec> processInternal(final Class<? extends Jooby> appClass,
      final List<Route.Definition> routes, final Path srcdir, final Path outdir) {
    log.debug("processing {}.spec", appClass.getName());
    List<RouteSpec> specs = new ArrayList<>();
    try {
      /**
       * Source resolver.
       */
      SourceResolver src = new SourceResolverImpl(srcdir);

      /**
       * Context with type resolver.
       */
      Context ctx = new ContextImpl(new TypeResolverImpl(appClass.getClassLoader()), src);

      Optional<List<RouteSpec>> ifspecs = ctx.parseSpec(appClass);
      if (ifspecs.isPresent()) {
        // compiled version found.
        return ifspecs.get();
      }

      /** Collect all routes and process them. */
      Set<String> owners = new HashSet<>();
      owners.add(appClass.getName());

      List<Entry<Object, Node>> routeNodes = new ArrayList<>();
      try {
        /**
         * Main AST
         */
        CompilationUnit unit = JavaParser.parse(src.resolveSource(appClass).get(), true);

        /**
         * Find out app node.
         */
        Node appNode = new AppCollector().accept(unit, ctx);
        if (appNode == null) {
          throw new IllegalStateException("Default constructor not found");
        }

        routeNodes = new RouteCollector(owners::add).accept(appNode, ctx);
      } catch (Exception x) {
        // ignore source code error
        log.debug("source code not found", x);
      }

      int j = 0;
      for (int i = 0; i < routes.size(); i++) {
        Route.Definition route = routes.get(i);
        Object cursor = null;
        Method method = null;
        try {
          Route.Filter handler = route.filter();
          // find out where the route was defined
          final String owner;
          if (handler instanceof Route.MethodHandler) {
            method = ((Route.MethodHandler) handler).method();
            owner = method.getDeclaringClass().getName();
            owners.add(owner);
          } else {
            // lambda script
            owner = handler.getClass().getName();
          }

          if (owners.stream().filter(o -> owner.startsWith(o)).findFirst().isPresent()) {

            log.debug("    found {} {}", route.method(), route.pattern());
            Entry<Object, Node> entry = routeNodes.get(j++);

            Object candidate = entry.getKey();
            if (candidate instanceof Node) {
              cursor = candidate;
              log.debug("\n{}\n", candidate);
              /** doc and response codes . */
              Map<String, Object> doc = new DocCollector(log).accept((Node) candidate,
                  route.method(), ctx);
              Map<Integer, String> codes = (Map<Integer, String>) doc.remove("@statusCodes");
              String desc = (String) doc.remove("@text");
              String summary = (String) doc.remove("@summary");
              String retDoc = (String) doc.remove("@return");
              Type retType = (Type) doc.remove("@type");

              /** params and return type */
              List<RouteParam> params;
              RouteResponse rsp;
              String name = route.name();
              if (method == null) {
                // script params
                params = new RouteParamCollector(doc, route.method(), route.pattern())
                    .accept(entry.getValue(), ctx);
                // script response
                rsp = new ResponseTypeCollector().accept(entry.getValue(), ctx, retType, retDoc,
                    codes);
              } else {
                name = method.getName();
                params = mvcParams(route, method, doc);
                rsp = new RouteResponseImpl(
                    retType == null ? method.getGenericReturnType() : retType,
                    retDoc, codes);
              }

              // test if body param is present, when present all the other params are set to query
              params.stream()
                  .filter(p -> p.paramType() == RouteParamType.BODY)
                  .findFirst()
                  .ifPresent(p -> {
                    params.stream().filter(it -> it != p)
                        .forEach(it -> it.paramType(RouteParamType.QUERY));
                  });
              // ovewrite param type if need it
              params.stream()
                  .forEach(p -> {
                    p.doc().ifPresent(pdoc -> {
                      TypeFromDoc.parse(entry.getValue(), ctx, pdoc).ifPresent(p::type);
                    });
                  });

              /** Create spec . */
              specs.add(new RouteSpecImpl(route, name, summary, desc, params, rsp));
            } else {
              specs.add((RouteSpec) candidate);
            }
          } else {
            log.debug("    ignoring {} {} from {}", route.method(), route.pattern(), owner);
          }
        } catch (Exception ex) {
          if (method != null) {
            // MVC:
            String name = method.getName();
            List<RouteParam> params = mvcParams(route, method, Collections.emptyMap());
            RouteResponseImpl rsp = new RouteResponseImpl(method.getGenericReturnType(), null,
                new HashMap<>());
            /** Create spec . */
            specs.add(new RouteSpecImpl(route, name, null, null, params, rsp));
          } else {
            if (cursor == null) {
              log.debug("ignoring {} {} no source code available", route.method(), route.pattern(),
                  ex);
              log.info("ignoring {} {} no source code available", route.method(), route.pattern());
            } else {
              log.error("ignoring {} {} reason {}", route.method(), route.pattern(), ex);
            }
          }
        }
      }
    } catch (Exception ex) {
      throw new IllegalStateException("Error while processing " + appClass, ex);
    }
    if (outdir != null) {
      save(outdir.resolve(appClass.getSimpleName() + ".spec"), specs);
    }
    log.debug("done");
    return specs;
  }

  private void save(final Path path, final List<RouteSpec> specs) {
    File fout = path.toFile();
    fout.getParentFile().mkdirs();
    try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(fout))) {
      log.info("    saving {}", fout);
      out.writeObject(specs);
    } catch (IOException ex) {
      throw Throwables.propagate(ex);
    }
  }

  private List<RouteParam> mvcParams(final Route.Definition route, final Method m,
      final Map<String, Object> doc) {
    RequestParamNameProviderImpl md = new RequestParamNameProviderImpl(
        new RouteMetadata(Env.DEFAULT.build(ConfigFactory.empty())));

    Parameter[] parameters = m.getParameters();
    List<RouteParam> params = new ArrayList<>(parameters.length);
    for (Parameter parameter : parameters) {

      if (parameter.isAnnotationPresent(Flash.class)
          || parameter.isAnnotationPresent(Local.class)) {
        continue;
      }
      if (SKIP.contains(parameter.getType())) {
        continue;
      }

      String name = md.name(parameter);

      final RouteParamType paramType;
      if (parameter.getAnnotation(Body.class) != null) {
        paramType = RouteParamType.BODY;
      } else if (parameter.getAnnotation(Header.class) != null) {
        paramType = RouteParamType.HEADER;
      } else if (route.vars().contains(name)) {
        paramType = RouteParamType.PATH;
      } else if (route.method().equals("GET")) {
        paramType = RouteParamType.QUERY;
      } else {
        paramType = RouteParamType.FORM;
      }

      String pdoc = (String) doc.get(name);

      RouteParamImpl param = new RouteParamImpl(name, parameter.getParameterizedType(),
          paramType, null, pdoc);
      params.add(param);
    }
    return params;
  }
}
