/*
 * 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 com.xiaomi.infra.galaxy.sds.client;

import com.google.common.collect.LinkedListMultimap;
import com.xiaomi.infra.galaxy.client.authentication.HttpKeys;
import com.xiaomi.infra.galaxy.client.authentication.HttpMethod;
import com.xiaomi.infra.galaxy.client.authentication.HttpUtils;
import com.xiaomi.infra.galaxy.client.authentication.signature.SignAlgorithm;
import com.xiaomi.infra.galaxy.client.authentication.signature.Signer;
import com.xiaomi.infra.galaxy.sds.shared.BytesUtil;
import com.xiaomi.infra.galaxy.sds.shared.DigestUtil;
import com.xiaomi.infra.galaxy.sds.shared.clock.AdjustableClock;
import com.xiaomi.infra.galaxy.sds.thrift.AuthenticationConstants;
import com.xiaomi.infra.galaxy.sds.thrift.CommonConstants;
import com.xiaomi.infra.galaxy.sds.thrift.Credential;
import com.xiaomi.infra.galaxy.sds.thrift.HttpStatusCode;
import com.xiaomi.infra.galaxy.sds.thrift.ThriftProtocol;
import com.xiaomi.infra.galaxy.sds.thrift.UserType;
import libthrift091.transport.TTransport;
import libthrift091.transport.TTransportException;
import libthrift091.transport.TTransportFactory;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.params.CoreConnectionPNames;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

/**
 * HTTP implementation of the TTransport interface. Used for working with a Thrift web services
 * implementation (using for example TServlet).
 * Code based on THttpClient
 */
public class SdsTHttpClient extends TTransport {
  private static final Logger LOG = LoggerFactory.getLogger(SdsTHttpClient.class);
  private static final int REQUEST_ID_LENGTH = 8;
  private URL url_ = null;
  private final ByteArrayOutputStream requestBuffer_ = new ByteArrayOutputStream();
  private InputStream inputStream_ = null;
  private int connectTimeout_ = 0;
  private int socketTimeout_ = 0;
  private Map<String, String> customHeaders_ = null;
  private final HttpHost host;
  private final HttpClient client;
  private Credential credential;
  private AdjustableClock clock;
  private ThriftProtocol protocol_ = ThriftProtocol.TCOMPACT;
  private String queryString = null;
  private boolean supportAccountKey = false;
  private String sid;

  public static class Factory extends TTransportFactory {
    private final String url;
    private final HttpClient client;
    private final Credential credential;
    private final AdjustableClock clock;

    public Factory(String url, HttpClient client, Credential credential, AdjustableClock clock) {
      this.url = url;
      this.client = client;
      this.credential = credential;
      this.clock = clock;
    }

    @Override
    public TTransport getTransport(TTransport trans) {
      try {
        return new SdsTHttpClient(url, client, credential, clock);
      } catch (TTransportException tte) {
        return null;
      }
    }
  }

  public SdsTHttpClient(String url, HttpClient client, Credential credential)
      throws TTransportException {
    this(url, client, credential, new AdjustableClock());
  }

  public SdsTHttpClient(String url, HttpClient client, Credential credential, AdjustableClock clock)
      throws TTransportException {
    try {
      url_ = new URL(url);
      this.client = client;
      this.host = new HttpHost(url_.getHost(), -1 == url_.getPort() ? url_.getDefaultPort()
          : url_.getPort(), url_.getProtocol());
      this.credential = credential;
      this.clock = clock;
    } catch (IOException iox) {
      throw new TTransportException(iox);
    }
  }

  public int getConnectTimeout() {
    if (null != this.client) {
      return client.getParams().getIntParameter(CoreConnectionPNames.CONNECTION_TIMEOUT, 0);
    }
    return -1;
  }

  public int getSocketTimeout() {
    if (null != this.client) {
      return client.getParams().getIntParameter(CoreConnectionPNames.SO_TIMEOUT, 0);
    }
    return -1;
  }

  public SdsTHttpClient setProtocol(ThriftProtocol protocol) {
    protocol_ = protocol;
    return this;
  }

  public ThriftProtocol getProtocol() {
    return protocol_;
  }

  public SdsTHttpClient setConnectTimeout(int timeout) {
    connectTimeout_ = timeout;
    if (null != this.client) {
      // WARNING, this modifies the HttpClient params, this might have an impact elsewhere if the
      // same HttpClient is used for something else.
      client.getParams().setParameter(CoreConnectionPNames.CONNECTION_TIMEOUT, connectTimeout_);
    }
    return this;
  }

  public SdsTHttpClient setSocketTimeout(int timeout) {
    socketTimeout_ = timeout;
    if (null != this.client) {
      // WARNING, this modifies the HttpClient params, this might have an impact elsewhere if the
      // same HttpClient is used for something else.
      client.getParams().setParameter(CoreConnectionPNames.SO_TIMEOUT, socketTimeout_);
    }
    return this;
  }

  public SdsTHttpClient setCustomHeaders(Map<String, String> headers) {
    customHeaders_ = headers;
    return this;
  }

  public SdsTHttpClient setCustomHeader(String key, String value) {
    if (customHeaders_ == null) {
      customHeaders_ = new HashMap<String, String>();
    }
    customHeaders_.put(key, value);
    return this;
  }

  public SdsTHttpClient setQueryString(String queryString) {
    this.queryString = queryString;
    return this;
  }

  public SdsTHttpClient setSupportAccountKey(boolean supportAccountKey) {
    this.supportAccountKey = supportAccountKey;
    return this;
  }

  public SdsTHttpClient setSid(String sid) {
    this.sid = sid;
    return this;
  }

  public void open() {
  }

  public void close() {
    if (null != inputStream_) {
      try {
        inputStream_.close();
      } catch (IOException ioe) {
        ;
      }
      inputStream_ = null;
    }
  }

  public boolean isOpen() {
    return true;
  }

  public int read(byte[] buf, int off, int len) throws TTransportException {
    if (inputStream_ == null) {
      throw new TTransportException("Response buffer is empty, no request.");
    }
    try {
      int ret = inputStream_.read(buf, off, len);
      if (ret == -1) {
        throw new TTransportException("No more data available.");
      }
      return ret;
    } catch (IOException iox) {
      throw new TTransportException(iox);
    }
  }

  public void write(byte[] buf, int off, int len) {
    requestBuffer_.write(buf, off, len);
  }

  /**
   * copy from org.apache.http.util.EntityUtils#consume. Android has it's own httpcore that doesn't
   * have a consume.
   */
  private static void consume(final HttpEntity entity) throws IOException {
    if (entity == null) {
      return;
    }
    if (entity.isStreaming()) {
      InputStream instream = entity.getContent();
      if (instream != null) {
        instream.close();
      }
    }
  }

  private void flushUsingHttpClient() throws TTransportException {
    if (null == this.client) {
      throw new RuntimeException("Null HttpClient, aborting.");
    }

    // Extract request and reset buffer
    byte[] data = requestBuffer_.toByteArray();
    requestBuffer_.reset();

    HttpPost post = null;

    InputStream is = null;

    try {
      // Set request to path + query string
      String requestId = generateRandomId(REQUEST_ID_LENGTH);
      StringBuilder sb = new StringBuilder();
      sb.append(this.url_.getFile()).append("?id=").append(requestId);
      if (queryString != null) {
        sb.append("&").append(queryString);
      }
      String uri = sb.toString();
      post = new HttpPost(uri);

      //
      // Headers are added to the HttpPost instance, not
      // to HttpClient.
      //
      post.setHeader("Content-Type", CommonConstants.THRIFT_HEADER_MAP.get(protocol_));
      post.setHeader("Accept", CommonConstants.THRIFT_HEADER_MAP.get(protocol_));
      post.setHeader("User-Agent", "Java/THttpClient/HC");
      setCustomHeaders(post);
      setAuthenticationHeaders(post, data);

      post.setEntity(new ByteArrayEntity(data));

      HttpResponse response = this.client.execute(this.host, post);
      int responseCode = response.getStatusLine().getStatusCode();
      String reasonPhrase = response.getStatusLine().getReasonPhrase();

      //
      // Retrieve the inputstream BEFORE checking the status code so
      // resources get freed in the finally clause.
      //

      is = response.getEntity().getContent();

      if (responseCode != HttpStatus.SC_OK) {
        adjustClock(response, responseCode);
        throw new HttpTTransportException(responseCode, reasonPhrase);
      }

      // Read the responses into a byte array so we can release the connection
      // early. This implies that the whole content will have to be read in
      // memory, and that momentarily we might use up twice the memory (while the
      // thrift struct is being read up the chain).
      // Proceeding differently might lead to exhaustion of connections and thus
      // to app failure.

      byte[] buf = new byte[1024];
      ByteArrayOutputStream baos = new ByteArrayOutputStream();

      int len = 0;
      do {
        len = is.read(buf);
        if (len > 0) {
          baos.write(buf, 0, len);
        }
      } while (-1 != len);

      try {
        // Indicate we're done with the content.
        consume(response.getEntity());
      } catch (IOException ioe) {
        // We ignore this exception, it might only mean the server has no
        // keep-alive capability.
      }

      inputStream_ = new ByteArrayInputStream(baos.toByteArray());
    } catch (IOException ioe) {
      // Abort method so the connection gets released back to the connection manager
      if (null != post) {
        post.abort();
      }
      throw new TTransportException(ioe);
    } finally {
      if (null != is) {
        // Close the entity's input stream, this will release the underlying connection
        try {
          is.close();
        } catch (IOException ioe) {
          throw new TTransportException(ioe);
        }
      }
    }
  }

  public void flush() throws TTransportException {
    if (this.client == null) {
      throw new RuntimeException("not supported");
    }
    flushUsingHttpClient();
  }

  private SdsTHttpClient setCustomHeaders(HttpPost post) {
    if (this.client != null && this.customHeaders_ != null) {
      for (Map.Entry<String, String> header : customHeaders_.entrySet()) {
        post.setHeader(header.getKey(), header.getValue());
      }
    }
    return this;
  }

  /**
   * Set signature related headers when credential is properly set
   */
  private SdsTHttpClient setAuthenticationHeaders(HttpPost post, byte[] data) {
    if (this.client != null && credential != null) {
      if (credential.getType() != null && credential.getSecretKeyId() != null) {

        // signature is supported
        if (AuthenticationConstants.SIGNATURE_SUPPORT.get(credential.getType())) {

          // host
          String host = this.host.toHostString();
          host = host.split(":")[0];
          post.setHeader(AuthenticationConstants.HK_HOST, host);

          // timestamp
          String timestamp = Long.toString(clock.getCurrentEpoch());
          post.setHeader(AuthenticationConstants.HK_TIMESTAMP, timestamp);
          post.setHeader(HttpKeys.MI_DATE, HttpUtils.getGMTDatetime(new Date()));

          // content md5
          String md5 = BytesUtil
              .bytesToHex(DigestUtil.digest(DigestUtil.DigestAlgorithm.MD5, data));
          post.setHeader(AuthenticationConstants.HK_CONTENT_MD5, md5);

          LinkedListMultimap<String, String> headers = LinkedListMultimap.create();
          for (Header header : post.getAllHeaders()) {
            headers.put(header.getName().toLowerCase(), header.getValue());
          }
          try {
            String authType = "Galaxy-V2 ";
            if (supportAccountKey) {
              authType = "Galaxy-V3 ";
            }
            if (credential.getType() == UserType.APP_ACCESS_TOKEN) {
              authType = "OAuth ";
            }
            String authString = authType + credential.getSecretKeyId() + ":" +
                Signer.signToBase64(HttpMethod.POST, post.getURI(), headers,
                    credential.getSecretKey(), SignAlgorithm.HmacSHA1);
            post.setHeader(AuthenticationConstants.HK_AUTHORIZATION, authString);
          } catch (Exception e) {
            throw new RuntimeException("Failed to sign", e);
          }
        } else {
          if (credential.getType() == UserType.APP_XIAOMI_SSO) {
            String authString = "SSO " + sid + ":" + credential.getSecretKey() + ":" +
                credential.getSecretKeyId();
            post.setHeader(AuthenticationConstants.HK_AUTHORIZATION, authString);
          } else if (credential.getType() == UserType.APP_ANONYMOUS) {
            String authString = "Guest " + credential.getSecretKeyId();
            post.setHeader(AuthenticationConstants.HK_AUTHORIZATION, authString);
          } else {
            throw new RuntimeException("Unsupported user type: " + credential.getType());
          }
        }
      }
    }
    return this;
  }

  /**
   * Adjust local clock when clock skew error received from server. The client clock need to be
   * roughly synchronized with server clock to make signature secure and reduce the chance of replay
   * attacks.
   *
   * @param response       server response
   * @param httpStatusCode status code
   * @return if clock is adjusted
   */
  private boolean adjustClock(HttpResponse response, int httpStatusCode) {
    if (httpStatusCode == HttpStatusCode.CLOCK_TOO_SKEWED.getValue()) {
      Header[] headers = response.getHeaders(AuthenticationConstants.HK_TIMESTAMP);
      for (Header h : headers) {
        String hv = h.getValue();
        long serverTime = Long.parseLong(hv);
        long min = 60 * 60 * 24 * 365 * (2010 - 1970);
        long max = 60 * 60 * 24 * 365 * (2030 - 1970);
        if (serverTime > min && serverTime < max) {
          LOG.debug("Adjusting client time from {} to {}",
              new Date(clock.getCurrentEpoch() * 1000), new Date(serverTime * 1000));
          clock.adjust(serverTime);
          return true;
        }
      }
    }
    return false;
  }
  private static String generateRandomId(int length) {
    return UUID.randomUUID().toString().substring(0, length);
  }
}