content: /*
   * Copyright 2013 Hannes Janetzek
   * Copyright 2017 ale5000
   *
   * This file is part of the OpenScienceMap project (http://www.opensciencemap.org).
   *
   * This program is free software: you can redistribute it and/or modify it under the
   * terms of the GNU Lesser General Public License as published by the Free Software
   * Foundation, either version 3 of the License, or (at your option) any later version.
   *
   * This program is distributed in the hope that it will be useful, but WITHOUT ANY
   * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
   * PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
   *
   * You should have received a copy of the GNU Lesser General Public License along with
   * this program. If not, see <http://www.gnu.org/licenses/>.
   */
  package org.oscim.tiling.source;
  
  import org.oscim.core.Tile;
  import org.oscim.utils.ArrayUtils;
  import org.oscim.utils.IOUtils;
  import org.slf4j.Logger;
  import org.slf4j.LoggerFactory;
  
  import java.io.BufferedInputStream;
  import java.io.IOException;
  import java.io.InputStream;
  import java.io.OutputStream;
  import java.net.InetSocketAddress;
  import java.net.Socket;
  import java.net.URL;
  import java.net.UnknownHostException;
  import java.util.Map.Entry;
  import java.util.zip.GZIPInputStream;
  
  /**
   * Lightweight HTTP connection for tile loading. Does not do redirects,
   * https, full header parsing or other stuff.
   */
  public class LwHttp implements HttpEngine {
      static final Logger log = LoggerFactory.getLogger(LwHttp.class);
      static final boolean dbg = false;
  
      private static final byte[] HEADER_HTTP_OK = "200 OK".getBytes();
      private static final byte[] HEADER_CONTENT_LENGTH = "Content-Length".getBytes();
      private static final byte[] HEADER_CONNECTION_CLOSE = "Connection: close".getBytes();
      private static final byte[] HEADER_ENCODING_GZIP = "Content-Encoding: gzip".getBytes();
  
      private static final int RESPONSE_EXPECTED_LIVES = 100;
      private static final long RESPONSE_TIMEOUT = (long) 10E9; // 10 second in nanosecond
  
      private static final int CONNECT_TIMEOUT = 15000; // 15 seconds
      private static final int SOCKET_TIMEOUT = 8000; // 8 seconds
  
      private static final int BUFFER_SIZE = 8192;
      private final byte[] buffer = new byte[BUFFER_SIZE];
  
      private final String mHost;
      private final int mPort;
  
      private int mMaxRequests = 0;
      private Socket mSocket;
      private OutputStream mCommandStream;
      private Buffer mResponseStream;
      private long mLastRequest = 0;
      private InetSocketAddress mSockAddr;
  
      /**
       * Server requested to close the connection
       */
      private boolean mMustCloseConnection;
  
      private final byte[] REQUEST_GET_START;
      private final byte[] REQUEST_GET_END;
      private final byte[] mRequestBuffer;
  
      private final byte[][] mTilePath;
      private final UrlTileSource mTileSource;
  
      //private boolean mUseGZIP;
  
      private LwHttp(UrlTileSource tileSource, byte[][] tilePath) {
          mTilePath = tilePath;
          mTileSource = tileSource;
  
          URL url = tileSource.getUrl();
          int port = url.getPort();
          if (port < 0)
              port = 80;
  
          mHost = url.getHost();
          mPort = port;
  
          String path = url.getPath();
  
          REQUEST_GET_START = ("GET " + path).getBytes();
  
          StringBuilder sb = new StringBuilder()
                  .append(" HTTP/1.1")
                  .append("\r\nUser-Agent: vtm/0.5.9")
                  .append("\r\nHost: ")
                  .append(mHost)
                  .append("\r\nConnection: Keep-Alive");
  
          for (Entry<String, String> l : tileSource.getRequestHeader().entrySet()) {
              String key = l.getKey();
              String val = l.getValue();
              //if ("Accept-Encoding".equals(key) && "gzip".equals(val))
              //    mUseGZIP = true;
              sb.append("\r\n").append(key).append(": ").append(val);
          }
          sb.append("\r\n\r\n");
  
          REQUEST_GET_END = sb.toString().getBytes();
  
          mRequestBuffer = new byte[1024];
          System.arraycopy(REQUEST_GET_START, 0,
                  mRequestBuffer, 0,
                  REQUEST_GET_START.length);
      }
  
      static final class Buffer extends BufferedInputStream {
          OutputStream cache;
          int bytesRead = 0;
          int bytesWrote;
          int marked = -1;
          int contentLength;
  
          public Buffer(InputStream is) {
              super(is, BUFFER_SIZE);
          }
  
          public void setCache(OutputStream cache) {
              this.cache = cache;
          }
  
          public void start(int length) {
              bytesRead = 0;
              bytesWrote = 0;
              contentLength = length;
          }
  
          public boolean finishedReading() {
              try {
                  while (bytesRead < contentLength && read() >= 0) ;
              } catch (IOException e) {
                  log.debug(e.getMessage());
              }
  
              return bytesRead == contentLength;
          }
  
          @Override
          public void close() throws IOException {
              if (dbg)
                  log.debug("close()... ignored");
          }
  
          @Override
          public synchronized void mark(int readlimit) {
              if (dbg)
                  log.debug("mark {}", readlimit);
  
              marked = bytesRead;
              super.mark(readlimit);
          }
  
          @Override
          public synchronized long skip(long n) throws IOException {
              /* Android(4.1.2) image decoder *requires* skip to
               * actually skip the requested amount.
               * https://code.google.com/p/android/issues/detail?id=6066 */
              long sumSkipped = 0L;
              while (sumSkipped < n) {
                  long skipped = super.skip(n - sumSkipped);
                  if (skipped != 0) {
                      sumSkipped += skipped;
                      continue;
                  }
                  if (read() < 0)
                      break; // EOF
  
                  sumSkipped += 1;
                  /* was incremented by read() */
                  bytesRead -= 1;
              }
  
              if (dbg)
                  log.debug("skip:{}/{} pos:{}", n, sumSkipped, bytesRead);
  
              bytesRead += sumSkipped;
              return sumSkipped;
          }
  
          @Override
          public synchronized void reset() throws IOException {
              if (dbg)
                  log.debug("reset");
  
              if (marked >= 0)
                  bytesRead = marked;
  
              /* TODO could check if the mark is already invalid */
              super.reset();
          }
  
          @Override
          public int read() throws IOException {
              if (bytesRead >= contentLength)
                  return -1;
  
              int data = super.read();
  
              if (data >= 0)
                  bytesRead += 1;
  
              if (cache != null && bytesRead > bytesWrote) {
                  bytesWrote = bytesRead;
                  cache.write(data);
              }
  
              return data;
          }
  
          @Override
          public int read(byte[] buffer, int offset, int byteCount)
                  throws IOException {
  
              if (bytesRead >= contentLength)
                  return -1;
  
              int len = super.read(buffer, offset, byteCount);
  
              if (dbg)
                  log.debug("read {} {} {}", len, bytesRead, contentLength);
  
              if (len <= 0)
                  return len;
  
              bytesRead += len;
  
              if (cache != null && bytesRead > bytesWrote) {
                  int add = bytesRead - bytesWrote;
                  bytesWrote = bytesRead;
                  cache.write(buffer, offset + (len - add), add);
              }
  
              return len;
          }
      }
  
      private void checkSocket() throws IOException {
          if (mSocket == null)
              throw new IOException("No Socket");
      }
  
      @Override
      public synchronized InputStream read() throws IOException {
          checkSocket();
  
          Buffer is = mResponseStream;
          is.mark(BUFFER_SIZE);
          is.start(BUFFER_SIZE);
  
          byte[] buf = buffer;
          boolean first = true;
          boolean gzip = false;
  
          int read = 0;
          int pos = 0;
          int end = 0;
          int len = 0;
  
          int contentLength = -1;
  
          /* header may not be larger than BUFFER_SIZE for this to work */
          for (; (pos < read) || ((read < BUFFER_SIZE) &&
                  (len = is.read(buf, read, BUFFER_SIZE - read)) >= 0); len = 0) {
  
              read += len;
              /* end of header lines */
              while (end < read && (buf[end] != '\n'))
                  end++;
  
              if (end == BUFFER_SIZE) {
                  throw new IOException("Header too large!");
              }
  
              if (buf[end] != '\n')
                  continue;
  
              /* empty line (header end) */
              if (end - pos == 1) {
                  end += 1;
                  break;
              }
  
              if (first) {
                  first = false;
                  /* check only for OK ("HTTP/1.? ".length == 9) */
                  if (!check(HEADER_HTTP_OK, buf, pos + 9, end)) {
                      throw new IOException("HTTP Error: "
                              + new String(buf, pos, end - pos - 1));
                  }
              } else if (check(HEADER_CONTENT_LENGTH, buf, pos, end)) {
                  /* parse Content-Length */
                  contentLength = parseInt(buf, pos +
                          HEADER_CONTENT_LENGTH.length + 2, end - 1);
              } else if (check(HEADER_ENCODING_GZIP, buf, pos, end)) {
                  gzip = true;
              } else if (check(HEADER_CONNECTION_CLOSE, buf, pos, end)) {
                  mMustCloseConnection = true;
              }
  
              if (dbg) {
                  String line = new String(buf, pos, end - pos - 1);
                  log.debug("> {} <", line);
              }
  
              pos += (end - pos) + 1;
              end = pos;
          }
  
          /* back to start of content */
          is.reset();
          is.mark(0);
          is.skip(end);
          is.start(contentLength);
  
          if (gzip) {
              return new GZIPInputStream(is);
          }
          return is;
      }
  
      @Override
      public synchronized void sendRequest(Tile tile) throws IOException {
  
          if (mSocket != null) {
              if (--mMaxRequests < 0)
                  close();
              else if (System.nanoTime() - mLastRequest > RESPONSE_TIMEOUT)
                  close();
              else {
                  try {
                      int n = mResponseStream.available();
                      if (n > 0) {
                          log.debug("left over bytes {} ", n);
                          close();
                      }
                  } catch (IOException e) {
                      log.debug(e.getMessage());
                      close();
                  }
              }
          }
  
          if (mSocket == null) {
              /* might throw IOException */
              lwHttpConnect();
  
              /* TODO parse from header */
              mMaxRequests = RESPONSE_EXPECTED_LIVES;
          }
  
          int pos = REQUEST_GET_START.length;
          int len = REQUEST_GET_END.length;
  
          pos = formatTilePath(tile, mRequestBuffer, pos);
          System.arraycopy(REQUEST_GET_END, 0, mRequestBuffer, pos, len);
          len += pos;
  
          if (dbg)
              log.debug("request: {}", new String(mRequestBuffer, 0, len));
  
          try {
              writeRequest(len);
          } catch (IOException e) {
              log.debug("recreate connection");
              close();
  
              lwHttpConnect();
              writeRequest(len);
          }
      }
  
      private void writeRequest(int length) throws IOException {
          mCommandStream.write(mRequestBuffer, 0, length);
          //mCommandStream.flush();
      }
  
      private synchronized void lwHttpConnect() throws IOException {
          if (mSockAddr == null || mSockAddr.isUnresolved()) {
              mSockAddr = new InetSocketAddress(mHost, mPort);
              if (mSockAddr.isUnresolved())
                  throw new UnknownHostException(mHost);
          }
  
          try {
              mSocket = new Socket();
              mSocket.setTcpNoDelay(true);
              mSocket.setSoTimeout(SOCKET_TIMEOUT);
              mSocket.connect(mSockAddr, CONNECT_TIMEOUT);
              mCommandStream = mSocket.getOutputStream();
              mResponseStream = new Buffer(mSocket.getInputStream());
  
              mMustCloseConnection = false;
          } catch (IOException e) {
              close();
              throw e;
          }
      }
  
      @Override
      public void close() {
          IOUtils.closeQuietly(mSocket);
          synchronized (this) {
              mSocket = null;
              mCommandStream = null;
              mResponseStream = null;
          }
      }
  
      @Override
      public synchronized void setCache(OutputStream os) {
          if (mSocket == null)
              return;
  
          mResponseStream.setCache(os);
      }
  
      @Override
      public synchronized boolean requestCompleted(boolean ok) {
          if (mSocket == null)
              return false;
  
          mLastRequest = System.nanoTime();
          mResponseStream.setCache(null);
  
          if (!ok || mMustCloseConnection || !mResponseStream.finishedReading())
              close();
  
          return ok;
      }
  
      /**
       * write (positive) integer to byte array
       */
      private static int writeInt(int val, int pos, byte[] buf) {
          if (val == 0) {
              buf[pos] = '0';
              return pos + 1;
          }
  
          int i = 0;
          for (int n = val; n > 0; n = n / 10, i++)
              buf[pos + i] = (byte) ('0' + n % 10);
  
          ArrayUtils.reverse(buf, pos, pos + i, 1);
  
          return pos + i;
      }
  
      /**
       * parse (positive) integer from byte array
       */
      private static int parseInt(byte[] buf, int pos, int end) {
          int val = 0;
          for (; pos < end; pos++)
              val = val * 10 + (buf[pos]) - '0';
  
          return val;
      }
  
      private static boolean check(byte[] string, byte[] buffer,
                                   int position, int available) {
  
          int length = string.length;
  
          if (available - position < length)
              return false;
  
          for (int i = 0; i < length; i++)
              if (buffer[position + i] != string[i])
                  return false;
  
          return true;
      }
  
      /**
       * @param tile the Tile
       * @param buf  to write url string
       * @param pos  current position
       * @return new position
       */
      private int formatTilePath(Tile tile, byte[] buf, int pos) {
          if (mTilePath == null) {
              String url = mTileSource.getUrlFormatter()
                      .formatTilePath(mTileSource, tile);
              byte[] b = url.getBytes();
              System.arraycopy(b, 0, buf, pos, b.length);
              return pos + b.length;
          }
  
          for (byte[] b : mTilePath) {
              if (b.length == 1) {
                  if (b[0] == '/') {
                      buf[pos++] = '/';
                      continue;
                  } else if (b[0] == 'X') {
                      pos = writeInt(tile.tileX, pos, buf);
                      continue;
                  } else if (b[0] == 'Y') {
                      pos = writeInt(tile.tileY, pos, buf);
                      continue;
                  } else if (b[0] == 'Z') {
                      pos = writeInt(tile.zoomLevel, pos, buf);
                      continue;
                  }
              }
              System.arraycopy(b, 0, buf, pos, b.length);
              pos += b.length;
          }
          return pos;
  
      }
  
      public static class LwHttpFactory implements HttpEngine.Factory {
          private byte[][] mTilePath;
  
          @Override
          public HttpEngine create(UrlTileSource tileSource) {
              if (tileSource.getUrlFormatter() != UrlTileSource.URL_FORMATTER)
                  return new LwHttp(tileSource, null);
  
              /* use optimized formatter replacing the default */
              if (mTilePath == null) {
                  String[] path = tileSource.getTilePath();
                  mTilePath = new byte[path.length][];
                  for (int i = 0; i < path.length; i++)
                      mTilePath[i] = path[i].getBytes();
              }
              return new LwHttp(tileSource, mTilePath);
          }
      }
  }
  