# LegacyLogManagerImpl.java

    /*
    ** Module   : LegacyLogManagerImpl.java
    ** Abstract : Implementation for the LOG-MANAGER system handles.
    **
    ** Copyright (c) 2014-2025, Golden Code Development Corporation.
    **
    ** -#- -I- --Date-- ---------------------------------------Description----------------------------------------
    ** 001 GES 20141028 Created initial version.
    ** 002 CA  20220605 Added an experimental implementation of the logging, which just dumps the info the
    **                  server's log.
    ** 003 GBB 20230313 Implementation of the base functionality.
    ** 004 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
    ** 005 GBB 20230523 Fixes LOG-MANAGER entry types parsing when no level applied.
    **                  Handles properly entry types value "::".
    **                  Changes entry type validation checks throwing errors to showing warnings.
    ** 006 GBB 20230608 Multiple methods modified to be exposed as package-private static to be reused by client
    **                  LOG-MANAGER service. Remote object of client LOG-MANAGER service used to perform
    **                  file operations on log files client-side when the server-side filesystem config is
    **                  disabled. Entry types to be resolved case-insensitive. Appserver warnings added.
    ** 007 GBB 20230609 Fixes NPEs when configs not present.
    ** 008 TT  20230915 Migrated fileSize and numLogFiles from String to Integer.
    ** 009 GBB 20240311 'isEnabled' flag needs to be set to false when the log file is closed.
    ** 010 GBB 20241010 Removing redundant constants. Exposing constants for the default logging level,
    **                  threshold and number of log files.
    ** 011 ICP 20250129 Used logical and character constants to leverage caches instances, fixed javadoc.
    ** 012 GBB 20250307 Fix finding the procedure name from the callstack. Defaults moved to LegacyLogOps.
    **                  Log entry types full support. Enums out to the package.
    **                  Multi-session appserver specifics added.
    */
    /*
    ** 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.util;

    import java.io.*;
    import java.nio.file.*;
    import java.text.*;
    import java.util.*;
    import java.util.concurrent.*;
    import java.util.concurrent.atomic.*;
    import java.util.logging.*;
    import java.util.regex.*;
    import java.util.stream.*;

    import com.goldencode.p2j.*;
    import com.goldencode.p2j.net.*;
    import com.goldencode.p2j.ui.*;
    import com.goldencode.p2j.util.appserver.*;
    import com.goldencode.p2j.util.logging.*;

    import static com.goldencode.util.StringHelper.*;

    /** Implementation for the LOG-MANAGER system handle. */
    @LegacyResource(resource = LegacyResource.PSEUDO_WIDGET)
    public class LegacyLogManagerImpl
    extends HandleResource
    implements LegacyLogManager
    {
       /** Default logging level for handle execution of WRITE-MESSAGE(). */
       private static final int HANDLE_CALL_LOGGING_LEVEL = LoggingLevel.ERROR.toInt();

       /** Number of characters of subsys column. */
       private static final int SUBSYS_CHARS_COUNT = 15;

       /** Formatter for date and time, used by WRITE-MESSAGE(). */
       private static final SimpleDateFormat DATETIME_FORMATTER =
          new SimpleDateFormat("YY/MM/dd@HH:mm:ss.SSSXX");

       /** The minimum log file size threshold for roll over in bytes. */
       private static final int MIN_LOG_FILE_THRESHOLD_BYTES = 500_000;

       /** The maximum number of log files for roll over. */
       private static final int MAX_NUM_LOG_FILES = 999_999;

       /** A logger to write messages to server's log. */
       private static final CentralLogger LOG = CentralLogger.get(LegacyLogManagerImpl.class);

       /** Default entry types for ABL clients.  */
       private static final String DEFAULT_TYPES = LoggingEntryType.Messages.toString();

       /** A reference to the queue that handles writing log records. */
       private final LegacyLogWriteExecutor writeExecutor;

       /** A flag to indicate execution of the method initialize has completed. */
       private final AtomicBoolean isInitialized = new AtomicBoolean(false);

       /** Flag to indicate if LOG-MANAGER is enabled by configs. */
       private AtomicBoolean isEnabled = new AtomicBoolean(false);

       /** Max number of characters supported in one message. */
       private int maxInputCharacters = 15_000;

       /** A flag to indicate if running in an appserver. */
       private boolean isAppserver;

       /** The client process id. */
       private long clientProcessId = 0L;

       /** The log file path for clients or servers (AppServer and WebSpeed). Absolute path. */
       private File logFile = null;

       /** The file writer. */
       private FileWriter logWriter;

       /** The default entry type - logging level pairs. */
       private final Map<LoggingEntryType, Integer> activatedTypeLevelPairs = new HashMap<>();

       /** The default logging level. */
       private int loggingLevel = LegacyLogOps.DEFAULT_LOGGING_LEVEL;

       /** The default logging threshold. */
       private int logThreshold = LegacyLogOps.DEFAULT_THRESHOLD;

       /** The default number of logging files. */
       private int numLogFiles = LegacyLogOps.DEFAULT_NUM_FILES;

       /** Client service. */
       private LegacyLogManagerClientService client;

       /** Client session. */
       private Session session;

       /** Counter for client-side log file size. */
       private AtomicInteger remoteFileSize = new AtomicInteger(0);

       /**
        * Package private constructor to create an instance of a LOG-MANAGER.
        *
        * @param   writeExecutor
        *          Reference to the executor that actually writes log messages.
        */
       LegacyLogManagerImpl(LegacyLogWriteExecutor writeExecutor)
       {
          this.writeExecutor = writeExecutor;
          LOG.log(Level.FINE, "New instance created for LOG-MANAGER");
       }

       /**
        * Validates and sets configs coming from command line.
        *
        * @param   configs
        *          Configs.
        */
       @Override
       public void initialize(LegacyLogManagerConfigs configs)
       {
          if (isInitialized.get())
          {
             return;
          }
          session = SessionManager.get().getSession();
          if (!configs.isServerSideFileSystem())
          {
             client = LogicalTerminal.getInstance().getLegacyLogManagerClient();
          }
          initialize(configs.getLogFile(), configs.getLevelString(), configs.getEntryTypes(),
                     configs.getFileSize(), configs.getNumLogFiles(), configs.getProcessId(),
                     configs.getMaxInputCharacters(), configs.isAppserver(),
                     configs.isLoggerInitialized());
          configs.setLoggerInitialized();
       }

       /**
        * Sets configs coming from command line.
        * They need to be set at once, because there are dependencies between them.
        * The recipe for initialization requires the order of setters to be kept in a strict order.
        * @param   logFile
        *          File path for the log.
        * @param   levelString
        *          Default logging level.
        * @param   entryTypes
        *          List of comma-separated log entry types.
        * @param   fileSize
        *          File size in bytes used as threshold for file roll over.
        * @param   numLogFiles
        *          Max number of log files.
        * @param   clientProcessId
        *          The id of the client process.
        * @param   maxInputCharacters
        *          The max number of characters.
        * @param   isAppServerLoggerInitialized
        *          A flag to indicate if the appserver logger has already been initialized in a different.
        */
       private synchronized void initialize(String logFile,
                                            String levelString,
                                            String entryTypes,
                                            Integer fileSize,
                                            Integer numLogFiles,
                                            long clientProcessId,
                                            int maxInputCharacters,
                                            boolean isAppserver,
                                            boolean isAppServerLoggerInitialized)
       {
          this.clientProcessId = clientProcessId;
          this.isAppserver = isAppserver;

          if (maxInputCharacters > 0)
          {
             this.maxInputCharacters =  maxInputCharacters;
          }

          if (hasContent(levelString))
          {
             int level;
             try
             {
                level = Integer.parseInt(levelString);
             }
             catch (Exception e)
             {
                recordOrThrowError(11996, "The -logginglevel parameter requires a numeric " +
                   "argument.", false);
                LOG.log(Level.CONFIG, "Logging level cannot be parsed. Initialization stops.");
                return;
             }

             this.setLoggingLevel(level);
             if (this.loggingLevel <= LoggingLevel.OFF.toInt())
             {
                LOG.log(Level.CONFIG, "Logging level wasn't set. Initialization stops.");
                return;
             }
          }

          if (fileSize != null && !this.setLogThreshold(fileSize))
          {
             LOG.log(Level.CONFIG, "There was an issue with log file threshold value. Initialization stops.");
             return;
          }

          if (numLogFiles != null && !this.setNumLogFiles(numLogFiles))
          {
             LOG.log(Level.CONFIG, "There was an issue with log files number. Initialization stops.");
             return;
          }

          if (!hasContent(logFile) &&
             (hasContent(levelString) || fileSize != null || numLogFiles != null || hasContent(entryTypes)))
          {
             recordOrThrowError( 11068, "Parameters for logging were specified but -clientlog was not set. " +
                "Logging parameters will be ignored. Specify -logginglevel 0 if you want to keep logging " +
                "disabled at startup.", false);
             LOG.log(Level.CONFIG, "Log file name not specified. Initialization stops.");
             return;
          }

          this.setLogFileName(false, logFile);

          this.setLogEntryTypes(hasContent(entryTypes) ?
                                   entryTypes :
                                   isAppserver ? LegacyLogOps.DEFAULT_APPSERVER_TYPES : DEFAULT_TYPES);

          if (!isAppServerLoggerInitialized)
          {
             writeSystemHeader();
          }

          isInitialized.set(true);

          LOG.log(Level.CONFIG, "LOG-MANAGER initialized:" +
             " clientProcessId=" + clientProcessId +
             " logFile=" + logFile +
             " loggingLevel=" + loggingLevel +
             " logThreshold=" + logThreshold +
             " numLogFiles=" + this.numLogFiles +
             " activatedTypeLevelPairs=" + activatedTypeLevelPairs);
       }

       /**
        * Provides the comma-separated list of currently valid entry types that can be assigned to
        * the {@link #setLogEntryTypes}.
        *
        * @return   The list of valid log entry types.
        */
       @Override
       public character getEntryTypesList()
       {
          String supportedEntryTypesList = String.join(",", LoggingEntryType.get4GLNames());
          return new character(supportedEntryTypesList);
       }

       /**
        * Provides the comma-separated list of entry types that are being actively used for logging.
        *
        * @return   The list of active log entry types.
        */
       @Override
       public character getLogEntryTypes()
       {
          if (activatedTypeLevelPairs.isEmpty())
          {
             return new character();
          }
          String entryTypes =
             activatedTypeLevelPairs.entrySet()
                                    .stream()
                                    .map(entrySet -> {
                                       String typeLevelPair = entrySet.getKey().getName();
                                       if (entrySet.getValue() != LoggingLevel.UNDEFINED.toInt())
                                       {
                                          typeLevelPair = typeLevelPair + ":" + entrySet.getValue();
                                       }
                                       return typeLevelPair;
                                    })
                                    .collect(Collectors.joining(","));
          return new character(entryTypes);
       }

       /**
        * Modifies the list of log entry types that are being actively used for logging.
        *
        * @param    types
        *           The comma-separated list of active entry types.
        */
       @Override
       public synchronized void setLogEntryTypes(String types)
       {
          LOG.log(Level.CONFIG, "Log entry types set to " + types);
          activatedTypeLevelPairs.clear();

          if (!hasContent(types))
          {
             resetEnabled();
             return;
          }

          String[] entryTypeInfos = types.split(",");
          for (String entryTypeInfo : entryTypeInfos)
          {
             String[] typeLevelPair = entryTypeInfo.split(":");
             if (typeLevelPair.length == 0)
             {
                recordOrShowWarning(11069, "Ignoring unknown log entry type: " + entryTypeInfo, false);
                continue;
             }

             String typeName = typeLevelPair[0].trim();
             LoggingEntryType foundType = LoggingEntryType.get(typeName);
             if (foundType == null || (isAppserver && foundType == LoggingEntryType.Messages))
             {
                recordOrShowWarning(11069, "Ignoring unknown log entry type: " + entryTypeInfo, false);
                continue;
             }

             if (typeLevelPair.length < 2)
             {
                if (loggingLevel == LoggingLevel.UNDEFINED.toInt())
                {
                   recordOrShowWarning(11072,
                                       "Logging level greater than 1 must be specified either for " +
                                          typeName + " or for all types by using -logginglevel.",
                                       false);
                }
                else
                {
                   activatedTypeLevelPairs.put(foundType, LoggingLevel.UNDEFINED.toInt());
                }
             }
             else
             {
                try
                {
                   int levelNumber = Integer.parseInt(typeLevelPair[1].trim());
                   if (levelNumber < LoggingLevel.BASIC.toInt())
                   {
                      recordOrShowWarning(11071,
                                          "Logging level for " + typeName + " must be " +
                                             LoggingLevel.BASIC.toInt() + " or higher",
                                          false);
                      continue;
                   }
                   activatedTypeLevelPairs.put(foundType, levelNumber);
                }
                catch (Exception e)
                {
                   LOG.log(Level.CONFIG, "Cannot parse to int entry type logging level " + typeLevelPair[1], e);
                   recordOrShowWarning(11071, "Logging level for " + typeName + " must be 2 or higher", false);
                }
             }
          }
          resetEnabled();

          if (isInitialized.get())
          {
             writeMessage(activatedTypeLevelPairs.isEmpty() ?
                             "No log entry types are activated" :
                             "Log entry types activated: " +
                                activatedTypeLevelPairs.keySet()
                                                       .stream()
                                                       .map(LoggingEntryType::name)
                                                       .collect(Collectors.joining(","))
             );
          }
       }

       /**
        * Modifies the list of log entry types that are being actively used for logging.
        *
        * @param    types
        *           The comma-separated list of active entry types.
        */
       @Override
       public synchronized void setLogEntryTypes(character types)
       {
          setLogEntryTypes(types == null || types.isUnknown() ? null : types.toStringMessage());
       }

       /**
        * Obtains the file path to which log entries and stack traces are written.
        *
        * @return   The log file path.
        */
       @Override
       public character getLogFileName()
       {
          return logFile == null ? character.UNKNOWN : character.of(logFile.getPath());
       }

       /**
        * Assigns the file path to which log entries and stack traces are written.
        *
        * @param    logFileConfig
        *           The log file path.
        */
       @Override
       public synchronized void setLogFileName(String logFileConfig)
       {
          setLogFileName(true, logFileConfig);
       }

       /**
        * Assigns the filename to which log entries and stack traces are written.
        *
        * @param    filePath
        *           The log file path.
        */
       @Override
       public synchronized void setLogFileName(character filePath)
       {
          setLogFileName(filePath == null || filePath.isUnknown() ? null : filePath.toStringMessage());
       }

       /**
        * Obtains the default logging level for all logging categories.
        *
        * @return   The logging level.
        */
       @Override
       public integer getLoggingLevel()
       {
          return new integer(loggingLevel);
       }

       /**
        * Assigns the default logging level for all logging categories.
        * <p>
        * There are 5 valid levels:
        * <p>
        * <ul>
        *   <li> 0 - no logging
        *   <li> 1 - only errors
        *   <li> 2 - basic, each entry type determines its own output but all types will generate
        *            some output
        *   <li> 3 - verbose
        *   <li> 4 - extended
        * </ul>
        *
        * @param    level
        *           The logging level to use by default.
        */
       @Override
       public synchronized void setLoggingLevel(int level)
       {
          if (isInitialized.get() && logFile == null)
          {
             recordOrThrowError(11078, "Cannot set attribute LOGGING-LEVEL because log file name was not " +
                "specified at startup.", true);
             return;
          }
          if (level < LoggingLevel.OFF.toInt())
          {
             recordOrThrowError(5049, "The -logginglevel parameter has too many digits.", false);
             return;
          }

          this.loggingLevel = Math.min(level, LoggingLevel.MAX.toInt());

          // resets all levels for enabled entry types
          activatedTypeLevelPairs.replaceAll((k, v) -> loggingLevel);

          resetEnabled();

          if (this.logWriter != null)
          {
             // only after initialization
             writeMessage("Logging level set to = " + this.loggingLevel);
          }
       }

       /**
        * Assigns the default logging level for all logging categories.
        * <p>
        * There are 5 valid levels:
        * <p>
        * <ul>
        *   <li> 0 - no logging
        *   <li> 1 - only errors
        *   <li> 2 - basic, each entry type determines its own output but all types will generate
        *            some output
        *   <li> 3 - verbose
        *   <li> 4 - extended
        * </ul>
        *
        * @param    level
        *           The logging level to use by default.
        */
       @Override
       public synchronized void setLoggingLevel(integer level)
       {
          if (level == null)
          {
             return;
          }
          setLoggingLevel(level.isUnknown() ? 0 : level.intValue());
       }

       /**
        * Obtains the configured log file size in bytes at which the log file will be renamed and a
        * new log file started.
        *
        * @return   The maximum log file size.
        */
       @Override
       public integer getLogThreshold()
       {
          return new integer(logThreshold);
       }

       /**
        * Obtains the configured number of log files that will be stored in total (including both
        * backed up log files and the current log).
        *
        * @return   The maximum number of log files.
        */
       @Override
       public integer getNumLogFiles()
       {
          return new integer(numLogFiles);
       }

       /**
        * Deletes all messages from the currently open log file or deletes all log files in the sequence if
        * rotation is enabled. At the end of the operation a new log file is open and system messages showing
        * LOG-MANAGER configs are printed.
        *
        * @return   <code>true</code> if the operation succeeded, or <code>false</code>
        *           otherwise.
        */
       @Override
       public synchronized logical clearLog()
       {
          if (isAppserver)
          {
             recordOrShowWarning(14331, "CLEAR-LOG() invalid for WebSpeed or AppServer", false);
             return new logical(false);
          }
          if (logFile == null || (client == null && logWriter == null))
          {
             recordOrShowWarning( 14333, "Cannot clear log because there is no log file open", false);
             return new logical(false);
          }

          String originalLogFileName;
          if (isRotationEnabled())
          {
             String noSequenceName = new SequencedFileNameMatcher(logFile).getNoSequenceName();
             originalLogFileName = new File(logFile.getParent(), noSequenceName).getPath();
          }
          else
          {
             originalLogFileName = logFile.getPath();
          }

          if (client != null)
          {
             remoteFileSize.set(0);
             logFile = null;
             boolean isSuccess = session.isRunning() && client.clear();
             setLogFileName(false, originalLogFileName);
             return new logical(isSuccess);
          }

          if (!isRotationEnabled())
          {
             try
             {
                logFile.delete();
                logFile = null;
                setLogFileName(false, originalLogFileName);
                return new logical(true);
             }
             catch (Exception e)
             {
                return new logical(false);
             }
          }

          close(false, false);

          boolean isSuccess = deleteSequence(logFile);
          setLogFileName(false, originalLogFileName);
          return new logical(isSuccess);
       }

       /**
        * Closes the currently open log file. Additional logging will not be possible after this
        * operation.
        * <p>
        * This method should not be used as a manual call, but only automatically from procedure execution.
        * Use {@link #close(boolean,boolean)} instead.
        *
        * @return   <code>true</code> if the operation succeeded, or <code>false</code>
        *           otherwise.
        */
       @Override
       public logical closeLog()
       {
          return close(true, true);
       }

       /**
        * Closes the currently open log file. Additional logging will not be possible after this
        * operation.
        *
        * @param    isProcedureCall
        *           Flag to identify the method is called by the execution of a converted 4GL procedure.
        * @param    closeWriteExecutor
        *           Flag to identify the manager is not going to use {@link #writeExecutor} any more. In case
        *           of file being rotated or {@link #clearLog()} called, this should be <code>false</code>.
        *
        * @return   <code>true</code> if the operation succeeded, or <code>false</code>
        *           otherwise.
        */
       @Override
       public synchronized logical close(boolean isProcedureCall, boolean closeWriteExecutor)
       {
          if (isAppserver && isProcedureCall)
          {
             recordOrShowWarning(14331, "CLOSE-LOG() invalid for WebSpeed or AppServer", false);
             return new logical(false);
          }

          isEnabled.set(false);

          if (client != null)
          {
             logFile = null;
             remoteFileSize.set(0);
             if (!session.isRunning())
             {
                return new logical(true);
             }
             if (isProcedureCall && loggingLevel > LoggingLevel.OFF.toInt())
             {
                client.write(createLogLine(HANDLE_CALL_LOGGING_LEVEL,
                                           "----------",
                                           "Log file closed at user's request",
                                           Thread.currentThread().getId(),
                                           new Date(),
                                           LoggingExecEnv.DEFAULT.toString()));
             }
             return new logical(client.close());
          }

          if (logWriter == null)
          {
             return new logical(true);
          }

          try
          {
             if (isProcedureCall && loggingLevel > LoggingLevel.OFF.toInt())
             {
                writeLocal(createLogLine(HANDLE_CALL_LOGGING_LEVEL,
                                         "----------",
                                         "Log file closed at user's request",
                                         Thread.currentThread().getId(),
                                         new Date(),
                                         LoggingExecEnv.DEFAULT.toString()));
             }
             logWriter.close();
             logWriter = null;
             if (closeWriteExecutor)
             {
                writeExecutor.close(clientProcessId, logFile.getAbsolutePath(), isRotationEnabled());
                logFile = null;
             }
             return new logical(true);
          }
          catch (IOException e)
          {
             LOG.log(Level.WARNING, "Not able to close log file writer for " + logFile.getAbsolutePath(), e);
             return new logical(false);
          }
       }

       /**
        * This object is always valid for use. It might output an error message if its state is not prepared
        * for logging, but that is the expected behavior.
        *
        * @return   Always <code>true</code>.
        */
       @Override
       public boolean valid()
       {
          return true;
       }

       /**
        * Returns <code>true</code> if logging is enabled, <code>false</code> otherwise.
        *
        * @return   See above.
        */
       @Override
       public boolean isLoggingEnabled()
       {
          return isEnabled.get();
       }

       /**
        * Write a user defined message to the currently open log file using the default subsystem name "APPL".
        * The method is called directly as a manual WRITE-MESSAGE, instead of an auto logging from a subsystem,
        * that's why the logging level is set to {@link #HANDLE_CALL_LOGGING_LEVEL}.
        * <p>
        * Should be used only automatically by the converted code or LOG-MANAGER itself.
        *
        * @param    msg
        *           The message to write.
        *
        * @return   <code>true</code> if the operation succeeded, or <code>false</code>
        *           otherwise.
        */
       @Override
       public logical writeMessage(String msg)
       {
          return writeMessage(msg, LoggingSubsys.DEFAULT.toString());
       }

       /**
        * Write a user defined message to the currently open log file using the default subsystem name "APPL".
        * The method is called directly as a manual WRITE-MESSAGE, instead of an auto logging from a subsystem,
        * that's why the logging level is set to {@link #HANDLE_CALL_LOGGING_LEVEL}.
        * <p>
        * Should be used only automatically by the converted code or LOG-MANAGER itself.
        *
        * @param    msg
        *           The message to write.
        *
        * @return   <code>true</code> if the operation succeeded, or <code>false</code>
        *           otherwise.
        */
       @Override
       public logical writeMessage(character msg)
       {
          return writeMessage(msg, new character(LoggingSubsys.DEFAULT.toString()));
       }

       /**
        * Write a user defined message to the currently open log file using the given subsystem name. The
        * method is called directly as a manual WRITE-MESSAGE, instead of an auto logging from a
        * subsystem, that's why the logging level is set to {@link #HANDLE_CALL_LOGGING_LEVEL}.
        * <p>
        * Should be used only automatically by the converted code or LOG-MANAGER itself.
        *
        * @param    msg
        *           The message to write.
        * @param    subsys
        *           Subsystem name (up to 10 characters will be used).
        *
        * @return   <code>true</code> if the operation succeeded, or <code>false</code>
        *           otherwise.
        */
       @Override
       public logical writeMessage(String msg, String subsys)
       {
          return writeMessage(msg,
                              null,
                              null,
                              subsys,
                              null,
                              HANDLE_CALL_LOGGING_LEVEL,
                              Thread.currentThread().getId(),
                              false);
       }

       /**
        * Write a user defined message to the currently open log file using the given subsystem name. The
        * method is called directly as a manual WRITE-MESSAGE, instead of an auto logging from a
        * subsystem, that's why the logging level is set to {@link #HANDLE_CALL_LOGGING_LEVEL}.
        * <p>
        * Should be used only automatically by the converted code or LOG-MANAGER itself.
        *
        * @param    msg
        *           The message to write.
        * @param    subsys
        *           Subsystem name (up to 10 characters will be used).
        *
        * @return   <code>true</code> if the operation succeeded, or <code>false</code>
        *           otherwise.
        */
       @Override
       public logical writeMessage(character msg, String subsys)
       {
          String message = msg == null || msg.isUnknown() ? "?" : msg.toStringMessage();
          return writeMessage(message,
                              null,
                              null,
                              subsys,
                              null,
                              HANDLE_CALL_LOGGING_LEVEL,
                              Thread.currentThread().getId(),
                              false);
       }

       /**
        * Write a user defined message to the currently open log file using the given subsystem name. The
        * method is called directly as a manual WRITE-MESSAGE, instead of an auto logging from a
        * subsystem, that's why the logging level is set to {@link #HANDLE_CALL_LOGGING_LEVEL}.
        * <p>
        * Should be used only automatically by the converted code or LOG-MANAGER itself.
        *
        * @param    msg
        *           The message to write.
        * @param    subsys
        *           Subsystem name (up to 10 characters will be used).
        *
        * @return   <code>true</code> if the operation succeeded, or <code>false</code>
        *           otherwise.
        */
       @Override
       public logical writeMessage(String msg, character subsys)
       {
          String subsystem = subsys == null || subsys.isUnknown() ?
             LoggingSubsys.DEFAULT.toString() :
             subsys.toStringMessage();
          return writeMessage(msg,
                              null,
                              null,
                              subsystem,
                              null,
                              HANDLE_CALL_LOGGING_LEVEL,
                              Thread.currentThread().getId(),
                              false);
       }

       /**
        * Write a user defined message to the currently open log file using the given subsystem name. The
        * method is called directly as a manual WRITE-MESSAGE, instead of an auto logging from a
        * subsystem, that's why the logging level is set to {@link #HANDLE_CALL_LOGGING_LEVEL}.
        * <p>
        * Should be used only automatically by the converted code or LOG-MANAGER itself.
        *
        * @param    msg
        *           The message to write.
        * @param    subsys
        *           Subsystem name (up to 10 characters will be used).
        *
        * @return   <code>true</code> if the operation succeeded, or <code>false</code>
        *           otherwise.
        */
       @Override
       public logical writeMessage(character msg, character subsys)
       {
          String message = msg == null || msg.isUnknown() ? "?" : msg.toStringMessage();
          String subsystem = subsys == null || subsys.isUnknown() ?
             LoggingSubsys.DEFAULT.toString() :
             subsys.toStringMessage();
          return writeMessage(message, subsystem);
       }

       /**
        * Write an automated message to the currently open log file using the given subsystem name.
        *
        * @param    msg
        *           The message to write.
        * @param    entryType
        *           The type of the entry.
        * @param    execEnv
        *           The execution environment.
        * @param    subsys
        *           Subsystem name (up to 10 characters will be used).
        * @param    logLevel
        *           The log level.
        *
        * @return   <code>true</code> if the operation succeeded, or <code>false</code>
        *           otherwise.
        */
       @Override
       public logical writeMessage(String msg,
                                   LoggingEntryType entryType,
                                   LoggingExecEnv execEnv,
                                   LoggingSubsys subsys,
                                   LoggingLevel logLevel)
       {
          return writeMessage(msg,
                              entryType,
                              execEnv,
                              subsys == null ? null : subsys.toString(),
                              null,
                              logLevel.toInt(),
                              Thread.currentThread().getId(),
                              false);
       }

       /**
        * Write an automated message from the given subsystem to the currently open log file.
        * <p>
        * To be used for custom calls / auto logging of entry types.
        *
        * @param    msg
        *           The message to write.
        * @param    entryType
        *           The type of the entry.
        * @param    execEnv
        *           The execution environment.
        * @param    subsys
        *           Subsystem name (up to 10 characters will be used).
        * @param    callStack
        *           The call stack.
        * @param    logLevel
        *           The logging level of the message.
        * @param    threadId
        *           The client thread id or server thread id for AppServer and WebSpeed.
        *
        * @return   logical <code>true</code> if the operation succeeded, or logical <code>false</code>
        *           otherwise.
        */
       @Override
       public logical writeMessage(String msg,
                                   LoggingEntryType entryType,
                                   LoggingExecEnv execEnv,
                                   LoggingSubsys subsys,
                                   LoggingLevel logLevel,
                                   String[] callStack,
                                   long threadId)
       {
          return writeMessage(msg,
                              entryType,
                              execEnv,
                              subsys == null ? null : subsys.toString(),
                              callStack,
                              logLevel.toInt(),
                              threadId,
                              true);
       }

       /**
        * Write a user defined message from a handle WRITE-MESSAGE call or an automated message to the
        * currently open log file using the given subsystem name.
        * <p>
        * To be used for custom calls / auto logging of entry types.
        *
        * @param    msg
        *           The message to write.
        * @param    entryType
        *           The type of the entry. Can be <code>null</code>.
        * @param    execEnv
        *           The execution environment abbr.
        * @param    subsys
        *           Subsystem name (up to 10 characters will be used). It may match the entry type, or it can
        *           be a custom one with calls to WRITE-MESSAGE.
        * @param    callStack
        *           The call stack.
        * @param    msgLogLevel
        *           The logging level of the message.
        * @param    threadId
        *           The client thread id or server thread id for AppServer and WebSpeed.
        * @param    isAutoLogging
        *           A flag to indicate if auto logging from a subsystem or a user call from converted procedure.
        *
        * @return   logical <code>true</code> if the operation succeeded, or logical <code>false</code>
        *           otherwise.
        */
       private synchronized logical writeMessage(String msg,
                                                 LoggingEntryType entryType,
                                                 LoggingExecEnv execEnv,
                                                 String subsys,
                                                 String[] callStack,
                                                 int msgLogLevel,
                                                 long threadId,
                                                 boolean isAutoLogging)
       {
          if (!isEnabled.get() && isAutoLogging)
          {
             return logical.FALSE;
          }
          if (client == null && logWriter == null)
          {
             recordOrThrowError(14332, "Cannot write message to log, as there is no log open", false);
             return logical.FALSE;
          }
          if (loggingLevel == LoggingLevel.OFF.toInt() || loggingLevel < msgLogLevel)
          {
             return logical.FALSE;
          }

          if (entryType != null)
          {
             if (!activatedTypeLevelPairs.containsKey(entryType))
             {
                return logical.FALSE;
             }

             Integer maxLevelForEntryType = activatedTypeLevelPairs.get(entryType);
             if (maxLevelForEntryType == LoggingLevel.OFF.toInt() || maxLevelForEntryType < msgLogLevel)
             {
                return logical.FALSE;
             }
          }

          if (msg == null)
          {
             msg = "?";
          }
          else if (msgLogLevel >= LoggingLevel.VERBOSE.toInt())
          {
             // TODO: confirm it's valid for all logs level 3 or above
             msg = "TRACE: " + msg;
          }

          if (msg.length() > maxInputCharacters)
          {
             recordOrThrowError(135, "More than " + maxInputCharacters + " characters in a single " +
                "statement--use -inp parm.", true);
             return logical.FALSE;
          }

          Date date = new Date();

          if (subsys == null)
          {
             subsys = isAppserver ? LoggingSubsys.AS.toString() : LoggingSubsys.DEFAULT.toString();
          }

          String fullExecEnv;
          if (execEnv == null)
          {
             if (isAppserver)
             {
                // TODO: classic?
                MultiSessionAppserverManager msaManager = MultiSessionAppserverManager.getInstance();
                Integer sessionId = msaManager.getSessionId();
                fullExecEnv = LoggingExecEnv.AS +
                   (msaManager.isMsaContext() && sessionId != null ? "-" + sessionId : "");
             }
             else
             {
                fullExecEnv = LoggingExecEnv.DEFAULT.toString();
             }
          }
          else
          {
             fullExecEnv = execEnv.toString();
          }

          String logRecord = createLogRecord(msg, subsys, callStack, msgLogLevel, threadId, date, fullExecEnv);

          long fileSize = client != null ? remoteFileSize.get() : logFile.length();
          int logRecordSize = logRecord.getBytes().length;
          if (isRotationEnabled() && fileSize + logRecordSize > logThreshold)
          {
             rotateLogFile();
          }

          if (client == null)
          {
             return logical.of(writeLocal(logRecord));
          }
          return logical.of(writeRemote(logRecord, isAutoLogging));
       }

       /**
        * Formats the input data as a log record.
        *
        * @param    msg
        *           The message to write.
        * @param    subsys
        *           Subsystem name (up to 10 characters will be used).
        * @param    callStack
        *           The call stack.
        * @param    logLevel
        *           The logging level of the message.
        * @param    threadId
        *           The client thread id or server thread id for AppServer and WebSpeed.
        * @param    date
        *           The date of the log record.
        * @param    execEnv
        *           The execution environment.
        *
        * @return   The formatted log record.
        */
       private String createLogRecord(String msg,
                                      String subsys,
                                      String[] callStack,
                                      int logLevel,
                                      long threadId,
                                      Date date,
                                      String execEnv)
       {
          if (callStack != null && callStack.length > 0)
          {
             if (SessionUtils.isDebugAlert().booleanValue())
             {
                StringBuilder msgBuilder = new StringBuilder();
                msgBuilder.append(createLogLine(logLevel, subsys, msg, threadId, date, execEnv));
                msgBuilder.append(createLogLine(logLevel,
                                                subsys,
                                                "** ABL Debug-Alert Stack Trace **",
                                                threadId,
                                                date,
                                                execEnv));

                for (int i = 0; i < callStack.length; i++)
                {
                   String callStackEntry = callStack[i];
                   String callStackPrefix = i == 0 ? "--> " : "    ";
                   msgBuilder.append(createLogLine(logLevel,
                                                   subsys,
                                                   callStackPrefix + callStackEntry,
                                                   threadId,
                                                   date,
                                                   execEnv));
                }
                return msgBuilder.toString();
             }
             msg = "(Procedure: '" + callStack[callStack.length - 1] + "') " + msg;
          }
          return createLogLine(logLevel, subsys, msg, threadId, date, execEnv);
       }

       /** Sets the isEnabled flag. */
       private void resetEnabled()
       {
          isEnabled.set(logFile != null &&
                           loggingLevel > LoggingLevel.OFF.toInt() &&
                           activatedTypeLevelPairs.size() > 0);
       }

       /**
        * Checks if the folder exists and if the JVM process has rights to write to it.
        *
        * @param     file
        *            The file to check for.
        *
        * @return    <code>true</code> if the folder is writable, <code>false</code> otherwise.
        */
       static boolean isWritable(File file)
       {
          if (file == null)
          {
             return false;
          }
          if (file.exists() && file.isFile() && file.canWrite())
          {
             return true;
          }
          try
          {
             File folder = file.getAbsoluteFile().getParentFile();
             return folder.exists() && folder.canWrite();
          }
          catch (SecurityException e)
          {
             return false;
          }
       }

       /**
        * Adds a dot and a sequence number to the file name before the file extension. The sequence number is six
        * digits with leading zeros in the range 000001 - 999999. Traverses log file directory to find all
        * files in the sequence. The first consecutive numbers found are used to determine the current sequence
        * number. If the file is full based on the {@link #logThreshold} value, the method increments the last
        * sequence number or uses 1 if the end of the allowed range 999999 is reached.
        *
        * @param     logFilePath
        *            The log file without sequence number.
        * @param     logThreshold
        *            The log file size threshold for rolling over to a new file.
        *
        * @return    The log file with updated name that includes the sequence number of the current last log
        *            file.
        */
       static File makeSequencedLogFile(String logFilePath, int logThreshold)
       {
          File logFile = new File(logFilePath);
          File dir = logFile.getParentFile(); // can be null for relative paths
          FileName fn = new GenericFileName(logFile.getName());

          int lastLogFileNumber = findLastLogFileNumber(logFile.getAbsoluteFile().getParentFile(),
                                                        fn.getPrefix(),
                                                        fn.getExt());
          if (lastLogFileNumber == 0)
          {
             String sequencedName = formatFileName(fn, 1);
             return new File(dir, sequencedName);
          }

          File lastLogFile = new File(dir, formatFileName(fn, lastLogFileNumber));
          if (lastLogFile.length() < logThreshold)
          {
             return lastLogFile;
          }

          String currentSequencedName = formatFileName(fn, nextSequenceNumber(lastLogFileNumber));
          return new File(dir, currentSequencedName);
       }

       /**
        * Removes the sequenced log files with the same name in the same folder that exceed the max count of
        * {@link #numLogFiles}, while preserving the current log file and of its predecessors.
        */
       static void cleanupRotation(File logFile, int numLogFiles)
       {
          logFile = logFile.getAbsoluteFile();
          SequencedFileName sequencedFileName = new SequencedFileName(logFile.getName());
          if (!sequencedFileName.isValid)
          {
             return;
          }

          SequencedFileNameMatcher matcher =
             new SequencedFileNameMatcher(sequencedFileName.getPrefix(), sequencedFileName.getExt());

          try
          {
             List<Integer> logFileNumbers =
                Files.walk(logFile.getParentFile().toPath(), 1)
                     .map(Path::toFile)
                     .filter(File::isFile)
                     .map(file -> matcher.findSequenceNumber(file.getName()))
                     .filter(number -> number > 0 && number <= MAX_NUM_LOG_FILES)
                     .sorted()
                     .collect(Collectors.toList());

             if (logFileNumbers.size() <= numLogFiles)
             {
                return;
             }
             int filesToDeleteCount = logFileNumbers.size() - numLogFiles;
             int indexOfCurrentLogFile = logFileNumbers.indexOf(sequencedFileName.sequenceNumberInt);

             while (filesToDeleteCount > 0)
             {
                // first deletes next and then starts from the first
                int fileNumber = indexOfCurrentLogFile < logFileNumbers.size() - 1 ?
                   logFileNumbers.remove(indexOfCurrentLogFile + 1) :
                   logFileNumbers.remove(0);
                String fileName = formatFileName(sequencedFileName, fileNumber);
                new File(logFile.getParentFile(), fileName).delete();
                filesToDeleteCount--;
             }
          }
          catch (IOException e)
          {
             LOG.log(Level.WARNING, "Not able to clear all log files.", e);
          }
       }

       /**
        * Deletes all files in the sequence in the same folder as the provided log file.
        *
        * @param     logFile
        *            The log file to base the sequence finding on.
        *
        * @return    <code>true</code> if all files in the sequence successfully deleted,
        *            <code>false</code> otherwise.
        */
       static boolean deleteSequence(File logFile)
       {
          SequencedFileNameMatcher matcher = new SequencedFileNameMatcher(logFile);

          boolean isSuccess = false;
          try
          {
             isSuccess = Files.walk(logFile.getAbsoluteFile().getParentFile().toPath(), 1)
                              .map(Path::toFile)
                              .filter(file -> file.isFile() &&
                                 matcher.matches(file.getName()))
                              .allMatch(File::delete);
          }
          catch (IOException e)
          {
             LOG.log(Level.WARNING, "Not able to clear all log files.", e);
          }
          return isSuccess;
       }

       /**
        * Get the next number in the sequence valid range.
        *
        * @param    lastNumber
        *           The previous number in the sequence.
        *
        * @return   The next number in the sequence.
        */
       private static int nextSequenceNumber(int lastNumber)
       {
          return lastNumber == 999999 ? 1 : lastNumber + 1;
       }

       /**
        * Concatenates the file name, sequence and extension. Number sequence is formatted with 6 digits
        * (leading zeros are added if needed).
        *
        * @param    fileName
        *           The file name object wrapper for the name prefix (name without extension and sequence number)
        *           and the extension itself.
        * @param    sequenceNumber
        *           The sequence number.
        *
        * @return   The formatted file name.
        */
       private static String formatFileName(FileName fileName, int sequenceNumber)
       {
          int leadingZerosCount = 6 - String.valueOf(sequenceNumber).length();
          String leadingZeros = new String(new char[leadingZerosCount]).replace('\0', '0');
          String currentNumberFormatted = leadingZeros + sequenceNumber;
          return fileName.getExt() == null ?
             fileName.getPrefix() + "." + currentNumberFormatted :
             fileName.getPrefix() + "." + currentNumberFormatted + "." + fileName.getExt();
       }

       /**
        * Format process or thread id. Should take up at least 6 characters including leading zeros.
        *
        * @param    id
        *           The process or thread id.
        *
        * @return   The formatted id.
        */
       private static String formatSystemId(long id)
       {
          String idString = String.valueOf(id);
          if (idString.length() >= 6)
          {
             return idString;
          }
          int leadingZerosCount = 6 - idString.length();
          String leadingZeros = new String(new char[leadingZerosCount]).replace("\0", "0");
          return leadingZeros + idString;
       }

       /**
        * This should reflect the 4GL logic to find the last file that has been written to. Traverses log file
        * directory to find all files in the sequence. The last gap in the numbers found are used to determine
        * the current sequence number. The last consecutive number (of that preceding sub-sequence) is returned.
        *
        * @param     dir
        *            The directory to search for log files.
        * @param     nameNoExt
        *            The name of the file without extension and sequence.
        * @param     ext
        *            The extension of the file.
        *
        * @return    The number in the sequence of the last log file.
        */
       private static int findLastLogFileNumber(File dir, String nameNoExt, String ext)
       {
          File[] allDirFiles = dir.listFiles();
          if (allDirFiles == null || allDirFiles.length == 0)
          {
             return 0;
          }

          Arrays.sort(allDirFiles, Comparator.comparing(File::getName).reversed());

          SequencedFileNameMatcher matcher = new SequencedFileNameMatcher(nameNoExt, ext);
          int lastFileNumber = 0;
          int lastFileNumberBeforeTheLastGap = 0;
          int previousNumber = 0;
          for (int i = 0; i < allDirFiles.length; i++)
          {
             File file = allDirFiles[i];
             if (!file.isFile())
             {
                continue;
             }

             int sequenceNumber = matcher.findSequenceNumber(file.getName());
             if (sequenceNumber == 0)
             {
                continue;
             }

             if (lastFileNumber == 0)
             {
                lastFileNumber = sequenceNumber;
             }

             if (sequenceNumber < previousNumber - 1)
             {
                lastFileNumberBeforeTheLastGap = sequenceNumber;
                break;
             }

             previousNumber = sequenceNumber;
          }

          return lastFileNumberBeforeTheLastGap != 0 ? lastFileNumberBeforeTheLastGap : lastFileNumber;
       }

       /**
        * Updates the field {@link #logFile} for resolved path to the log File. Closes the old log file, does
        * cleanup of old rotation log files based on {@link #numLogFiles}, and opens the new file
        * adding the log header lines.
        *
        * @param    newLogFile
        *           The new log file to be used.
        */
       private void switchLogFile(File newLogFile)
       {
          if (this.logFile != null && this.logFile.equals(newLogFile))
          {
             return;
          }

          close(false, false);

          this.logFile = newLogFile;
          LOG.log(Level.CONFIG, "Log file name set to %s.", logFile);

          if (this.logFile == null)
          {
             return;
          }

          boolean isRotationEnabled = isRotationEnabled();
          if (client != null)
          {
             if (!session.isRunning())
             {
                return;
             }
             client.open(newLogFile.getPath());
             if (isRotationEnabled)
             {
                client.cleanupRotation(logFile.getPath(), numLogFiles);
             }
             return;
          }

          try
          {
             this.logWriter = new FileWriter(logFile, true);
          }
          catch (IOException e)
          {
             LOG.log(Level.WARNING, e, "Not able to open log file %s.", logFile.getAbsolutePath());
          }

          if (isRotationEnabled)
          {
             cleanupRotation(logFile, numLogFiles);
          }
       }

       /** Writes the system messages when a new file name is set. */
       private void writeSystemHeader()
       {
          if ((client != null || logFile != null) && loggingLevel > LoggingLevel.OFF.toInt())
          {
             if (isAppserver)
             {
                writeMessage("Agent Starting Up -- " + Version.getFWDVersion(),
                             LoggingEntryType.ASPlumbing,
                             LoggingExecEnv.AS_AUX,
                             LoggingSubsys.MSAS, // todo: classic?
                             LoggingLevel.ERROR);
             }

             writeMessage("Logging level set to = " + loggingLevel, LoggingSubsys.EMPTY.toString());
             writeMessage(activatedTypeLevelPairs.size() == 0 ?
                             "No entry types are activated" :
                             "Log entry types activated: " + getLogEntryTypes().toStringMessage(),
                          LoggingSubsys.EMPTY.toString());
          }
       }

       /**
        * Assigns the log file size threshold in bytes for rolling over to a new file.
        *
        * Valid values are either 0 for no limit (limited only by the system),
        * or in the range 500_000 - 2_147_483_647.
        *
        * Can be set only command line on initialization.
        *
        * @param    fileSize
        *           The log file size threshold for rolling over to a new file.
        */
       private boolean setLogThreshold(int fileSize)
       {
          if (fileSize == 0)
          {
             this.logThreshold = fileSize;
             return true;
          }

          if (fileSize < MIN_LOG_FILE_THRESHOLD_BYTES)
          {
             recordOrThrowError(11065, "-logthreshold must be set to a value between 500000 and 2147483647.",
                                false);
             return false;
          }

          this.logThreshold = fileSize;

          if (this.logWriter != null)
          {
             // only after initialization
             writeMessage("Log file threshold set to = " + this.logThreshold);
          }
          return true;
       }

       /**
        * Checks if file rotation is enabled. If {@link #logThreshold} has been initialized (having a value
        * greater than 0), log file rotation is enabled.
        *
        * @return   <code>true</code> if rotation is enabled, <code>false</code> otherwise.
        */
       private boolean isRotationEnabled()
       {
          return logThreshold > 0;
       }

       /**
        * Sets the number of log files that will be stored in total
        * (including both backed up log files and the current log).
        * Can be set only command line.
        *
        * @param    numLogFiles
        *           The number of log files in total.
        */
       private boolean setNumLogFiles(int numLogFiles)
       {
          if (numLogFiles == 1)
          {
             recordOrThrowError(11067, "-numlogfiles cannot be set to 1", false);
             return false;
          }

          if (numLogFiles > MAX_NUM_LOG_FILES)
          {
             recordOrThrowError( 14416, "-numlogfiles cannot be set to a value greater than 999999", false);
             return false;
          }
          this.numLogFiles = numLogFiles;
          if (this.logWriter != null)
          {
             // only after initialization
             writeMessage("The number of log files in total is set to = " + this.numLogFiles);
          }
          return true;
       }

       /**
        * Assigns the file path to which log entries and stack traces are written.
        *
        * @param    isProcedureCall
        *           Flag to identify the method is called by the execution of a converted 4GL procedure.
        * @param    logFileConfig
        *           The log file path.
        */
       private synchronized void setLogFileName(boolean isProcedureCall, String logFileConfig)
       {
          if (isInitialized.get() && isAppserver)
          {
             recordOrShowWarning(4052, "LOGFILE-NAME is not a setable attribute for PSEUDO-WIDGET.", true);
             return;
          }

          if (!hasContent(logFileConfig))
          {
             switchLogFile(null);
             resetEnabled();
             return;
          }

          logFileConfig = logFileConfig.trim();

          if (client != null)
          {
             if (!client.isWritable(logFileConfig))
             {
                recordOrShowWarning(11076, "Cannot open log file " + logFileConfig + ", errno 3", false);
                recordOrThrowError(4487, "Unable to open file for PSEUDO-WIDGET.", true);
                return;
             }
             String newFilePath =
                isRotationEnabled() ? client.makeSequencedLogFile(logFileConfig, logThreshold) : logFileConfig;
             switchLogFile(new File(newFilePath));
          }
          else
          {
             File newLogFile = new File(logFileConfig);
             if (!isWritable(newLogFile))
             {
                recordOrShowWarning(11076, "Cannot open log file " + logFileConfig + ", errno 3", false);
                recordOrThrowError(4487, "Unable to open file for PSEUDO-WIDGET.", true);
                return;
             }

             File newFile = isRotationEnabled() ? makeSequencedLogFile(logFileConfig, logThreshold) : newLogFile;
             switchLogFile(newFile);
          }
          resetEnabled();
          if (isProcedureCall)
          {
             writeSystemHeader();
          }
       }

       /**
        * Formats the parameters to the 4GL log record format.
        *
        * @param    logLvl
        *           The logging level. See {@link LoggingLevel}.
        * @param    subsys
        *           The subsystem name.
        * @param    msg
        *           The message.
        * @param    threadId
        *           The client thread id or server thread id for AppServer and WebSpeed.
        * @param    date
        *           The date of the log record.
        * @param    execEnv
        *           The execution environment.
        *
        * @return   The formatted string line representing the log record.
        */
       private String createLogLine(int logLvl,
                                    String subsys,
                                    String msg,
                                    long threadId,
                                    Date date,
                                    String execEnv)
       {
          String formattedDateTime = DATETIME_FORMATTER.format(date);
          String formattedSubsys = LoggingSubsys.EMPTY.toString().equals(subsys) ? subsys : formatSubsys(subsys);
          String formatProcessId = formatSystemId(clientProcessId);
          String formatThreadId = formatSystemId(threadId);
          return  "[" + formattedDateTime + "] P-" + formatProcessId + " T-" + formatThreadId + " " + logLvl + " " +
             execEnv + " " + formattedSubsys + " " + msg + System.lineSeparator();
       }

       /**
        * Writes the log record to the log file server-side.
        *
        * @param    logRecord
        *           The record to be written to the log file.
        *
        * @return   <code>true</code> if the record has been written successfully, <code>false</code> otherwise.
        */
       private boolean writeLocal(String logRecord)
       {
          try
          {
             Optional<Future<Boolean>> writeFutureOptional = writeExecutor.write(clientProcessId,
                                                                                 logFile.getAbsolutePath(),
                                                                                 isRotationEnabled(),
                                                                                 logWriter,
                                                                                 logRecord);
             if (writeFutureOptional.isPresent())
             {
                return writeFutureOptional.get().get();
             }
             return true;
          }
          catch (InterruptedException e)
          {
             if (LOG.isLoggable(Level.FINE))
             {
                LOG.log(Level.FINE, "Interrupted while writing log [%s] to file %s.", logRecord, logFile);
             }
             return false;
          }
          catch (Exception e)
          {
             LOG.log(Level.WARNING, e, "Could't write log [%s] to file %s.", logRecord, logFile);
             return false;
          }
       }

       /**
        * Writes the log record to the log file client-side.
        *
        * @param    logRecord
        *           The record to be written to the log file.
        * @param    isAutoLogging
        *           A flag to indicate if auto logging from a subsystem or a user call from converted procedure.
        *
        * @return   <code>true</code> if the record has been written successfully, <code>false</code> otherwise.
        */
       private boolean writeRemote(String logRecord, boolean isAutoLogging)
       {
          if (!session.isRunning())
          {
             return true;
          }
          if (isAutoLogging)
          {
             LogicalTerminal.getInstance().addLogRecord(logRecord);
             return true;
          }
          boolean successful = client.write(logRecord);
          if (successful)
          {
             remoteFileSize.addAndGet(logRecord.getBytes().length);
          }
          return successful;
       }

       /**
        * Format the subsystem string. It should take up a space defined by
        * {@link #SUBSYS_CHARS_COUNT}.
        *
        * @param    subsys
        *           Subsystem name.
        *
        * @return   The formatted string for subsystem.
        */
       private String formatSubsys(String subsys)
       {
          if (subsys == null || subsys.isEmpty())
          {
             subsys = LoggingSubsys.DEFAULT.toString();
          }

          if (subsys.equals(LoggingSubsys.EMPTY.toString()) ||
             subsys.equals(LoggingSubsys.AS.toString()) ||
             subsys.equals(LoggingSubsys.MSAS.toString()))
          {
             return subsys;
          }

          if (subsys.length() > SUBSYS_CHARS_COUNT)
          {
             subsys = subsys.substring(0, SUBSYS_CHARS_COUNT);
             return subsys;
          }

          int whitespaceCount = SUBSYS_CHARS_COUNT - subsys.length();
          String whitespaces = new String(new char[whitespaceCount]).replace("\0", " ");
          return subsys + whitespaces;
       }

       /**
        * Creates the next file in the sequence.
        */
       private void rotateLogFile()
       {
          try
          {
             SequencedFileName sequencedFileName = new SequencedFileName(logFile.getName());
             if (!sequencedFileName.isValid)
             {
                return;
             }
             String nextFileName = formatFileName(sequencedFileName,
                                                  nextSequenceNumber(sequencedFileName.sequenceNumberInt));
             File newLogFile = new File(logFile.getParentFile(), nextFileName);
             switchLogFile(newLogFile);
          }
          catch (Exception e)
          {
             LOG.log(Level.WARNING, "Couldn't rotate log file " + logFile.getPath());
          }
       }

       /**
        * Raise an error condition and optionally record the given error.
        *
        * @param    num
        *           The error number.
        * @param    text
        *           The text of the error message.
        * @param    prefix
        *           <code>true</code> to prepend a double asterisk prefix to the
        *           formatted error message, <code>false</code> to omit this text.
        *
        * @throws   ErrorConditionException
        */
       private void recordOrThrowError(int num, String text, boolean prefix)
       {
          ErrorManager.recordOrThrowLogManagerError(num, text, prefix);
       }

       /**
        * Manufacture a warning text based on the given parameters.
        *
        * @param    num
        *           The error number.
        * @param    text
        *           The text of the error message.
        * @param    prefix
        *           <code>true</code> to prepend a double asterisk prefix to the formatted error
        *           message, <code>false</code> to omit this text.
        */
       private void recordOrShowWarning(int num, String text, boolean prefix)
       {
          ErrorManager.recordOrShowLogManagerWarning(num, text, prefix);
       }

       /**
        * Interface to unite all implementations in returning common file name attributes - file name prefix
        * (clean name without number sequence or extension) and extension.
        */
       private interface FileName {
          /**
           * Returns the file name without extension.
           *
           * @return   The file name without extension.
           */
          String getPrefix();

          /**
           * Returns the file extension.
           *
           * @return   The file extension.
           */
          String getExt();
       }

       /** A class for generic file names. */
       private static class GenericFileName
          implements FileName
       {
          /** The name before the extension. */
          private String nameNoExt;
          /** The file extension, might not be present. */
          private String ext;

          /** Package private constructor to create an instance of GenericFileName. */
          GenericFileName(String fileName)
          {
             if (!fileName.contains("."))
             {
                nameNoExt = fileName;
                ext = null;
                return;
             }

             int lastDotIndex = fileName.lastIndexOf(".");
             nameNoExt = fileName.substring(0, lastDotIndex);

             ext = fileName.substring(lastDotIndex + 1);
             ext = ext.trim().isEmpty() ? null : ext.trim();
          }

          /**
           * Returns the file name without extension.
           *
           * @return   The file name without extension.
           */
          public String getPrefix()
          {
             return nameNoExt;
          }

          /**
           * Returns the file extension.
           *
           * @return   The file extension.
           */
          public String getExt()
          {
             return ext;
          }
       }

       /** A parser for log file names including a sequence number for rotation. */
       private static class SequencedFileName
          implements FileName
       {
          /** A pattern to match the file name (prefix), 6-digit sequence number and optional file extension. */
          private static final Pattern PATTERN = Pattern.compile("^(.*)\\.(\\d{6})(\\.([^.]+))?$");

          /** <code>true</code> if the file name matches the generic pattern, <code>false</code> otherwise. */
          private boolean isValid;
          /** The name prefix before the sequence number. */
          private String nameNoSequenceNoExt;
          /** The file extension, might not be present. */
          private String ext;
          /** The sequence number. */
          private int sequenceNumberInt;

          /** Package private constructor to create an instance of SequencedFileName. */
          SequencedFileName(String fileName)
          {
             Matcher sequencedNameMatcher = PATTERN.matcher(fileName);
             isValid = sequencedNameMatcher.matches();
             if (!isValid)
             {
                return;
             }
             nameNoSequenceNoExt = sequencedNameMatcher.group(1);
             String currentSequenceNumber = sequencedNameMatcher.group(2);
             ext = sequencedNameMatcher.group(4);
             sequenceNumberInt = Integer.parseInt(currentSequenceNumber);
          }

          /**
           * Returns the file name without sequence number and extension.
           *
           * @return   The file name without extension.
           */
          public String getPrefix()
          {
             return nameNoSequenceNoExt;
          }

          /**
           * Returns the file extension.
           *
           * @return   The file extension.
           */
          public String getExt()
          {
             return ext;
          }
       }

       /** A matcher for log file names including certain prefix, extension and sequence number for rotation. */
       private static class SequencedFileNameMatcher
       {
          /** The name prefix before the sequence number. */
          String nameNoSequenceNoExt;
          /** The file extension, might not be present. */
          String ext;
          /** Pattern to match the sequence number in the log file name. */
          Pattern sequencedNamePattern;

          /**
           * Package private constructor to create an instance of SequencedFileNameMatcher.
           *
           * @param    file
           *           The file.
           */
          SequencedFileNameMatcher(File file)
          {
             SequencedFileName sequencedFileName = new SequencedFileName(file.getName());
             initialize(sequencedFileName.getPrefix(), sequencedFileName.getExt());
          }

          /**
           * Package private constructor to create an instance of SequencedFileNameMatcher.

           * @param    nameNoSequenceNoExt
           *           The file name without number sequence and extension.
           * @param    ext
           *           The file extension.
           */
          SequencedFileNameMatcher(String nameNoSequenceNoExt, String ext)
          {
             initialize(nameNoSequenceNoExt, ext);
          }

          /**
           * Returns if the file name matches the pattern of the matcher.
           *
           * @param    fileName
           *           The file name to match against the pattern.
           *
           * @return   <code>true</code> if the file name matches the pattern of the matcher,
           *           <code>false</code> otherwise.
           */
          boolean matches(String fileName)
          {
             Matcher matcher = sequencedNamePattern.matcher(fileName);
             return matcher.matches();
          }

          /**
           * Finds the sequence number in the log file name that matches the pattern.
           *
           * @param    fileName
           *           The file name to match against the pattern.
           *
           * @return   The sequence number. <code>0</code> if no match found.
           */
          int findSequenceNumber(String fileName)
          {
             Matcher matcher = sequencedNamePattern.matcher(fileName);
             if (!matcher.matches())
             {
                return 0;
             }
             return Integer.parseInt(matcher.group(1));
          }

          /**
           * Returns the file name without the number sequence, keeping the base name (prefix) and ext if
           * available.
           *
           * @return   The file name without the number sequence.
           */
          String getNoSequenceName()
          {
             return ext == null ? nameNoSequenceNoExt : nameNoSequenceNoExt + "." + ext;
          }

          /**
           * Common logic for all constructors.
           *
           * @param    nameNoSequenceNoExt
           *           The file name without number sequence and extension.
           * @param    ext
           *           The file extension.
           */
          private void initialize(String nameNoSequenceNoExt, String ext)
          {
             this.nameNoSequenceNoExt = nameNoSequenceNoExt;
             this.ext = ext;
             String sequencePatternString = ext == null ?
                nameNoSequenceNoExt + "\\.(\\d{6})" :
                nameNoSequenceNoExt + "\\.(\\d{6})\\." + ext;
             this.sequencedNamePattern = Pattern.compile(sequencePatternString);
          }
       }

    }