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);
      }
   }

}