# SpreadsheetWebApp.java

    /*
     ** Module   : SpreadsheetWebApp.java
     ** Abstract : SPREADSHEET widget web application.
     **
     ** Copyright (c) 2024, Golden Code Development Corporation.
     **
     ** -#- -I- --Date-- ---------------------------------Description---------------------------------
     ** 001 HC  20240228 Created initial version.
     ** 002 HC  20240315 Implemented optional warmup of SPREADSHEET web application backend.
     ** 003 HC  20240513 Moved SPREADSHEET backend (Keikai web application and the related FWD
     **                  integration) from client to server.
     ** 004 HC  20240526 Added Keikai locale setup for both the Conversation and the HTTP request
     **                  threads.
     ** 005 HC  20241203 Improved event handling to better handle rapid key typing with high
     **                  latencies of Keikai backend. The changes simplify event handling by removing
     **                  the need for key events delivered to the backend through a custom web
     **                  socket, now all the events are delivered using the standard Keikai
     **                  mechanism.
     */
    /*
     ** This program is free software: you can redistribute it and/or modify
     ** it under the terms of the GNU Affero 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 Affero General Public License for more details.
     **
     ** You may find a copy of the GNU Affero GPL version 3 at the following
     ** location: https://www.gnu.org/licenses/agpl-3.0.en.html
     **
     ** Additional terms under GNU Affero GPL version 3 section 7:
     **
     **   Under Section 7 of the GNU Affero GPL version 3, the following additional
     **   terms apply to the works covered under the License.  These additional terms
     **   are non-permissive additional terms allowed under Section 7 of the GNU
     **   Affero GPL version 3 and may not be removed by you.
     **
     **   0. Attribution Requirement.
     **
     **     You must preserve all legal notices or author attributions in the covered
     **     work or Appropriate Legal Notices displayed by works containing the covered
     **     work.  You may not remove from the covered work any author or developer
     **     credit already included within the covered work.
     **
     **   1. No License To Use Trademarks.
     **
     **     This license does not grant any license or rights to use the trademarks
     **     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
     **     of Golden Code Development Corporation. You are not authorized to use the
     **     name Golden Code, FWD, or the names of any author or contributor, for
     **     publicity purposes without written authorization.
     **
     **   2. No Misrepresentation of Affiliation.
     **
     **     You may not represent yourself as Golden Code Development Corporation or FWD.
     **
     **     You may not represent yourself for publicity purposes as associated with
     **     Golden Code Development Corporation, FWD, or any author or contributor to
     **     the covered work, without written authorization.
     **
     **   3. No Misrepresentation of Source or Origin.
     **
     **     You may not represent the covered work as solely your work.  All modified
     **     versions of the covered work must be marked in a reasonable way to make it
     **     clear that the modified work is not originating from Golden Code Development
     **     Corporation or FWD.  All modified versions must contain the notices of
     **     attribution required in this license.
     */

    package com.goldencode.p2j.main;

    import com.goldencode.p2j.net.*;
    import com.goldencode.p2j.security.*;
    import com.goldencode.p2j.ui.*;
    import com.goldencode.p2j.ui.SpreadsheetWidget;
    import com.goldencode.p2j.ui.client.event.*;
    import com.goldencode.p2j.util.*;
    import com.goldencode.p2j.util.logging.*;
    import org.eclipse.jetty.annotations.*;
    import org.eclipse.jetty.http.MetaData;
    import org.eclipse.jetty.server.*;
    import org.eclipse.jetty.server.Handler;
    import org.eclipse.jetty.server.handler.*;
    import org.eclipse.jetty.webapp.*;

    import javax.servlet.*;
    import javax.servlet.http.*;
    import java.io.*;
    import java.lang.reflect.*;
    import java.util.*;
    import java.util.concurrent.*;
    import java.util.concurrent.atomic.*;
    import java.util.logging.*;
    import java.util.regex.*;

    /**
     * Represents a web application for managing and interacting with spreadsheets through a web interface.
     * This class initializes and manages the lifecycle of the web application including starting, stopping,
     * and handling of web contexts for spreadsheet widgets. It utilizes Jetty for web server functionality
     * and is designed to be accessed as a singleton instance.
     */
    public class SpreadsheetWebApp
    {
       /** Logger */
       private static final CentralLogger LOG = CentralLogger.get(SpreadsheetWebApp.class.getSimpleName());

       /** Singleton instance of the SpreadsheetWebApp class. */
       private static final SpreadsheetWebApp instance = new SpreadsheetWebApp();

       /** The static part of context path */
       private static final String CONTEXT_PATH = "/_sheet";

       /** WebAppContext for managing the web application's servlet context. */
       private WebAppContext webApp;

       /** The set of active spreadsheet widget ids */
       private Set<Integer> activeWidgets = new HashSet<>();

       /** Thread-local storage for current request */
       private static ThreadLocal<String> currentRequestOrigURI = new ThreadLocal<>();

       /** Represents an atomic integer that is used to generate a unique context ID. */
       private static AtomicInteger lastContextId = new AtomicInteger();

       /**
        * A map that stores the Spreadsheet application contexts.
        * The map is thread-safe and allows concurrent access.
        */
       private static Map<Integer, Context> contexts = new ConcurrentHashMap<>();

       /**
        * Retrieves the singleton instance of the SpreadsheetWebApp.
        *
        * @return  The singleton instance of SpreadsheetWebApp.
        */
       public static SpreadsheetWebApp getInstance()
       {
          return instance;
       }

       /**
        * Returns the current request URI
        *
        * @return  See above.
        */
       public static String getCurrentRequestOrigURI()
       {
          return currentRequestOrigURI.get();
       }

       /**
        * Initializes the web application.
        */
       public Handler init()
       {
          try
          {
             // Override Jetty's web app context to allow handling multiple spreadsheet instances
             // from a single web app while allowing to request the resources with widget-specific
             // URI (/_sheet/<widgetId>). The widget-specific URI allows to bind to the specific driver
             // widget inside the web application.
             webApp = new WebAppContext()
             {
                private Pattern contextIdPattern;

                @Override
                public void setContextPath(String contextPath)
                {
                   super.setContextPath(contextPath);
                   contextIdPattern = Pattern.compile(contextPath + "/\\d+");
                }

                @Override
                public void doScope(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
                throws IOException,
                       ServletException
                {
                   ContextHandler.Context oldContext = null;
                   String oldContextPath = null;
                   String oldServletPath = null;
                   String oldPathInfo = null;
                   ClassLoader oldClassloader = null;
                   Thread currentThread = null;
                   String pathInfo = target;
                   DispatcherType dispatch = baseRequest.getDispatcherType();
                   oldContext = baseRequest.getContext();
                   String contextPath = getContextPath();
                   if (oldContext != this._scontext) {
                      if (DispatcherType.REQUEST.equals(dispatch) || DispatcherType.ASYNC.equals(dispatch)) {
                         if (contextIdPattern != null)
                         {
                            Matcher matcher = contextIdPattern.matcher(target);
                            if (matcher.find())
                            {
                               StringBuffer sb = new StringBuffer();
                               matcher.appendReplacement(sb, contextPath);
                               matcher.appendTail(sb);
                               target = sb.toString();
                            }
                         }

                         if (!this.checkContext(target, baseRequest, response)) {
                            return;
                         }

                         if (target.length() > contextPath.length()) {
                            if (contextPath.length() > 1) {
                               target = target.substring(contextPath.length());
                            }

                            pathInfo = target;
                         } else if (contextPath.length() == 1) {
                            target = "/";
                            pathInfo = "/";
                         } else {
                            target = "/";
                            pathInfo = null;
                         }
                      }

                      if (this.getClassLoader() != null) {
                         currentThread = Thread.currentThread();
                         oldClassloader = currentThread.getContextClassLoader();
                         currentThread.setContextClassLoader(this.getClassLoader());
                      }
                   }

                   ThreadLocal __context = getPrivateFieldValue(ContextHandler.class, this, "__context");

                   try {
                      currentRequestOrigURI.set(baseRequest.getOriginalURI());

                      oldContextPath = baseRequest.getContextPath();
                      oldServletPath = baseRequest.getServletPath();
                      oldPathInfo = baseRequest.getPathInfo();
                      baseRequest.setContext(this._scontext);
                      __context.set(this._scontext);
                      if (!DispatcherType.INCLUDE.equals(dispatch) && target.startsWith("/")) {
                         if (contextPath.length() == 1) {
                            baseRequest.setContextPath("");
                         } else {
                            baseRequest.setContextPath(this.getContextPathEncoded());
                         }

                         baseRequest.setServletPath((String)null);
                         baseRequest.setPathInfo(pathInfo);
                      }

                      if (oldContext != this._scontext) {
                         this.enterScope(baseRequest, dispatch);
                      }

                      this.nextScope(target, baseRequest, request, response);
                   } finally {
                      if (oldContext != this._scontext) {
                         this.exitScope(baseRequest);
                         if (this.getClassLoader() != null && currentThread != null) {
                            currentThread.setContextClassLoader(oldClassloader);
                         }

                         baseRequest.setContext(oldContext);
                         __context.set(oldContext);
                         baseRequest.setContextPath(oldContextPath);
                         baseRequest.setServletPath(oldServletPath);
                         baseRequest.setPathInfo(oldPathInfo);

                         currentRequestOrigURI.set(null);
                      }

                   }

                }
             };

             // add AnnotationConfiguration to the list of default configurations to initialize jsp
             // (JasperInitializer is called)
             webApp.setConfigurations(new org.eclipse.jetty.webapp.Configuration[]
             {
                new WebInfConfiguration(),
                new WebXmlConfiguration(),
                new MetaInfConfiguration(),
                new FragmentConfiguration(),
                new JettyWebXmlConfiguration(),
                new AnnotationConfiguration()
             });

             webApp.setWar(getWARPath().getAbsolutePath());
             webApp.setContextPath(CONTEXT_PATH);

             return webApp;
          }
          catch (Throwable e)
          {
             LOG.log(Level.WARNING, "Spreadsheet web app init error.", e);
             return null;
          }
       }

       /**
        * Starts the web application for a specific spreadsheet widget identified by its widget ID. This method
        * sets the context path and starts the web application context. If the application fails to start,
        * it logs the error and returns null.
        *
        * @param   widgetId
        *          The ID of the widget for which the web application is to be started.
        *
        * @return  The path to the started web context or null if the application failed to start.
        */
       public String start(int widgetId)
       {
          if (webApp == null || webApp.isFailed() || !webApp.isAvailable())
          {
             Throwable ex = webApp.getUnavailableException();
             LOG.log(Level.WARNING, "Failed to start Spreadsheet web application!", ex);

             webApp = null;
             return null;
          }

          activeWidgets.add(widgetId);
          return CONTEXT_PATH + "/" + widgetId;
       }

       /**
        * Checks if the web application is started for a specific widget ID.
        *
        * @param   widgetId
        *          The ID of the widget to check.
        *
        * @return  true if the web application is started, false otherwise.
        */
       public boolean isStarted(int widgetId)
       {
          return activeWidgets.contains(widgetId);
       }

       /**
        * Stops the web application for a specific widget ID. If the application is not running,
        * this method does nothing.
        *
        * @param   widgetId
        *          The ID of the widget for which the web application is to be stopped.
        */
       public void stop(int widgetId)
       {
          activeWidgets.remove(widgetId);
       }

       /**
        * This method is called when a SpreadsheetWidget is created. It is responsible for creating a context,
        * assigning necessary values to it, initializing the dispatcher, setting the context ID in the
        * SpreadsheetConfig, and adding the context to the map of contexts.
        *
        * @param   widget
        *          The SpreadsheetWidget that was created.
        * @param   eventHandler
        *          The SheetEventHandler that will handle events for the SpreadsheetWidget.
        *
        * @return  The ID of the created context.
        */
       public static int onSpreadsheetWidgetCreated(SpreadsheetWidget widget, SheetEventHandler eventHandler)
       {
          Context ctxt = new Context();
          ctxt.widget = widget;
          ctxt.eventHandler = eventHandler;
          ctxt.client = LogicalTerminal.getClient();
          ctxt.id = lastContextId.incrementAndGet();

          // TODO: get the locale from the legacy session
          String locString = Utils.getDirectoryNodeString(null, "currentLanguage", "?");
          if (locString != null)
          {
             // convert to BCP 47
             locString = locString.replace("_", "-");
             ctxt.locale = Locale.forLanguageTag(locString);
          }
          ctxt.dispatcher = new SecurityContextDispatcher();
          SpreadsheetConfig c = widget.config();
          c.contextId = ctxt.id;
          ClientParameters cp = StandardServer.getClientParameters();
          c.welcomeUrl = cp.serverBaseUrl + CONTEXT_PATH + "/" + c.contextId;
          contexts.put(ctxt.id, ctxt);
          ctxt.dispatcher.start();
          return ctxt.id;
       }

       /**
        * Removes the context associated with a deleted SpreadsheetWidget.
        *
        * @param   widget
        *          The SpreadsheetWidget that was deleted.
        */
       public static void onSpreadsheetWidgetDeleted(SpreadsheetWidget widget)
       {
          Context ctxt = contexts.remove(widget.config().contextId);
          if (ctxt != null)
          {
             ctxt.dispatcher.stop();
          }
       }

       /**
        * Returns the spreadsheet widget ID belonging to the current HTTP request (i.e. loading the
        * spreadsheet web component from the JS client, handling an event triggered in the spreadsheet
        * web component, etc.).
        *
        * @return  See above.
        */
       public static int getWidgetId()
       {
          Context ctxt = getContext();
          return ctxt != null ? ctxt.widget.getId() : WidgetId.INVALID_WIDGET_ID;
       }

       /**
        * Returns the SheetEventHandler instance belonging to the current HTTP request (i.e. loading the
        * spreadsheet web component from the JS client, handling an event triggered in the spreadsheet
        * web component, etc.).
        *
        * @return  See above.
        */
       public static SheetEventHandler getEventHandler()
       {
          Context ctxt = getContext();
          return ctxt != null ? ctxt.eventHandler : null;
       }

       /**
        * Returns the spreadsheet web app context belonging to the current HTTP request (i.e. loading the
        * spreadsheet web component from the JS client, handling an event triggered in the spreadsheet
        * web component, etc.).
        *
        * @return  See above.
        */
       public static Context getContext()
       {
          return contexts.get(getContextId());
       }

       /**
        * Returns the ID if the spreadsheet web app context belonging to the current HTTP request (i.e. loading
        * the spreadsheet web component from the JS client, handling an event triggered in the spreadsheet
        * web component, etc.).
        *
        * @return  See above.
        */
       public static int getContextId()
       {
          return getContextId(getCurrentRequestOrigURI());
       }

       /**
        * Returns the spreadsheet web app context associated with the specified contextId.
        *
        * @param   contextId
        *          The identifier of the context object to retrieve.
        *
        * @return  The Context object associated with the specified contextId.
        */
       public static Context getContext(int contextId)
       {
          return contexts.get(contextId);
       }

       /**
        * Retrieves the ID of the spreadsheet web app context from the supplied path. The path is expected to
        * have the following form: /sheet/[context ID]/whatever/subpath/components.
        *
        * @param   path
        *          The path of the current HTTP request.
        *
        * @return  The ID of the spreadsheet web app context, or -1 if the ID cannot be determined.
        */
       public static int getContextId(String path)
       {
          if (path == null)
          {
             return -1;
          }

          String[] components = path.split("/");
          if (components.length < 3)
          {
             return -1;
          }

          try
          {
             return Integer.parseInt(components[2]);
          }
          catch (NumberFormatException ex)
          {
             return -1;
          }
       }

       /**
        * The method invokes {@link ClientExports#postServerEvent(ServerEvent)} asynchronously
        * in a thread associated with the security context related to the supplied spreadsheet web
        * app context.
        *
        * @param   contextId
        *          ID representing a valid spreadsheet web app context.
        * @param   run
        *          The code to be executed when the server event is handled on the server.
        *
        * @return  {@code true} if the server event is successfully posted, {@code false} otherwise.
        */
       public static boolean postServerEvent(int contextId, Runnable run)
       {
          Context ctxt = contexts.get(contextId);
          if (ctxt == null)
          {
             LOG.warning("postServerEvent called but no context found for context id " + contextId);
             return false;
          }

          postServerEvent(ctxt, run);
          return true;
       }

       /**
        * The method invokes {@link ClientExports#postServerEvent(ServerEvent)} asynchronously
        * in a thread associated with the security context related to the supplied spreadsheet web
        * app context.
        *
        * @param   context
        *          A valid spreadsheet web app context.
        * @param   run
        *          The code to be executed when the server event is handled on the server.
        */
       public static void postServerEvent(Context context, Runnable run)
       {
          context.dispatcher.execute(() ->
             context.client.postServerEvent(new ServerEvent(context.widget.getId(), "", run)));
       }

       /**
        * Returns the value of a private field in the given object without throwing checked exceptions.
        *
        * @param   clazz
        *          the class whose private field is to be read
        * @param   targetObject
        *          the object whose private field is to be read
        * @param   fieldName
        *          the name of the private field to be read
        */
       private static <T> T getPrivateFieldValue(Class clazz, Object targetObject, String fieldName)
       {
          try
          {
             // Get the private field, using getDeclaredField to access private fields
             Field field = clazz.getDeclaredField(fieldName);

             // Set the field accessible to bypass access checks
             field.setAccessible(true);

             // Set the value of the field in the target object
             return (T) field.get(targetObject);
          }
          catch (NoSuchFieldException | IllegalAccessException e)
          {
             throw new RuntimeException("Error setting private field value: " + e.getMessage(), e);
          }
       }

       /**
        * Returns the path of the spreadsheet backed war file.
        *
        * @return  See above.
        */
       private File getWARPath()
       {
          // TODO: currently the method searches for the p2j.jar parent dir and uses it to resolve the war file.
          //       This should eventually change once war files can be defined in the directory.
          String cp = System.getProperty("java.class.path");
          String[] paths = cp.split(File.pathSeparator);

          String p2jPath = null;
          for (String path : paths)
          {
             if (path.endsWith("p2j.jar"))
             {
                p2jPath = path;
                break;
             }
          }

          File f = null;
          if (p2jPath != null)
          {
             f = new File(p2jPath);
             f = f.getParentFile();
          }

          return new File(f, "fwd_sheet.war");
       }

       /**
        * The Context class represents the context within which a spreadsheet widget operates.
        * It holds information such as the widget ID, event handlers, client exports, and security context dispatcher.
        */
       public static class Context
       {
          /** The ID of the context. */
          public int id;

          /** The SpreadsheetWidget associated with the context. */
          public SpreadsheetWidget widget;

          /** The Spreadsheet UI event handler. */
          public SheetEventHandler eventHandler;

          /** The client exports associated with this context. */
          public ClientExports client;

          /** Security context dispatcher associated with this context. */
          public SecurityContextDispatcher dispatcher;

          /** The effective locale at time of SPREADSHEET widget creation */
          public Locale locale;
       }

       /**
        * The class provides a mechanism to execute runnables asynchronously in a separate thread associated
        * with the related security context.
        *
        * Executing on the associated thread is needed in order to access the resources bound to the related
        * security context and call remote objects like ThinClient.
        */
       public static class SecurityContextDispatcher
       {
          /** The associated thread that allows execution of runnables asynchronously. */
          private AssociatedThread thread;

          /** Stores runnable instances to be executed asynchronously. */
          private BlockingQueue<Runnable> queue = new LinkedBlockingQueue();

          /**
           * Starts the thread associated with the current security context.
           */
          public void start()
          {
             thread = new AssociatedThread(this::run);
             thread.setDaemon(true);
             SessionManager.get().registerAsyncThread(thread);
             thread.start();
          }

          /**
           * Stops the associated thread.
           */
          public void stop()
          {
             thread.interrupt();

             try
             {
                thread.join();
             }
             catch (InterruptedException e)
             {
                Thread.currentThread().interrupt();
             }

             SessionManager.get().deregisterAsyncThread(thread);
          }

          /**
           * Executes the supplied runnable asynchronously in a thread associated with the security context.
           *
           * @param   run
           *          The runnable object representing the execution to be added to the queue.
           */
          public void execute(Runnable run)
          {
             queue.add(run);
          }

          /**
           * Executes the runnables in the queue. The method will continue executing until the thread is
           * interrupted.
           */
          private void run()
          {
             while (!thread.interrupted())
             {
                try
                {
                   Runnable r = queue.take();
                   r.run();
                }
                catch (InterruptedException e)
                {
                   thread.interrupt();
                }
             }
          }
       }
    }