/*
 * Copyright 2000-2015 JetBrains s.r.o.
 *
 * 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 org.jetbrains.ide;

import com.google.common.base.Supplier;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import com.google.gson.stream.MalformedJsonException;
import com.intellij.ide.IdeBundle;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.project.ProjectManager;
import com.intellij.openapi.util.NotNullLazyValue;
import com.intellij.openapi.util.Ref;
import com.intellij.openapi.util.io.BufferExposingByteArrayOutputStream;
import com.intellij.openapi.util.registry.Registry;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.openapi.util.text.StringUtilRt;
import com.intellij.openapi.vfs.CharsetToolkit;
import com.intellij.openapi.wm.IdeFocusManager;
import com.intellij.openapi.wm.IdeFrame;
import com.intellij.util.ExceptionUtil;
import com.intellij.util.ObjectUtils;
import com.intellij.util.containers.ContainerUtil;
import com.intellij.util.io.NettyKt;
import com.intellij.util.net.NetUtils;
import io.netty.buffer.ByteBufInputStream;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.builtInWebServer.BuiltInWebServerKt;
import org.jetbrains.io.Responses;

import javax.swing.*;
import java.awt.*;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.lang.reflect.InvocationTargetException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

import static com.intellij.ide.impl.ProjectUtil.showYesNoDialog;

/**
 * Document your service using <a href="http://apidocjs.com">apiDoc</a>. To extract big example from source code, consider to use *.coffee file near your source file.
 * (or Python/Ruby, but coffee recommended because it's plugin is lightweight). See {@link AboutHttpService} for example.
 *
 * Don't create JsonReader/JsonWriter directly, use only provided {@link #createJsonReader}, {@link #createJsonWriter} methods (to ensure that you handle in/out according to REST API guidelines).
 *
 * @see <a href="http://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api">Best Practices for Designing a Pragmatic RESTful API</a>.
 */
public abstract class RestService extends HttpRequestHandler {
  protected static final Logger LOG = Logger.getInstance(RestService.class);
  public static final String PREFIX = "api";

  protected final NotNullLazyValue<Gson> gson = new NotNullLazyValue<Gson>() {
    @NotNull
    @Override
    protected Gson compute() {
      return new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
    }
  };

  private final LoadingCache<InetAddress, AtomicInteger> abuseCounter =
    CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.MINUTES).build(CacheLoader.from((Supplier<AtomicInteger>)AtomicInteger::new));

  private final Cache<String, Boolean> trustedOrigins =
    CacheBuilder.newBuilder().maximumSize(1024).expireAfterWrite(1, TimeUnit.DAYS).build();

  @Override
  public final boolean isSupported(@NotNull FullHttpRequest request) {
    if (!isMethodSupported(request.method())) {
      return false;
    }

    String uri = request.uri();

    if (isPrefixlessAllowed() && checkPrefix(uri, getServiceName())) {
      return true;
    }

    String serviceName = getServiceName();
    int minLength = 1 + PREFIX.length() + 1 + serviceName.length();
    if (uri.length() >= minLength &&
        uri.charAt(0) == '/' &&
        uri.regionMatches(true, 1, PREFIX, 0, PREFIX.length()) &&
        uri.regionMatches(true, 2 + PREFIX.length(), serviceName, 0, serviceName.length())) {
      if (uri.length() == minLength) {
        return true;
      }
      else {
        char c = uri.charAt(minLength);
        return c == '/' || c == '?';
      }
    }
    return false;
  }

  /**
   * Service url must be "/api/$serviceName", but to preserve backward compatibility, prefixless path could be also supported
   */
  protected boolean isPrefixlessAllowed() {
    return false;
  }

  /**
   * Use human-readable name or UUID if it is an internal service.
   */
  @NotNull
  protected abstract String getServiceName();

  protected abstract boolean isMethodSupported(@NotNull HttpMethod method);

  @Override
  public final boolean process(@NotNull QueryStringDecoder urlDecoder, @NotNull FullHttpRequest request, @NotNull ChannelHandlerContext context) throws IOException {
    try {
      AtomicInteger counter = abuseCounter.get(((InetSocketAddress)context.channel().remoteAddress()).getAddress());
      if (counter.incrementAndGet() > Registry.intValue("ide.rest.api.requests.per.minute", 30)) {
        Responses.send(Responses.orInSafeMode(HttpResponseStatus.TOO_MANY_REQUESTS, HttpResponseStatus.OK), context.channel(), request);
        return true;
      }

      if (!isHostTrusted(request)) {
        Responses.send(Responses.orInSafeMode(HttpResponseStatus.FORBIDDEN, HttpResponseStatus.OK), context.channel(), request);
        return true;
      }

      String error = execute(urlDecoder, request, context);
      if (error != null) {
        Responses.send(HttpResponseStatus.BAD_REQUEST, context.channel(), request, error);
      }
    }
    catch (Throwable e) {
      HttpResponseStatus status;
      // JsonReader exception
      //noinspection InstanceofCatchParameter
      if (e instanceof MalformedJsonException || (e instanceof IllegalStateException && e.getMessage().startsWith("Expected a "))) {
        LOG.warn(e);
        status = HttpResponseStatus.BAD_REQUEST;
      }
      else {
        LOG.error(e);
        status = HttpResponseStatus.INTERNAL_SERVER_ERROR;
      }
      Responses.send(status, context.channel(), request, ExceptionUtil.getThrowableText(e));
    }
    return true;
  }

  // e.g. upsource trust to configured host
  protected boolean isHostTrusted(@NotNull FullHttpRequest request) throws InterruptedException, InvocationTargetException {
    if (BuiltInWebServerKt.isSignedRequest(request)) {
      return true;
    }

    String referrer = NettyKt.getOrigin(request);
    if (referrer == null) {
      referrer = NettyKt.getReferrer(request);
    }

    String host;
    try {
      host = StringUtil.nullize(referrer == null ? null : new URI(referrer).getHost());
    }
    catch (URISyntaxException ignored) {
      return false;
    }

    Ref<Boolean> isTrusted = Ref.create();
    if (host != null) {
      if (NetUtils.isLocalhost(host)) {
        isTrusted.set(true);
      }
      else {
        isTrusted.set(trustedOrigins.getIfPresent(host));
      }
    }

    if (isTrusted.isNull()) {
      SwingUtilities.invokeAndWait(() -> {
        isTrusted.set(showYesNoDialog(
          IdeBundle.message("warning.use.rest.api", getServiceName(), ObjectUtils.chooseNotNull(host, "unknown host")),
          "title.use.rest.api"));
        if (host != null) {
          trustedOrigins.put(host, isTrusted.get());
        }
      });
    }
    return isTrusted.get();
  }

  protected static void activateLastFocusedFrame() {
    IdeFrame frame = IdeFocusManager.getGlobalInstance().getLastFocusedFrame();
    if (frame instanceof Window) {
      ((Window)frame).toFront();
    }
  }

  /**
   * Return error or send response using {@link #sendOk(FullHttpRequest, ChannelHandlerContext)}, {@link #send(BufferExposingByteArrayOutputStream, HttpRequest, ChannelHandlerContext)}
   */
  @Nullable("error text or null if successful")
  public abstract String execute(@NotNull QueryStringDecoder urlDecoder, @NotNull FullHttpRequest request, @NotNull ChannelHandlerContext context) throws IOException;

  @NotNull
  protected static JsonReader createJsonReader(@NotNull FullHttpRequest request) {
    JsonReader reader = new JsonReader(new InputStreamReader(new ByteBufInputStream(request.content()), CharsetToolkit.UTF8_CHARSET));
    reader.setLenient(true);
    return reader;
  }

  @NotNull
  protected static JsonWriter createJsonWriter(@NotNull OutputStream out) {
    JsonWriter writer = new JsonWriter(new OutputStreamWriter(out, CharsetToolkit.UTF8_CHARSET));
    writer.setIndent("  ");
    return writer;
  }

  @Nullable
  protected static Project getLastFocusedOrOpenedProject() {
    IdeFrame lastFocusedFrame = IdeFocusManager.getGlobalInstance().getLastFocusedFrame();
    Project project = lastFocusedFrame == null ? null : lastFocusedFrame.getProject();
    if (project == null) {
      Project[] openProjects = ProjectManager.getInstance().getOpenProjects();
      return openProjects.length > 0 ? openProjects[0] : null;
    }
    return project;
  }

  protected static void sendOk(@NotNull FullHttpRequest request, @NotNull ChannelHandlerContext context) {
    sendStatus(HttpResponseStatus.OK, HttpUtil.isKeepAlive(request), context.channel());
  }

  protected static void sendStatus(@NotNull HttpResponseStatus status, boolean keepAlive, @NotNull Channel channel) {
    DefaultFullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status);
    HttpUtil.setContentLength(response, 0);
    Responses.addCommonHeaders(response);
    Responses.addNoCache(response);
    if (keepAlive) {
      HttpUtil.setKeepAlive(response, true);
    }
    response.headers().set("X-Frame-Options", "Deny");
    Responses.send(response, channel, !keepAlive);
  }

  protected static void send(@NotNull BufferExposingByteArrayOutputStream byteOut, @NotNull HttpRequest request, @NotNull ChannelHandlerContext context) {
    HttpResponse response = Responses.response("application/json", Unpooled.wrappedBuffer(byteOut.getInternalBuffer(), 0, byteOut.size()));
    Responses.addNoCache(response);
    response.headers().set("X-Frame-Options", "Deny");
    Responses.send(response, context.channel(), request);
  }

  @Nullable
  protected static String getStringParameter(@NotNull String name, @NotNull QueryStringDecoder urlDecoder) {
    return ContainerUtil.getLastItem(urlDecoder.parameters().get(name));
  }

  protected static int getIntParameter(@NotNull String name, @NotNull QueryStringDecoder urlDecoder) {
    return StringUtilRt.parseInt(StringUtil.nullize(ContainerUtil.getLastItem(urlDecoder.parameters().get(name)), true), -1);
  }

  protected static boolean getBooleanParameter(@NotNull String name, @NotNull QueryStringDecoder urlDecoder) {
    return getBooleanParameter(name, urlDecoder, false);
  }

  protected static boolean getBooleanParameter(@NotNull String name, @NotNull QueryStringDecoder urlDecoder, boolean defaultValue) {
    List<String> values = urlDecoder.parameters().get(name);
    if (ContainerUtil.isEmpty(values)) {
      return defaultValue;
    }

    String value = values.get(values.size() - 1);
    // if just name specified, so, true
    return value.isEmpty() || Boolean.parseBoolean(value);
  }
}
