package nxm.sys.net;

import java.io.IOException;
import java.net.BindException;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.ServerSocket;
import java.net.Socket;
import java.security.NoSuchAlgorithmException;
import java.util.Enumeration;
import java.util.LinkedList;
import java.util.logging.Level;

import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLServerSocket;

import nxm.sys.inc.InternalUseOnly;
import nxm.sys.lib.Command;
import nxm.sys.lib.Convert;
import nxm.sys.lib.LogUtilities;
import nxm.sys.lib.Macro;
import nxm.sys.lib.Midas;
import nxm.sys.lib.Shell;
import nxm.sys.lib.StringUtil;

/** Simple implementation of an HTTP/1.1 server.
    For details of the HTTP/1.1 specification see RFC 2616.
    @author J. Czechowski IV / Neon Ngo
 */
public class HServer implements Runnable {
  /** The default port that the web server listens on. */
  public static final int DEFAULT_PORT = 8080;

  /** The default number of workers that the web server uses. */
  public static final int DEFAULT_NUM_WORKERS = 5;

  /** The standard buffer size that will be allocated to read data from the socket. */
  public static final int BUF_SIZE = 2048;

  private static       boolean debug  = false; // Controls the outputting of debug messages.
  private static       boolean debug2 = false; // Controls the outputting of additional debug messages.
  private static final int     TIMEOUT = 1000;  //  Timeout on server socket (SO_TIMEOUT) in milliseconds

  /** Midas context to use (can be null) */
  private Midas               M = null;
  private LinkedList<HSource> sources = new LinkedList<>(); // Data sources available to clients
  private ServerSocket        serverSocket;                        // Server socket (null = stop the thread)
  private String              host;                                // The host name
  private int                 port;                                // The port that the web server is listening on.
  private int                 prange = 1;                          // The range of port numbers above port to use if already allocated.
  private String              dsTitle = "nxm.sys.net.HServer";     // The title of this servers data sources page
  private String              homePage;                            // The home page of this Server
  private String              homeDir;                             // The home directory of this Server
  private String              appName;                             // The application name of this Server
          Command             parent;                              // The application Macro of this Server

  /** True if authentication is only one way, false if it is 2 way
   * @since NeXtMidas 3.9.0
   */
  private static boolean oneWay;
  /** True if it is an https server, false if it is http
   *  @since NeXtMidas 3.9.0
   */
  private static boolean      isHttps;                             // Whether or not this will use https or not

  /** Create an HServer and start its thread
      @param midas  Midas context
      @param title  Title of the data sources page
      @param port   The port to be used by this HServer
      @param prange The range of port numbers above port to use if already allocated
      @return A new, running HServer.
      @since NeXtMidas 2.9.3
   */
  public static HServer launch (Midas midas, String title, int port, int prange) {
    HServer ws = new HServer(midas, port, prange);
    ws.dsTitle = title;
    ws.open();
    Thread thread = new Thread(ws);
    thread.start();
    return ws;
  }

  /** Create an HServer and start its thread. Note, since the Midas context
      is not supplied here, files from RAM AUX might not be found.
      @param title Title of the data sources page
      @param port  The port to be used by this HServer
      @return A new, running HServer.
      @see #launch(Midas, String, int, int)
   */
  public static HServer launch (String title, int port) {
    return launch(null, title, port, 1);
  }

  /** Open a HTTP server. This is only called from open(), it is separate from open() to
      allow subclasses to override.
      @param ref      The reference ({@link Midas} context or {@link Command}).
      @param title    The title of the server (to be displayed to users).
      @param port     The port to be used by the server.
      @param prange   The range of port numbers above port to use if already allocated
      @param homepage The home page to show (null = don't show).
      @param auxlist  The AUX list to show. One or more AUX names separated by a "|"
                      or "*" to serve up all AUXs in AUX.READ. (Use null or "NULL" to
                      disable serving up of AUXs.
      @return A new, running HServer.
      @since NeXtMidas 2.9.3
   */
  public static HServer launch (Object ref, String title, int port, int prange, String homepage, String auxlist) {
    Midas M = Convert.ref2Midas(ref);
    if (M == null) M = Shell.getSharedMidasContext();
    HServer ws = HServer.launch(M, title, port, prange);
    if (ref instanceof Command) ws.setParent((Command)ref);
    if (homepage != null) ws.setHomePage(homepage);
    if (M.macro!=null) ws.addSource( new HQuery("Controls",M.macro.controls,null,128) );
    if (M.macro!=null) ws.addSource( new HQuery("Registry",M.macro.getRegistry(),null,128) );
    if (M.macro!=null) ws.addSource( new HQuery("Results",M.macro.getResults(),null,128) ); // since 3.7.0 for BUG 2847
    if (!StringUtil.isNull(auxlist)) ws.addSource( new HFiles("Files",M,auxlist) );
    ws.addSource( new HSystem("System",M) );
    return ws;
  }

  /** Open a HTTP server. This is only called from open(), it is separate from open() to
      allow subclasses to override.
      @param ref      The reference ({@link Midas} context or {@link Command}).
      @param title    The title of the server (to be displayed to users).
      @param port     The port to be used by the server.
      @param homepage The home page to show (null = don't show).
      @param auxlist  The AUX list to show. One or more AUX names separated by a "|"
                      or "*" to serve up all AUXs in AUX.READ. (Use null or "NULL" to
                      disable serving up of AUXs.
      @return A new, running HServer.
      @since NeXtMidas 2.7.0
   */
  public static HServer launch (Object ref, String title, int port, String homepage, String auxlist) {
    return launch(ref, title, port, 1, homepage, auxlist);
  }

  /** Initializes the new instance to listen on the default port which is
      set by the {@link #DEFAULT_PORT} static member.
   */
  public HServer () {
    this(null, DEFAULT_PORT, 1);
  }

  /** Initializes the new instance to listen on the specified port.  The new
      instance will be able to respond to requests for the root object only.
      @param port the port number, or <code>0</code> to use any free port.
   */
  public HServer (int port) {
    this(null, port, 1);
  }

  /** Initializes the new instance to listen on the specified port.  The new
      instance will be able to respond to requests for the root object only.
      @param midas  the Midas context to use
      @param port   the port number, or <code>0</code> to use any free port.
      @param prange the range of port numbers above port to use if already allocated.
      @since NeXtMidas 2.9.3
   */
  public HServer (Midas midas, int port, int prange) {
    this.M = midas;
    this.port = port;
    this.prange = prange;
    log("Creating new server starting on port " + port +" with range of "+prange);
    addSource ( new ServerIndex() );
    addSource ( new HFiles("nmroot",Shell.getNmRoot()) );
  }


  /** set the Data Sources page title
   *  @param title the Data Sources page title */
  public void setTitle (String title) {
    dsTitle = title;
  }

  /** Get the servers Midas context.
      @return The Midas context.
   */
  public Midas getMidas () {
    return M;
  }

  /** Get the servers port number.
      @return The port number.
   */
  public int getPort () {
    return port;
  }

  /** Get the server's host name.
      @return The server's host name or "unknown" if unknown.
   */
  public String getHost () {
    if (host==null) {
      InetAddress ia = getHostIA();
      host = (ia==null)?  "unknown" : ia.getHostAddress();
    }
    return host;
  }

  /** Get the server's host:port name.
      @return The server's host name or "unknown" if unknown.
   */
  public String getHostPort () {
    return getHost()+":"+getPort();
  }

  /** set the Data Sources page application name
   *  @param name the Data Sources page application name */
  public void setAppName (String name) {
    int i,j,k=name.length(),m=80;
    for (i=j=0; i<k && i<m; i++) {
      if (name.charAt(i)==',') j = i;
    }
    if (i==k) appName = name;
    else appName = name.substring(0,j)+", ...";
  }

  /** Set the command that is running this server. This is used to determine context-sensitive
      pages provided, such as the home page, application name, documentation, etc. The amount of
      context-sensitive is reduced if the parent was not launched from a macro.
      @param parent The parent for this server. This is usually the primitive that started the service.
   */
  public void setParent (Command parent) {
    String tmp;
    this.parent = parent;
    Macro macro = parent.M.macro;
    if (macro==null) return;
    tmp = "/nmroot/nxm/"+macro.args.option+"/mcr/"+macro.args.name+".html";
    setHomePage(tmp.toLowerCase());
    tmp = macro.toString();
    int i1 = tmp.indexOf(":");
    int i2 = tmp.indexOf(",/"); if (i2<0) i2=tmp.length();
    setAppName(tmp.substring(i1+1,i2));
  }

  /** Adds the specified HSource to the server under the given name. If the name does not begin
      with a slash (/), one will be added to the name. If another source already exists with a
      given name, that source is removed prior to adding this one. Upon returning from this method
      the source object will be available to clients by using the name specified by the object.
      This method does nothing if either <code>name</code> or <code>source</code> are <code>null</code>.
      @param name     The name of the source.
      @param source   The source that handles the requests.
      @param override If true, the source will be put on the front of the source list, if false the
                      source will be put on the end of the source list. When a request comes in the
                      sources are checked front-to-back to see if there is a match, if multiple
                      sources could match the request, the first one found will be used.
      @since NeXtMidas 2.7.1
   */
  public void addSource (String name, HSource source, boolean override) {
    if (name==null || source==null) return;
    if (!name.startsWith("/")) name = "/"+name;
    log("Adding " + name);
    synchronized (sources) {
      removeSource(name);
      if (override) sources.add(0, source); // add to front
      else          sources.add(source);    // add to end
    }
    source.setServer(this);
  }


  /** Adds the specified HSource to the server under the given name. This is identical to
      <code>addSource(name,source,false)</code>.
      @param name     The name of the source.
      @param source   The source that handles the requests.
   */
  public void addSource (String name, HSource source) {
    addSource(name,source,false);
  }

  /** Adds the specified HSource to the server under the given name. This is identical to
      <code>addSource(name,source,false)</code> where <code>name</code> is equal to <code>source.getName()</code>.
      @param source   The source that handles the requests.
   */
  public void addSource (HSource source) {
    String name = (source == null)? null : source.getName();
    addSource(name,source,false);
  }

  /** Adds the specified HSource to the server under the given name. This is identical to
      <code>addSource(name,source,override)</code> where <code>name</code> is equal to <code>source.getName()</code>.
      @param source   The source that handles the requests.
      @param override Override other requests?
      @since NeXtMidas 2.7.1
   */
  public void addSource (HSource source, boolean override) {
    String name = (source == null)? null : source.getName();
    addSource(name,source,override);
  }

  /** Removes the specified HSource by name.
      @param name The name of the source.
   */
  public void removeSource (String name) {
    if (!name.startsWith("/")) name = "/"+name;
    synchronized (sources) {
      for (HSource source : sources) {
        if (source.getName().equals(name)) {
          removeSource(source);
          return;
        }
      }
    }
  }

  /** Removes the specified HSource.
      @param source The source to remove.
   */
  public void removeSource (HSource source) {
    if (source==null) return;
    synchronized (sources) {
      sources.remove(source);
    }
  }

  /** Return the registered HSource object for the given request.
      @param req The requested path
      @return The reference to the registered data source or null if one cannot be found.
  */
  public HSource getSource (String req) {
    synchronized (sources) {
      for (HSource source : sources) {
        if (source.canHandleRequest(req)) return source;
      }
    }
    return null;
  }

  /** This will start the server socket in https mode.
   *  @throws IOException Thrown if there is no default SSLContext setup.
   *  @since NeXtMidas 3.9.0
   */
  private void createHttpsServer() throws IOException {
    SSLContext sslContext;
    try {
      // This will use the ssl setup done by sslconfigure
      sslContext = SSLContext.getDefault();
    } catch (NoSuchAlgorithmException e) {
      // TODO Auto-generated catch block
      e.printStackTrace();
      return;
    }

    serverSocket = sslContext.getServerSocketFactory().createServerSocket(port);
    info("Starting HTTP Web Server at URL=https://"+getHost()+":"+port+"/");

    if(!oneWay) {
      ((SSLServerSocket)serverSocket).setWantClientAuth(true);
      ((SSLServerSocket)serverSocket).setNeedClientAuth(true);
    } else {
      ((SSLServerSocket)serverSocket).setWantClientAuth(false);
      ((SSLServerSocket)serverSocket).setNeedClientAuth(false);
    }
  }

  /**This will start the server socket in http mode.
   * @since NeXtMidas 3.9.0
   */
  private void createHttpServer() {
    while (serverSocket == null) {
      try {
        serverSocket = new ServerSocket(port);
        serverSocket.setSoTimeout(TIMEOUT);
        port = serverSocket.getLocalPort(); // update to actual listen port
        info("Starting HTTP Web Server at URL=http://"+getHost()+":"+port+"/");
      }
      catch (BindException be) {
        log("Port "+port+" not available");
        if (--prange <= 0) {
          printStackTrace("HServer.open(): Error opening server socket, no port(s) available ("+port+")", be);
          break; // out of while loop
        }
        port++;  // try next port number
      }
      catch (IOException e) {
        printStackTrace("HServer.open(): Error opening server socket on port "+port, e);
        stopThread();
      }
    }
  }

  /** Calls {@link #createHttpsServer()} or {@link #createHttpServer()} to create a web server
   *  @return the command status (NORMAL in this case)
   */
  public int open () {
    // Attempt to open the server socket. If we can't open it, then simply set
    // it to null and print out the exception. Since the socket is null, the
    // while loop encapsulating the rest of the body won't execute.
    if(isHttps) {
      try {
        createHttpsServer();
      } catch (IOException e) {
        // TODO Auto-generated catch block
        e.printStackTrace();
      }
    }else {
      createHttpServer();
    }
    return Command.NORMAL;
  }

  /** The server simply listens for connections and returns a new socket for every connection request
   *  @return the command status
   */
  public int process () {
    int status = Command.NOOP;
    // The main loop of the server simply listens for connections and returns
    // a new socket for every connection request.
    if (serverSocket != null) {
      try {
        Socket sock = serverSocket.accept();
        sock.setSoTimeout(0); // infinite timeout on client socket
        sock.setTcpNoDelay(true);
        HPage hp = new HPage(this,sock);
        Thread thread = new Thread(hp);
        thread.start();
      }
      catch (java.net.SocketTimeoutException e) {
        // This should occur frequently during periods of inactivity (every TIMEOUT ms)
        if (debug2) log("Socket timeout after "+TIMEOUT+" millisecs on port "+port);
      }
      catch (java.io.InterruptedIOException e) {
        printStackTrace("HServer: Got InterruptedIOException on port "+port, e);
      }
      catch (java.net.SocketException e) {
        if (serverSocket != null) {
          if ("socket closed".equalsIgnoreCase(e.getMessage())) { // Bug 1817: Server Socket has been closed (not an error)
            log("Server Socket closed. exiting HTTP server on port "+port);
          }
          else {
            printStackTrace("HServer: Got SocketException while closing server socket on port "+port, e);
          }
          status = Command.FINISH;
        }
      }
      catch (Exception e) {
        printStackTrace("HServer: Got exception on port "+port,e);
      }
    } else {
      status = Command.FINISH;
    }
    return status;
  }

  /** Calls {@link #stopThread()} to close the web server
   *  @return the command status (NORMAL in this case)
   */
  public int close () {
    stopThread();
    return Command.NORMAL;
  }

  /** Begins the web server running and accepting connections on the currently
      configured port. Assumes open() method has already been called.
  */
  @Override
  public void run () {
    int status = Command.NORMAL;

    while (status != Command.FINISH) {
      status = process();
    }
    close();
  }

  /** Set the thread to stop executing after handling the current client. */
  public synchronized void stopThread () {
    if (serverSocket != null) {
      try {
        serverSocket.close();
      }
      catch (Exception e) {
        printStackTrace("Error closing socket on port "+port, e);
      }
    }
    serverSocket = null;
  }

  /** If the HTTP Server Socket is still running.
      @return true if the HTTP Server Socket is still running
      @since NeXtMidas 2.9.3
   */
  public boolean isRunning() { return (serverSocket != null); }

  /** Returns the set of documents that have been registered with the instance.  */
  protected class ServerIndex extends HSource {
    @Override
    public String getName() { return ""; }

    @Override
    public boolean canHandleRequest (String uri) {
      return uri.equals("/") ||  uri.equals("/header");
    }

    @Override
    public void handleRequest (String uri, HPage hp) {
      if (uri.equals("/")) framesPage(hp);
      else if (uri.equals("/header")) headerPage(hp);
    }
    @Override
    public String toString () { return "Source List"; }
  }

  private void framesPage (HPage hp) {
    String tmp = (homePage != null)? homePage : "/nmroot/";
    boolean hponly = dsTitle!=null && dsTitle.equals("homepageonly"); // since 3.7.0 for BUG 2847
    hp.open();
    hp.writeln("<html>");
    hp.writeln("<head>");
    hp.writeln("<title>HServer Frame</title>");
    hp.writeln("</head>");
    if (hponly) hp.writeln("<frameset rows='0,*'>");                  // since 3.7.0 for BUG 2847
    else        hp.writeln("<frameset rows='65,*'>");
    hp.writeln("<frame src='/header' name='headerFrame'>");
    hp.writeln("<frame src='"+tmp+"' name='dataFrame'>");
    hp.writeln("</frameset>");
    hp.writeln("</html>");
    hp.close();
  }

  private void headerPage (HPage hp) {
    String hostname="unknown", hostaddr="unknown";
    try {
      InetAddress inet = getHostIA();
      hostname = inet.getHostName();
      hostaddr = inet.getHostAddress();
    } catch (Exception e) {
      Shell.warning("Error in host lookup: "+e); // stack trace not relevent, lookup failed
    }
    int hostport = serverSocket.getLocalPort();
    int rootport = hostport / 10 * 10;
    int np = getPartners();
    String parts="&nbsp;&nbsp;"; if (np>0) for (int i=0; i<=np; i++)
      parts += "-<a href='http://"+hostaddr+":"+(rootport+i)+"' target='_top'>"+i+"</a>";
    String hname = hostname.equals(hostaddr.toString())? "" : "Name="+hostname;
    hp.openToBody("HServer Header");
    if (dsTitle!=null) hp.writeln("<div style=\"text-align:center;\"><b>"+dsTitle+"</b>");
    if (appName!=null) hp.writeln("<b>APP: "+appName+"</b>");
    hp.writeln("<b>HOST "+hname+" Address="+hostaddr+" Port="+hostport+"</b>"+parts);
    if (homePage!=null) hp.writeln("<a href='"+homePage+"' target='dataFrame'>Home</a>");
    if (homePage!=null) hp.writeln("<b>-</b>");
    synchronized (sources) {
      for (HSource source : sources) {
        String name = source.getName();
        if (name.length()<=0 || name.equals("/nmroot/")) continue;
        String rname = name;
        if (rname.startsWith("/")) rname=rname.substring(1);
        if (rname.endsWith("/")) rname=rname.substring(0,rname.length()-1);
        hp.writeln("<a href='"+name+"' target='dataFrame'>"+rname+"</a>");
        hp.writeln("<b>-</b>");
      }
    }
    hp.writeln("<a href='/nmroot/htdocs/help/index.html' target='dataFrame'>Help</a>");
    hp.writeln("</div>");
    hp.closeFromBody();
  }

  /** Set the home page
   *  @param page the home page */
  public void setHomePage (String page) {
    if (page==null || page.length()==0) return;
    homePage = page;
    int i = homePage.lastIndexOf('/');
    if (i>0) homeDir = homePage.substring(0,i);
  }

  /** for undoing the macro name being used for homepage */
  @InternalUseOnly
  public void nullHomePage () {
    homePage = null;
  }

  /** Get home directory
   *  @return the home directory
   */
  public String getHomeDir () {
    return homeDir;
  }

  private int getPartners() {
    if (parent instanceof nxm.sys.prim.rmif) return ((nxm.sys.prim.rmif)parent).getPartners();
    return 0;
  }

  /** Turns debugging on to the lowest level of debugging.
   *  @param val true to turn on the lowest level of debug */
  public static void setDebug (boolean val) {
    debug = val;
  }

  /** @deprecated Since NeXtMidas 2.7.1: Use {@link #setDebug(boolean)}.
   *  @param val 0 for debug being off, any other value for debug is on  */
  @Deprecated public static void setDebug (int val) {
    setDebug(val != 0);
  }

  /** Prints the given string to the log file
   *  @param s string to be printed to the log file */
  protected static void log (String s) {
    if (debug) LogUtilities.midasLog(null, Level.INFO, "[DEBUG] HServer : " + s, null);
  }

  /** display info statements using current Midas context (if set), otherwise uses Shell.info(..).
      @param seq string to display info statement
      @see Midas#info(CharSequence)
      @see Shell#info(CharSequence)
      @since NeXtMidas 2.9.3
   */
  protected void info (CharSequence seq) {
    if (M != null) M.info(seq);
    else           Shell.info(seq);
  }

  /** display message along with stacktrace from provided Throwable (exception) using
      to current Midas context (if set), otherwise to Shell.printStackTrace(..).
      @param msg message to display along with stacktrace
      @param t   the error/exception to print stacktrace
      @see Midas#printStackTrace(CharSequence, Throwable)
      @see Shell#printStackTrace(CharSequence, Throwable)
      @since NeXtMidas 2.9.3
*/
  protected void printStackTrace (CharSequence msg, Throwable t) {
    if (M != null) M.printStackTrace(msg, t);
    else           Shell.printStackTrace(msg, t);

  }

  static InetAddress hostip;
  /** get the host address as viewed externally, not the loopback address
   *  @return the host address as viewed externally */
  public static InetAddress getHostIA() {
    if (hostip!=null) return hostip;
    try {
      InetAddress ip=InetAddress.getLocalHost();
      if (!ip.isLoopbackAddress()) return ip;
      ip = null;
      Enumeration<NetworkInterface> netInterfaces = NetworkInterface.getNetworkInterfaces();
      while (netInterfaces.hasMoreElements()) {
        NetworkInterface ni = netInterfaces.nextElement();
        String niname = ni.getName();
        Enumeration<InetAddress> ias =  ni.getInetAddresses();
        while (ias.hasMoreElements()) {
          InetAddress ia = ias.nextElement();
          //System.out.println(ia.getHostAddress()+" "+ia.isSiteLocalAddress()+" "+ia.isLoopbackAddress());
          if (ia.isSiteLocalAddress() && !ia.isLoopbackAddress()) {
            if (ip==null || niname.endsWith("0")) ip = ia;
          }
        }
      }
      //System.out.println("Chose "+ip);
      hostip = ip;
      return ip;
    }
    catch (Exception e) {
      return null;
    }
  }

  /** Set if Https communication
   *  @param isHttps true if Https communication
   */
  public static void setHttps (boolean isHttps) {
    HServer.isHttps = isHttps;
  }

  /** Set if one-way authentication
   *  @param oneWay true if one-way authentication
   */
  public static void setOneWay (boolean oneWay) {
    HServer.oneWay = oneWay;
  }

}
