/**
* 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.hadoop.yarn.webapp;

import static com.google.common.base.Preconditions.checkState;

import java.io.IOException;
import java.util.Timer;
import java.util.TimerTask;

import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.hadoop.classification.InterfaceAudience;
import org.apache.hadoop.http.HtmlQuoting;
import org.apache.hadoop.yarn.webapp.Controller.RequestContext;
import org.apache.hadoop.yarn.webapp.Router.Dest;
import org.apache.hadoop.yarn.webapp.view.ErrorPage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.collect.Iterables;
import com.google.inject.Inject;
import com.google.inject.Injector;
import com.google.inject.Singleton;

/**
 * The servlet that dispatch request to various controllers
 * according to the user defined routes in the router.
 */
@InterfaceAudience.LimitedPrivate({"YARN", "MapReduce"})
@Singleton
public class Dispatcher extends HttpServlet {
  private static final long serialVersionUID = 1L;
  static final Logger LOG = LoggerFactory.getLogger(Dispatcher.class);
  static final String ERROR_COOKIE = "last-error";
  static final String STATUS_COOKIE = "last-status";

  private transient final Injector injector;
  private transient final Router router;
  private transient final WebApp webApp;
  private volatile boolean devMode = false;

  @Inject
  Dispatcher(WebApp webApp, Injector injector, Router router) {
    this.webApp = webApp;
    this.injector = injector;
    this.router = router;
  }

  @Override
  public void doOptions(HttpServletRequest req, HttpServletResponse res) {
    // for simplicity
    res.setHeader("Allow", "GET, POST");
  }

  @Override
  public void service(HttpServletRequest req, HttpServletResponse res)
      throws ServletException, IOException {
    res.setCharacterEncoding("UTF-8");
    String uri = HtmlQuoting.quoteHtmlChars(req.getRequestURI());

    if (uri == null) {
      uri = "/";
    }
    if (devMode && uri.equals("/__stop")) {
      // quick hack to restart servers in dev mode without OS commands
      res.setStatus(res.SC_NO_CONTENT);
      LOG.info("dev mode restart requested");
      prepareToExit();
      return;
    }
    // if they provide a redirectPath go there instead of going to
    // "/" so that filters can differentiate the webapps.
    if (uri.equals("/")) {
      String redirectPath = webApp.getRedirectPath();
      if (redirectPath != null && !redirectPath.isEmpty()) {
        res.sendRedirect(redirectPath);
        return;
      }
    }
    String method = req.getMethod();
    if (method.equals("OPTIONS")) {
      doOptions(req, res);
      return;
    }
    if (method.equals("TRACE")) {
      doTrace(req, res);
      return;
    }
    if (method.equals("HEAD")) {
      doGet(req, res); // default to bad request
      return;
    }
    String pathInfo = req.getPathInfo();
    if (pathInfo == null) {
      pathInfo = "/";
    }
    Controller.RequestContext rc =
        injector.getInstance(Controller.RequestContext.class);
    if (setCookieParams(rc, req) > 0) {
      Cookie ec = rc.cookies().get(ERROR_COOKIE);
      if (ec != null) {
        rc.setStatus(Integer.parseInt(rc.cookies().
            get(STATUS_COOKIE).getValue()));
        removeErrorCookies(res, uri);
        rc.set(Params.ERROR_DETAILS, ec.getValue());
        render(ErrorPage.class);
        return;
      }
    }
    rc.prefix = webApp.name();
    Router.Dest dest = null;
    try {
      dest = router.resolve(method, pathInfo);
    } catch (WebAppException e) {
      rc.error = e;
      if (!e.getMessage().contains("not found")) {
        rc.setStatus(res.SC_INTERNAL_SERVER_ERROR);
        render(ErrorPage.class);
        return;
      }
    }
    if (dest == null) {
      rc.setStatus(res.SC_NOT_FOUND);
      render(ErrorPage.class);
      return;
    }
    rc.devMode = devMode;
    setMoreParams(rc, pathInfo, dest);
    Controller controller = injector.getInstance(dest.controllerClass);
    try {
      // TODO: support args converted from /path/:arg1/...
      dest.action.invoke(controller, (Object[]) null);
      if (!rc.rendered) {
        if (dest.defaultViewClass != null) {
          render(dest.defaultViewClass);
        } else if (rc.status == 200) {
          throw new IllegalStateException("No view rendered for 200");
        }
      }
    } catch (Exception e) {
      LOG.error("error handling URI: "+ uri, e);
      // Page could be half rendered (but still not flushed). So redirect.
      redirectToErrorPage(res, e, uri, devMode);
    }
  }

  public static void redirectToErrorPage(HttpServletResponse res, Throwable e,
                                         String path, boolean devMode) {
    String st = devMode ? ErrorPage.toStackTrace(e, 1024 * 3) // spec: min 4KB
                        : "See logs for stack trace";
    res.setStatus(res.SC_FOUND);
    Cookie cookie = new Cookie(STATUS_COOKIE, String.valueOf(500));
    cookie.setPath(path);
    res.addCookie(cookie);
    cookie = new Cookie(ERROR_COOKIE, st);
    cookie.setPath(path);
    res.addCookie(cookie);
    res.setHeader("Location", path);
  }

  public static void removeErrorCookies(HttpServletResponse res, String path) {
    removeCookie(res, ERROR_COOKIE, path);
    removeCookie(res, STATUS_COOKIE, path);
  }

  public static void removeCookie(HttpServletResponse res, String name,
                                  String path) {
    LOG.debug("removing cookie {} on {}", name, path);
    Cookie c = new Cookie(name, "");
    c.setMaxAge(0);
    c.setPath(path);
    res.addCookie(c);
  }

  private void render(Class<? extends View> cls) {
    injector.getInstance(cls).render();
  }

  // /path/foo/bar with /path/:arg1/:arg2 will set {arg1=>foo, arg2=>bar}
  private void setMoreParams(RequestContext rc, String pathInfo, Dest dest) {
    checkState(pathInfo.startsWith(dest.prefix), "prefix should match");
    if (dest.pathParams.size() == 0 ||
        dest.prefix.length() == pathInfo.length()) {
      return;
    }
    String[] parts = Iterables.toArray(WebApp.pathSplitter.split(
        pathInfo.substring(dest.prefix.length())), String.class);
    LOG.debug("parts={}, params={}", parts, dest.pathParams);
    for (int i = 0; i < dest.pathParams.size() && i < parts.length; ++i) {
      String key = dest.pathParams.get(i);
      if (key.charAt(0) == ':') {
        rc.moreParams().put(key.substring(1), parts[i]);
      }
    }
  }

  private int setCookieParams(RequestContext rc, HttpServletRequest req) {
    Cookie[] cookies = req.getCookies();
    if (cookies != null) {
      for (Cookie cookie : cookies) {
        rc.cookies().put(cookie.getName(), cookie);
      }
      return cookies.length;
    }
    return 0;
  }

  public void setDevMode(boolean choice) {
    devMode = choice;
  }

  private void prepareToExit() {
    checkState(devMode, "only in dev mode");
    new Timer("webapp exit", true).schedule(new TimerTask() {
      @Override public void run() {
        LOG.info("WebAppp /{} exiting...", webApp.name());
        webApp.stop();
        System.exit(0); // FINDBUG: this is intended in dev mode
      }
    }, 18); // enough time for the last local request to complete
  }
}
