package nxm.net.lib;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.Authenticator;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.net.URL;
import java.util.Base64;
import java.util.Map;

import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;

import nxm.net.prim.httprequest;
import nxm.sys.lib.Midas;
import nxm.sys.lib.Privileges;
import nxm.sys.lib.Results;
import nxm.sys.lib.SSLUtilities.AcceptAllHostNameVerifier;
import nxm.sys.lib.Shell;
import nxm.sys.lib.Table;

/** HTTP Request support Possible Reuqests - GET|POST|PUT|DELETE
 *  This can be run as a main executable or as an instance
 *  <br>
 *  <br>
 *  {@link httprequest} can execute the commands as well.
 *  @author Kevin Esterheld / Jeanette McCall
 *  @since NeXtMidas 3.9.2
 */
public class HttpRequester {

  /** Flag for HTTP GET request */
  public static final String GET    = "GET";
  /** Flag for HTTP POST request */
  public static final String POST   = "POST";
  /** Flag for HTTP PUT request */
  public static final String PUT    = "PUT";
  /** Flag for HTTP DELETE request */
  public static final String DELETE = "DELETE";
  /** Default timeout in milliseconds */
  public static final int DEFAULT_TIMEOUT = 60000;
  private static String user = null;
  private static String pass = null;
  private Proxy proxy = null;
  private static Base64.Encoder encode = Base64.getEncoder();
  private int openTimeout = DEFAULT_TIMEOUT; // HTTP connection open timeout in milliseconds, 0 for infinite
  private int readTimeout = DEFAULT_TIMEOUT; // HTTP connection read timeout in milliseconds, 0 for infinite
  private boolean DEBUG = false; // enable/disable debug messages (including timing debug)
  private boolean timing = false; // enable/disable timing debug
  private long startTime = 0; // only used when timing is true
  private boolean printWarn = true; // enable/disable printing of all warnings
  private boolean timeoutDebug = false; // enable/disable of warnings related to connect & read timeouts
  private int code = -1; // the most recent HTTP response code (-1 for none)
  private String contentType = null;
  private Midas M;
  private Results MR;
  private HostnameVerifier originalHttpAuth;
  private boolean anyHost = false;
  // constructors
  /** HttpRequestor Constructor
   *  @param M Midas Context
   */
  public HttpRequester (Midas M) {
    this.M = M;
    if (M != null) {
      this.MR = M.results;
    }
    this.originalHttpAuth = HttpsURLConnection.getDefaultHostnameVerifier();
  }

  /** HttpRequestor Constructor
   *  @param openTimeoutMillis open timeout in milliseconds
   *  @param readTimeoutMillis read timeout in milliseconds
   */
  public HttpRequester (int openTimeoutMillis, int readTimeoutMillis) {
    this(Shell.getSharedMidasContext());
    openTimeout = openTimeoutMillis;
    readTimeout = readTimeoutMillis;
  }

  /** Submit an HTTP GET request
   *  @param httpurl URL
   *  @return null on error, otherwise HTTP response data for specified request URL
   */
  public String getPage (String httpurl) {
    return doRequest(httpurl, null, openTimeout, readTimeout, GET);
  }

  /** Submit an HTTP GET request.
   *  @param httpurl URL
   *  @return null on error, otherwise HTTP response data for specified request URL
   */
  public String doRequest (String httpurl) {
    return doRequest(httpurl, null, openTimeout, readTimeout, GET);
  }

  /** Submit an HTTP GET or POST (if body is not null) request.
   *  @param httpurl URL
   *  @param body Body of request (if null will default to GET request)
   *  @return null on error, otherwise HTTP response data for specified request URL
   */
  public String doRequest (String httpurl, String body) {
    return doRequest(httpurl, body, openTimeout, readTimeout, body != null ? POST : GET);
  }

  /** Submit an HTTP request.
   *  @param httpurl URL
   *  @param body Body of request
   *  @param requestType null means use defaults, otherwise use this request type (PUT, DELETE, POST, or GET)
   *  @return null on error, otherwise HTTP response data for specified request URL
   */
  public String doRequest (String httpurl, String body, String requestType) {
    return doRequest(httpurl, body, openTimeout, readTimeout, requestType);
  }

  /** Submit an HTTP POST (if post is not null) request.
   *  @param httpurl URL
   *  @param body Body of request
   *  @return null on error, otherwise HTTP response data for specified request URL
   */
  public String doPost (String httpurl, String body) {
    return doRequest(httpurl, body, openTimeout, readTimeout, POST);
  }

  /** Submit an HTTP request using the specified timeout instead of this.timeout.
   *  @param httpurl URL to submit an HTTP GET or POST request
   *  @param body Body of request
   *  @param openTimeout URL request timeout in milliseconds (when opening connection)
   *  @param readTimeout URL request timeout in milliseconds (when reading response from server)
   *  @param requestType defaults to GET, otherwise use this request type (PUT, DELETE, POST, or GET)
   *  @return null on error, otherwise HTTP response data for specified request URL
   */
  public String doRequest(String httpurl,String body, int openTimeout, int readTimeout, String requestType) {
    try {
      if (anyHost) {
        HttpsURLConnection.setDefaultHostnameVerifier(new AcceptAllHostNameVerifier());

      }
      code = -1; // reset last response code
      if (httpurl==null) { M.warning("Null httpurl to httpdata"); return null; }
      if (DEBUG || timing) { startTime = System.nanoTime(); M.info("***Starting "+httpurl+" Request: "+startTime); }
      URL url = (new URI(httpurl)).toURL();
      HttpURLConnection hcon; // NTN: this needs to be a local variable (otherwise this method won't be thread safe)
      if (proxy!=null) hcon = (HttpURLConnection)url.openConnection(proxy);
      else hcon = (HttpURLConnection)url.openConnection();
      if (DEBUG || timing) { elapsedTime("  Created HTTP Connection   "); }
      if (hcon==null) {
        M.warning("Failed to get Page for User="+user+". Cannot open connection for "+httpurl);
        return null;
      }
      if (openTimeout>0) { hcon.setConnectTimeout(openTimeout); }
      if (readTimeout>0) { hcon.setReadTimeout(readTimeout);    }
      if (user!=null) {
        hcon.setRequestProperty("Authorization", "Basic "+encode.encodeToString((user+":"+pass).getBytes()));

        if (DEBUG || timing) { elapsedTime("  Set HTTP Properties       "); }
      }
      if (this.contentType != null) {
        hcon.setRequestProperty("Content-Type", this.contentType.toLowerCase());
      }

      if (requestType==null) requestType = GET;
      if (body!=null && !requestType.equalsIgnoreCase(GET)) {
        if (DEBUG || timing) { elapsedTime("  Starting HTTP "+requestType+"  "); }
        hcon.setRequestMethod(requestType.toUpperCase());
        hcon.setFixedLengthStreamingMode(body.length());
        hcon.setDoOutput(true);
        try {
          // FYI: the below will cause an implicit connection to the server
          OutputStream posting = hcon.getOutputStream();
          posting.write(body.getBytes());
          posting.flush();
          posting.close();
        } catch (Exception e) {
          M.warning("Failed to write data for User="+user+". Cannot POST to "+httpurl);
          M.warning(e);
          return null;
        }
        if (DEBUG || timing) { elapsedTime("  End HTTP "+requestType+"  "); }
      } else {
        hcon.setRequestMethod(requestType.toUpperCase());
        if (DEBUG || timing) { elapsedTime("  Starting HTTP connect     "); }
        try {
          hcon.connect();
        } catch (SocketTimeoutException sockEx) {
          if (timeoutDebug) M.info("Failed to get Page (connect timeout) for User="+user+". Cannot connect for "+httpurl+"\t"+sockEx);
          return null;
        } catch (Exception e) {
          M.warning("Failed to get Page (connect) for User="+user+". Cannot connect for "+httpurl);
          return null;
        }
        if (DEBUG || timing) { elapsedTime("  End      HTTP connect     "); }
      }
      HttpsURLConnection.setDefaultHostnameVerifier(originalHttpAuth);

      int codeValue;
      if (DEBUG || timing) { elapsedTime("  Starting HTTP ResponseCode"); }
      try {
        code = codeValue = hcon.getResponseCode();
      } catch (Exception e) {
        M.warning("Failed to get Page (Response code) for User="+user+". Cannot get response code for url="+httpurl+". "+e);
        if (hcon!=null) hcon.disconnect();
        return null;
      }
      if (DEBUG || timing) { elapsedTime("  End      HTTP ResponseCode("+codeValue+")"); }

      if (codeValue != HttpURLConnection.HTTP_ACCEPTED && codeValue != HttpURLConnection.HTTP_OK ) {
        M.warning("Failed to get Page (Bad code) for User="+user+". Due to "+codeValue+" "+hcon.getResponseMessage()+" for "+httpurl);
      } else {
        try {
          if (DEBUG || timing) { elapsedTime("  Start HTTP Results read   "); }
          BufferedReader in = new BufferedReader(new InputStreamReader(hcon.getInputStream()));
          String inputLine;
          StringBuilder shttpdata = new StringBuilder(1000);
          //MSZ - had to add the \n at the end b/c it was being lost on text type pages
          while ((inputLine = in.readLine()) != null) shttpdata.append(inputLine).append('\n');
          if(shttpdata.length()>1) shttpdata.deleteCharAt(shttpdata.length()-1); // remove the last \n
          in.close();
          if (DEBUG) { M.info("Response="+shttpdata); }
          if (DEBUG || timing) { elapsedTime("  End   HTTP Results read   "); }
          if (hcon!=null) hcon.disconnect();
          if (DEBUG || timing) { elapsedTime("***End HTTP"); }
          return (shttpdata.length()<1)? "": shttpdata.toString();
        } catch (SocketTimeoutException sockEx) {
          if (timeoutDebug) M.info("Failed to read Page (Read timeout) for User="+user
                                +". Cannot read from "+httpurl+". (code="+codeValue+")");
          hcon.disconnect();
          throw sockEx;
        }
      }
      hcon.disconnect();
      if (DEBUG || timing) { elapsedTime("***End HTTP"); }
    } catch (Exception e) {
      M.error("Error:"+e);
      if (DEBUG || printWarn) e.printStackTrace();
      M.warning("Page="+httpurl);
      HttpsURLConnection.setDefaultHostnameVerifier(originalHttpAuth);

    }
    return null;
  }

  /** Submit an HTTP request using a NeXtMidas table
   *  @param table NeXtMidas table containing all values needed
   *  @return null on error, otherwise HTTP response data for specified request URL
   */
  public String doRequest (Table table) {
    String url = table.getS("URL");
    String user = table.getS("USER");
    String pass = table.getS("PASS");
    if (user != null) {
      if (pass == null) {
        M.info("When user is defined, pass must also be defined");
        return null;
      }
      setUserPass(user, pass);
    }
    int timeout = table.getL("TIMEOUT", DEFAULT_TIMEOUT);
    int openTimeout = table.getL("OPENTIMEOUT", timeout);
    int readTimeout = table.getL("READTIMEOUT", timeout);
    String request = table.getS("REQUEST", null);
    setContentType(table.getS("CONTENTTYPE"));
    setProxy(table.getS("PROXYHOST"), table.getL("PROXYPORT"));
    String body = table.getS("BODY");
    if(contentType != null && contentType.equalsIgnoreCase("application/json") && body!=null) {
      body = body.replaceAll("'", "\"");
    }
    String label = table.getS("LABEL");
    anyHost = table.getZ("ANYHOST", false);
    String response = doRequest(url, body, openTimeout, readTimeout, request);
    if (response != null) {
      if (label != null) {
        MR.put(label, response);
      }
    }

    return response;
  }

  /** Adds ability to send in a Map for the configuration of the http reqeust
   *
   *  @param table Map of the configuration
   *  @return The response of the request
   */
  public String doRequest (Map<String, ?> table) {
    Table tbl = new Table(table);
    return doRequest(tbl);
  }

  /** Run HttpRequester as a main executable
   *  @param args commandline arguments for HttpRequester main executable
   */
  public static void main (String args[]) {
    boolean runSSLConfigure = false;
    try {
      if (args.length < 1) {
        Midas.log("Usage: URL=<url> USER=<user> PASS=<pass> TIMEOUT=<timeout> OPENTIMEOUT=<opentimeout> READTIMEOUT<readtimeout> BODY=<body> REQUEST=<request> CONTENTTYPE=<contenttype>", Midas.INFO);
        return;
      }
      Table tbl = new Table();
      for(String arg : args) {
        String[] keyval = arg.split("=");
        if (keyval[0].equalsIgnoreCase("sslconfigure")) {runSSLConfigure=true; continue;}
        if (keyval.length==1) {
          if (keyval[0].equalsIgnoreCase("anyhost")) tbl.putIfAbsent(keyval[0].toUpperCase(), true);
          else {
            Midas.log("Ignoring parameter "+ keyval[0].toUpperCase() + " because no value was assigned to it", Midas.WARNING);
          }
        }
        tbl.putIfAbsent(keyval[0].toUpperCase(), keyval.length == 2 ? keyval[1] : ""); // if it is a key without a value, set it to true as if it is present
      }
     // get the sslconfigure part complete first
      if (runSSLConfigure) {
        Shell.setPriv(Privileges.PRIV_EXECUTE); // had to do this to get sslconfigure to run the command "which firefox"
        Shell.execCommandAndWait(Shell.getSharedMidasContext(), "sslconfigure");
      }
      HttpRequester hg = new HttpRequester(Shell.getSharedMidasContext());
      String response = hg.doRequest(tbl);
      Midas.log("HTTP RESPONSE = " + response, Midas.INFO);
    } catch (Exception e) {
      e.printStackTrace(System.err);
      System.exit(1);
    }
    System.exit(0);
  }

  /** Call me before connecting if you wish to set a timeout (open and read) for this connection.
   *  @param timeoutMillis timeout in milliseconds
   */
  public void setTimeout (int timeoutMillis) {
    openTimeout = readTimeout = timeoutMillis;
  }

  /** Get the open timeout for the current connection
   *  @return open timeout for the current connection
   */
  public int getOpenTimeout () {
    return openTimeout;
  }

  /** Get the read timeout for the current connection
   *  @return read timeout for the current connection
   */
  public int getReadTimeout () {
    return readTimeout;
  }
  /** Set the open timeout for the current connection
   *  @param timeout new open timeout for the current connection
   */
  public void setOpenTimeout (int timeout) {
    this.openTimeout = timeout;
  }

  /** Set the read timeout for the current connection
   *  @param timeout new read timeout for the current connection
   */
  public void setReadTimeout (int timeout) {
    this.readTimeout = timeout;
  }

  /** get the most recent HTTP response code (-1 for none or if no code can be discerned).
   *  @return the most recent HTTP response code
   */
  public int getResponseCode () {
    return code;
  }

  /** Get the current proxy server being used for this instance
   *  @return the current proxy server being used for this instance
   */
  public Proxy getProxy () {
    return proxy;
  }

  /** Set proxy server to use for this instance.
   *  @param phost proxy hostname, null to clear current proxy setting
   *  @param pport proxy port, 0 or negative to clear current proxy setting
   */
  public void setProxy (String phost, int pport) {
    if (phost == null || pport < 1) {
      proxy = null;
      return;
    }
    if (DEBUG) M.info("Setting proxy " + phost + ":" + pport);
    try {
      InetSocketAddress isa = new InetSocketAddress(phost, pport);
      proxy = new Proxy(Proxy.Type.HTTP, isa);
    } catch (Exception e) {
      e.printStackTrace();
      M.warning("httpgetdata: Exception setting proxy=" + e);
    }
  }

  /** Get the content type
   *  @return the content type
   */
  public String getContentType () {
    return contentType;
  }

  /** Set the content type
   *  @param contentType content type
   */
  public void setContentType (String contentType) {
    this.contentType = contentType;
  }

  /** Set username and password for web server BASIC authentication.
   *  @param user user name
   *  @param pass user password
   */
  public void setUserPass (String user, String pass) {
    HttpRequester.user = user;
    HttpRequester.pass = pass;
    if (DEBUG) M.info("Setting user:" + user + " and password");
    if (user == null || pass == null) {
      M.warning("httpgetdata: Bad Credentials - Null user or Null password");
    }
    else {
      Authenticator.setDefault(new MyAuthenticator(user, pass));
    }
  }

  /** turn on/off debug output
   *  @param dbg true to turn on debugging output
   */
  public void setDEBUG (boolean dbg) {
    DEBUG = dbg;
  }

  /** turn on/off printing of warning messages to console.
   *  @param b true to turn on printing of warning messages to console
   */
  public void setShowWarn (boolean b) {
    printWarn = b;
  }

  /** is debug output on or off
   *  @return true if debug output is on
   */
  public boolean isDEBUG () {
    return DEBUG;
  }

  /** is timing debug output on or off
   *  @return true if timeing debug is on
   */
  public boolean isTiming () {
    return timing;
  }

  /** is printing of warning messages to console enabled?
   *  @return true if printing of warning messages to console is enabled
   */
  public boolean isShowWarn () {
    return printWarn;
  }

  /** is printing of timeout warning messages to console enabled?
   *  @return true if printing of timeout warning messages to console is enabled
   */
  public boolean isTimeoutDebug () {
    return timeoutDebug;
  }

  /** turn on/off printing of timeout warning messages to console.
   *  @param b true to turn on printing of timeout warning messages to console
   */
  public void setTimeoutDebug (boolean b) {
    timeoutDebug = b;
  }

  /** Print elapsed time (in nanoseconds) since previous <code>startTime</code> with specified prefix message prepended
   *  to output.
   *  @param prefixMessage message to add on as a prefix before the elapsed time
   */
  final void elapsedTime (String prefixMessage) {
    M.info(prefixMessage + ": " + (System.nanoTime() - startTime));
  }

  /** turn on timing debug output */
  public void startTiming () {
    timing = true;
  }

  /** turn off timing debug output */
  public void stopTiming () {
    timing = false;
  }

  /** Get the current state of anyHost
   *  @return the anyHost
   */
  public boolean isAnyHost () {
    return anyHost;
  }

  /** Set the new state of anyHost
   *  @param anyHost the anyHost to set
   */
  public void setAnyHost (boolean anyHost) {
    this.anyHost = anyHost;
  }
}
