/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko.sync.net;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.lang.ref.WeakReference;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.GeneralSecurityException;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.concurrent.CopyOnWriteArrayList;

import javax.net.ssl.SSLContext;

import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.mozilla.gecko.background.common.GlobalConstants;
import org.mozilla.gecko.background.common.log.Logger;
import org.mozilla.gecko.sync.ExtendedJSONObject;

import ch.boye.httpclientandroidlib.Header;
import ch.boye.httpclientandroidlib.HttpEntity;
import ch.boye.httpclientandroidlib.HttpResponse;
import ch.boye.httpclientandroidlib.HttpVersion;
import ch.boye.httpclientandroidlib.client.AuthCache;
import ch.boye.httpclientandroidlib.client.ClientProtocolException;
import ch.boye.httpclientandroidlib.client.entity.GzipCompressingEntity;
import ch.boye.httpclientandroidlib.client.methods.HttpDelete;
import ch.boye.httpclientandroidlib.client.methods.HttpGet;
import ch.boye.httpclientandroidlib.client.methods.HttpPatch;
import ch.boye.httpclientandroidlib.client.methods.HttpPost;
import ch.boye.httpclientandroidlib.client.methods.HttpPut;
import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest;
import ch.boye.httpclientandroidlib.client.protocol.ClientContext;
import ch.boye.httpclientandroidlib.conn.ClientConnectionManager;
import ch.boye.httpclientandroidlib.conn.scheme.PlainSocketFactory;
import ch.boye.httpclientandroidlib.conn.scheme.Scheme;
import ch.boye.httpclientandroidlib.conn.scheme.SchemeRegistry;
import ch.boye.httpclientandroidlib.conn.ssl.SSLSocketFactory;
import ch.boye.httpclientandroidlib.entity.StringEntity;
import ch.boye.httpclientandroidlib.impl.client.BasicAuthCache;
import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
import ch.boye.httpclientandroidlib.impl.conn.tsccm.ThreadSafeClientConnManager;
import ch.boye.httpclientandroidlib.params.HttpConnectionParams;
import ch.boye.httpclientandroidlib.params.HttpParams;
import ch.boye.httpclientandroidlib.params.HttpProtocolParams;
import ch.boye.httpclientandroidlib.protocol.BasicHttpContext;
import ch.boye.httpclientandroidlib.protocol.HttpContext;
import ch.boye.httpclientandroidlib.util.EntityUtils;

/**
 * Provide simple HTTP access to a Sync server or similar.
 * Implements Basic Auth by asking its delegate for credentials.
 * Communicates with a ResourceDelegate to asynchronously return responses and errors.
 * Exposes simple get/post/put/delete methods.
 */
@SuppressWarnings("deprecation")
public class BaseResource implements Resource {
  private static final String ANDROID_LOOPBACK_IP = "10.0.2.2";

  private static final int MAX_TOTAL_CONNECTIONS     = 20;
  private static final int MAX_CONNECTIONS_PER_ROUTE = 10;

  private boolean retryOnFailedRequest = true;

  public static boolean rewriteLocalhost = true;

  private static final String LOG_TAG = "BaseResource";

  protected final URI uri;
  protected BasicHttpContext context;
  protected DefaultHttpClient client;
  public    ResourceDelegate delegate;
  protected HttpRequestBase request;
  public final String charset = "utf-8";

  private boolean shouldGzipCompress = false;
  // A hint whether uploaded payloads are chunked. Default true to use GzipCompressingEntity, which is built-in functionality.
  private boolean shouldChunkUploadsHint = true;

  /**
   * We have very few writes (observers tend to be installed around sync
   * sessions) and many iterations (every HTTP request iterates observers), so
   * CopyOnWriteArrayList is a reasonable choice.
   */
  protected static final CopyOnWriteArrayList<WeakReference<HttpResponseObserver>>
    httpResponseObservers = new CopyOnWriteArrayList<>();

  public BaseResource(String uri) throws URISyntaxException {
    this(uri, rewriteLocalhost);
  }

  public BaseResource(URI uri) {
    this(uri, rewriteLocalhost);
  }

  public BaseResource(String uri, boolean rewrite) throws URISyntaxException {
    this(new URI(uri), rewrite);
  }

  public BaseResource(URI uri, boolean rewrite) {
    if (uri == null) {
      throw new IllegalArgumentException("uri must not be null");
    }
    if (rewrite && "localhost".equals(uri.getHost())) {
      // Rewrite localhost URIs to refer to the special Android emulator loopback passthrough interface.
      Logger.debug(LOG_TAG, "Rewriting " + uri + " to point to " + ANDROID_LOOPBACK_IP + ".");
      try {
        this.uri = new URI(uri.getScheme(), uri.getUserInfo(), ANDROID_LOOPBACK_IP, uri.getPort(), uri.getPath(), uri.getQuery(), uri.getFragment());
      } catch (URISyntaxException e) {
        Logger.error(LOG_TAG, "Got error rewriting URI for Android emulator.", e);
        throw new IllegalArgumentException("Invalid URI", e);
      }
    } else {
      this.uri = uri;
    }
  }

  public static void addHttpResponseObserver(HttpResponseObserver newHttpResponseObserver) {
    if (newHttpResponseObserver == null) {
      return;
    }
    httpResponseObservers.add(new WeakReference<HttpResponseObserver>(newHttpResponseObserver));
  }

  public static boolean isHttpResponseObserver(HttpResponseObserver httpResponseObserver) {
    for (WeakReference<HttpResponseObserver> weakReference : httpResponseObservers) {
      HttpResponseObserver innerHttpResponseObserver = weakReference.get();
      if (innerHttpResponseObserver == httpResponseObserver) {
        return true;
      }
    }
    return false;
  }

  public static boolean removeHttpResponseObserver(HttpResponseObserver httpResponseObserver) {
    for (WeakReference<HttpResponseObserver> weakReference : httpResponseObservers) {
      HttpResponseObserver innerHttpResponseObserver = weakReference.get();
      if (innerHttpResponseObserver == httpResponseObserver) {
        // It's safe to mutate the observers while iterating.
        httpResponseObservers.remove(weakReference);
        return true;
      }
    }
    return false;
  }

  @Override
  public URI getURI() {
    return this.uri;
  }

  @Override
  public String getURIString() {
    return this.uri.toString();
  }

  @Override
  public String getHostname() {
    return this.getURI().getHost();
  }

  /**
   * Causes the Resource to compress the uploaded entity payload in requests with payloads (e.g. post, put)
   * @param shouldCompress true if the entity should be compressed, false otherwise
   */
  public void setShouldCompressUploadedEntity(final boolean shouldCompress) {
    shouldGzipCompress = shouldCompress;
  }

  /**
   * Causes the Resource to chunk the uploaded entity payload in requests with payloads (e.g. post, put).
   * Note: this flag is only a hint - chunking is not guaranteed.
   *
   * Chunking is currently supported with gzip compression.
   *
   * @param shouldChunk true if the transfer should be chunked, false otherwise
   */
  public void setShouldChunkUploadsHint(final boolean shouldChunk) {
    shouldChunkUploadsHint = shouldChunk;
  }

  private HttpEntity getMaybeCompressedEntity(final HttpEntity entity) {
    if (!shouldGzipCompress) {
      return entity;
    }

    return shouldChunkUploadsHint ? new GzipCompressingEntity(entity) : new GzipNonChunkedCompressingEntity(entity);
  }

  /**
   * This shuts up HttpClient, which will otherwise debug log about there
   * being no auth cache in the context.
   */
  private static void addAuthCacheToContext(HttpUriRequest request, HttpContext context) {
    AuthCache authCache = new BasicAuthCache();                // Not thread safe.
    context.setAttribute(ClientContext.AUTH_CACHE, authCache);
  }

  /**
   * Invoke this after delegate and request have been set.
   * @throws NoSuchAlgorithmException
   * @throws KeyManagementException
   */
  protected void prepareClient() throws KeyManagementException, NoSuchAlgorithmException, GeneralSecurityException {
    context = new BasicHttpContext();

    // We could reuse these client instances, except that we mess around
    // with their parameters… so we'd need a pool of some kind.
    client = new DefaultHttpClient(getConnectionManager());

    // TODO: Eventually we should use Apache HttpAsyncClient. It's not out of alpha yet.
    // Until then, we synchronously make the request, then invoke our delegate's callback.
    AuthHeaderProvider authHeaderProvider = delegate.getAuthHeaderProvider();
    if (authHeaderProvider != null) {
      Header authHeader = authHeaderProvider.getAuthHeader(request, context, client);
      if (authHeader != null) {
        request.addHeader(authHeader);
        Logger.debug(LOG_TAG, "Added auth header.");
      }
    }

    addAuthCacheToContext(request, context);

    HttpParams params = client.getParams();
    HttpConnectionParams.setConnectionTimeout(params, delegate.connectionTimeout());
    HttpConnectionParams.setSoTimeout(params, delegate.socketTimeout());
    HttpConnectionParams.setStaleCheckingEnabled(params, false);
    HttpProtocolParams.setContentCharset(params, charset);
    HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1);
    final String userAgent = delegate.getUserAgent();
    if (userAgent != null) {
      HttpProtocolParams.setUserAgent(params, userAgent);
    }
    delegate.addHeaders(request, client);
  }

  private static final Object connManagerMonitor = new Object();
  private static ClientConnectionManager connManager;

  // Call within a synchronized block on connManagerMonitor.
  private static ClientConnectionManager enableTLSConnectionManager() throws KeyManagementException, NoSuchAlgorithmException  {
    SSLContext sslContext = SSLContext.getInstance("TLS");
    sslContext.init(null, null, new SecureRandom());

    Logger.debug(LOG_TAG, "Using protocols and cipher suites for Android API " + android.os.Build.VERSION.SDK_INT);
    SSLSocketFactory sf = new SSLSocketFactory(sslContext, GlobalConstants.DEFAULT_PROTOCOLS, GlobalConstants.DEFAULT_CIPHER_SUITES, null);
    SchemeRegistry schemeRegistry = new SchemeRegistry();
    schemeRegistry.register(new Scheme("https", 443, sf));
    schemeRegistry.register(new Scheme("http", 80, new PlainSocketFactory()));
    ThreadSafeClientConnManager cm = new ThreadSafeClientConnManager(schemeRegistry);

    cm.setMaxTotal(MAX_TOTAL_CONNECTIONS);
    cm.setDefaultMaxPerRoute(MAX_CONNECTIONS_PER_ROUTE);
    connManager = cm;
    return cm;
  }

  public static ClientConnectionManager getConnectionManager() throws KeyManagementException, NoSuchAlgorithmException
                                                         {
    // TODO: shutdown.
    synchronized (connManagerMonitor) {
      if (connManager != null) {
        return connManager;
      }
      return enableTLSConnectionManager();
    }
  }

  /**
   * Do some cleanup, so we don't need the stale connection check.
   */
  public static void closeExpiredConnections() {
    ClientConnectionManager connectionManager;
    synchronized (connManagerMonitor) {
      connectionManager = connManager;
    }
    if (connectionManager == null) {
      return;
    }
    Logger.trace(LOG_TAG, "Closing expired connections.");
    connectionManager.closeExpiredConnections();
  }

  public static void shutdownConnectionManager() {
    ClientConnectionManager connectionManager;
    synchronized (connManagerMonitor) {
      connectionManager = connManager;
      connManager = null;
    }
    if (connectionManager == null) {
      return;
    }
    Logger.debug(LOG_TAG, "Shutting down connection manager.");
    connectionManager.shutdown();
  }

  private void execute() {
    HttpResponse response;
    try {
      response = client.execute(request, context);
      Logger.debug(LOG_TAG, "Response: " + response.getStatusLine().toString());
    } catch (ClientProtocolException e) {
      delegate.handleHttpProtocolException(e);
      return;
    } catch (IOException e) {
      Logger.debug(LOG_TAG, "I/O exception returned from execute.");
      if (!retryOnFailedRequest) {
        delegate.handleHttpIOException(e);
      } else {
        retryRequest();
      }
      return;
    } catch (Exception e) {
      // Bug 740731: Don't let an exception fall through. Wrapping isn't
      // optimal, but often the exception is treated as an Exception anyway.
      if (!retryOnFailedRequest) {
        // Bug 769671: IOException(Throwable cause) was added only in API level 9.
        final IOException ex = new IOException();
        ex.initCause(e);
        delegate.handleHttpIOException(ex);
      } else {
        retryRequest();
      }
      return;
    }

    // Don't retry if the observer or delegate throws!
    for (WeakReference<HttpResponseObserver> weakReference : httpResponseObservers) {
      HttpResponseObserver observer = weakReference.get();
      if (observer != null) {
        observer.observeHttpResponse(request, response);
      }
    }
    delegate.handleHttpResponse(response);
  }

  private void retryRequest() {
    // Only retry once.
    retryOnFailedRequest = false;
    Logger.debug(LOG_TAG, "Retrying request...");
    this.execute();
  }

  private void go(HttpRequestBase request) {
   if (delegate == null) {
      throw new IllegalArgumentException("No delegate provided.");
    }
    this.request = request;
    try {
      this.prepareClient();
    } catch (KeyManagementException e) {
      Logger.error(LOG_TAG, "Couldn't prepare client.", e);
      delegate.handleTransportException(e);
      return;
    } catch (GeneralSecurityException e) {
      Logger.error(LOG_TAG, "Couldn't prepare client.", e);
      delegate.handleTransportException(e);
      return;
    } catch (Exception e) {
      // Bug 740731: Don't let an exception fall through. Wrapping isn't
      // optimal, but often the exception is treated as an Exception anyway.
      delegate.handleTransportException(new GeneralSecurityException(e));
      return;
    }
    this.execute();
  }

  @Override
  public void get() {
    Logger.debug(LOG_TAG, "HTTP GET " + this.uri.toASCIIString());
    this.go(new HttpGet(this.uri));
  }

  /**
   * Perform an HTTP GET as with {@link BaseResource#get()}, returning only
   * after callbacks have been invoked.
   */
  public void getBlocking() {
    // Until we use the asynchronous Apache HttpClient, we can simply call
    // through.
    this.get();
  }

  @Override
  public void delete() {
    Logger.debug(LOG_TAG, "HTTP DELETE " + this.uri.toASCIIString());
    this.go(new HttpDelete(this.uri));
  }

  @Override
  public void post(HttpEntity body) {
    Logger.debug(LOG_TAG, "HTTP POST " + this.uri.toASCIIString());
    body = getMaybeCompressedEntity(body);
    HttpPost request = new HttpPost(this.uri);
    request.setEntity(body);
    this.go(request);
  }

  @Override
  public void patch(HttpEntity body) {
    Logger.debug(LOG_TAG, "HTTP PATCH " + this.uri.toASCIIString());
    body = getMaybeCompressedEntity(body);
    HttpPatch request = new HttpPatch(this.uri);
    request.setEntity(body);
    this.go(request);
  }

  @Override
  public void put(HttpEntity body) {
    Logger.debug(LOG_TAG, "HTTP PUT " + this.uri.toASCIIString());
    body = getMaybeCompressedEntity(body);
    HttpPut request = new HttpPut(this.uri);
    request.setEntity(body);
    this.go(request);
  }

  protected static StringEntity stringEntityWithContentTypeApplicationJSON(String s) {
    StringEntity e = new StringEntity(s, "UTF-8");
    e.setContentType("application/json");
    return e;
  }

  /**
   * Helper for turning a JSON object into a payload.
   * @throws UnsupportedEncodingException
   */
  protected static StringEntity jsonEntity(JSONObject body) {
    return stringEntityWithContentTypeApplicationJSON(body.toJSONString());
  }

  /**
   * Helper for turning an extended JSON object into a payload.
   * @throws UnsupportedEncodingException
   */
  protected static StringEntity jsonEntity(ExtendedJSONObject body) {
    return stringEntityWithContentTypeApplicationJSON(body.toJSONString());
  }

  /**
   * Helper for turning a JSON array into a payload.
   * @throws UnsupportedEncodingException
   */
  protected static HttpEntity jsonEntity(JSONArray toPOST) throws UnsupportedEncodingException {
    return stringEntityWithContentTypeApplicationJSON(toPOST.toJSONString());
  }

  /**
   * Best-effort attempt to ensure that the entity has been fully consumed and
   * that the underlying stream has been closed.
   *
   * This releases the connection back to the connection pool.
   *
   * @param entity The HttpEntity to be consumed.
   */
  public static void consumeEntity(HttpEntity entity) {
    try {
      EntityUtils.consume(entity);
    } catch (IOException e) {
      // Doesn't matter.
    }
  }

  /**
   * Best-effort attempt to ensure that the entity corresponding to the given
   * HTTP response has been fully consumed and that the underlying stream has
   * been closed.
   *
   * This releases the connection back to the connection pool.
   *
   * @param response
   *          The HttpResponse to be consumed.
   */
  public static void consumeEntity(HttpResponse response) {
    if (response == null) {
      return;
    }
    try {
      EntityUtils.consume(response.getEntity());
    } catch (IOException e) {
    }
  }

  /**
   * Best-effort attempt to ensure that the entity corresponding to the given
   * Sync storage response has been fully consumed and that the underlying
   * stream has been closed.
   *
   * This releases the connection back to the connection pool.
   *
   * @param response
   *          The SyncStorageResponse to be consumed.
   */
  public static void consumeEntity(SyncStorageResponse response) {
    if (response.httpResponse() == null) {
      return;
    }
    consumeEntity(response.httpResponse());
  }

  /**
   * Best-effort attempt to ensure that the reader has been fully consumed, so
   * that the underlying stream will be closed.
   *
   * This should allow the connection to be released back to the connection pool.
   *
   * @param reader The BufferedReader to be consumed.
   */
  public static void consumeReader(BufferedReader reader) {
    try {
      reader.close();
    } catch (IOException e) {
      // Do nothing.
    }
  }

  public void post(JSONArray jsonArray) throws UnsupportedEncodingException {
    post(jsonEntity(jsonArray));
  }

  public void put(JSONObject jsonObject) throws UnsupportedEncodingException {
    put(jsonEntity(jsonObject));
  }

  public void put(ExtendedJSONObject o) {
    put(jsonEntity(o));
  }

  public void post(ExtendedJSONObject o) {
    post(jsonEntity(o));
  }

  /**
   * Perform an HTTP POST as with {@link BaseResource#post(ExtendedJSONObject)}, returning only
   * after callbacks have been invoked.
   */
  public void postBlocking(final ExtendedJSONObject o) {
    // Until we use the asynchronous Apache HttpClient, we can simply call
    // through.
    post(jsonEntity(o));
  }

  public void post(JSONObject jsonObject) throws UnsupportedEncodingException {
    post(jsonEntity(jsonObject));
  }

  public void patch(JSONArray jsonArray) throws UnsupportedEncodingException {
    patch(jsonEntity(jsonArray));
  }

  public void patch(ExtendedJSONObject o) {
    patch(jsonEntity(o));
  }

  public void patch(JSONObject jsonObject) throws UnsupportedEncodingException {
    patch(jsonEntity(jsonObject));
  }
}
