# StanzaIni.java

    /*
    ** Module   : StanzaIni.java
    ** Abstract : Progress 4GL compatible Windows INI files related methods
    **
    ** Copyright (c) 2013-2024, Golden Code Development Corporation.
    **
    ** -#- -I- --Date-- ---------------------------------Description----------------------------------
    ** 001 EVL 20130604 Created initial version.
    ** 002 EVL 20140403 The file separator value should be taken from client side JVM, not from
    **                  EnvironmentOps class.  Because StanzaIni is the code running only in Windows
    **                  we can use system File.separator value here.
    ** 003 CA  20140702 Added getEnvironmentName API.
    ** 004 MAG 20140908 Make section names case insensitive. Added getEnvironmentType API.
    **                  Added read only options when INI files are deployed on jar files.
    ** 005 OM  20180606 Avoid duplicating .ini extension. Return null/unknown when entry not found.
    ** 006 CA  20180523 Fixed environment name for stanza - this is case sensitive.
    ** 007 RFB 20200609 The getFullIniFileName and openReader methods were not normalizing the workingDir
    **                  and fileName.
    **     RFB 20200610 openReader needs to check for file existing before attempting to open to prevent NPE.
    **     RFB 20200612 Enhanced the error reporting in openReader by maintaining the filename that fails to open.
    ** 008 HC  20230427 Implemented server-side file-system resource. File operations performed on
    **                  client can be optionally performed directly on server without the expensive
    **                  remote call.
    ** 009 GBB 20240826 Moving OSResourceManager to osresource package.
    */

    /*
    ** 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.util.*;

    import com.goldencode.p2j.ui.chui.ThinClient;
    import com.goldencode.p2j.util.osresource.*;

    // TODO: Implement PrintWriter supporting charset other than default one.

    /**
     * A class implementing the INI file based environment methods. The INI file is the plain text
     * file containing the inside the [] braces and key=value pairs within every section.
     * <p>
     * The INI file like other INI files in Windows allow to use the comment and empty strings to
     * be added into.  Every line beginning with ';' char is considered as comment line.  The INI
     * file allows section or key-value pair duplications.  Only first encountered section or key-value
     * pair will be meaningful in this case, the other will be ignored.  This is important to know
     * because after deletion the section or key the modified INI file is reloading and previously
     * ignored section or key-value pair become active.
     * <p>
     * Another note is some functionality is very strange for CHUI client mode on Windows.  It does
     * not follow the description in Progress ABL command reference book.  All such 'bugs' are
     * duplicated here.  It is getting the key or section list and removing the key-value pair or
     * section.
     *
     * @author    EVL
     */
    public class StanzaIni
    implements EnvironmentReader
    {
       /** The name of the INI file that holds the environment variables. */
       private String name = null;

       /** The name of the environment. */
       private String envName = null;

       /** Working directory value to search the INI file. */
       private String workingDirectory = null;

       /** The flag indicating whether we need to create new file instead of opening existed one. */
       private boolean isNew = false;

       /** Flag indicating the type of the client running, either CHUI or Windows graphical UI. */
       private boolean isChui = false;

       /** INI files read only property */
       private boolean isReadOnly = false;

       /** The ordered list of INI file content. */
       private List<Content> contList = null;

       /** The map that caches the INI file sections without any comments. */
       private Map<String, Content> sectMap = null;

       /**
        * Constructs an instance of the environment with INI file as backend.
        *
        * @param env
        *        The environment to create or load. The name without extension of the initialization
        *        file.
        * @param directory
        *        The directory path for existing initialization file or place to create
        *        initialization file.
        * @param flagNew
        *        If <code>true</code> - the new file will be created overwriting the possibly
        *        existed one.
        * @param readOnly
        *        If <code>true</code> INI files are loaded from server jar files otherwise from
        *        client file system.
        */
       public StanzaIni(String env, String directory, boolean flagNew, boolean readOnly)
       {
          contList = new LinkedList<Content>();
          sectMap = new TreeMap<String, Content>(String.CASE_INSENSITIVE_ORDER);

          // get driver type and read only property.
          ThinClient tc = ThinClient.getInstance();
          isChui = tc.isChui();

          if (env.indexOf('.') < 0)
          {
             env = env + ".ini";
          }

          name = env;
          if (directory != null)
          {
             workingDirectory = directory;
          }
          isNew = flagNew;

          isReadOnly = readOnly;

          // environment name should not contain .ini file extension
          envName = env;
       }

       /**
        * Creates application defaults implementing Progress LOAD statement.
        */
       public boolean load()
       {
          return (!isNew) ? loadIniFile() : createEmptyIniFile();
       }

       /**
        * Adds, modifies and deletes keys in current environment.  The real work it performs is
        * controlled by the input parameters.  In a simplest case when all three inputs have the real
        * not null and not empty string the method changes the value of the given key in the given
        * section of the currently loaded INI file.
        * <p>
        * The other possible cases are("" means the parameter is null or empty string if the
        * difference is not important):
        * key == "" deletes the given section with all it's content from the INI file.
        * value == "" removes the key-value pair from the given section of the current INI file.
        *
        * @param section
        *        The name of the section containing the key to modify.
        * @param key
        *        The name of the key key to modify or default key if not specified.
        * @param value
        *        The new value of the key under modification.
        */
       public void setKeyValue(String section, String key, String value)
       {
          // The 4GL does not allow to put extra spaces for sections and key names into INI file
          String sectionAsString = section.trim();
          String keyAsString = key.trim();
          // The 4GL does allow to put extra spaces for value string into INI file
          String valueAsString = value;

          if (keyAsString == null || keyAsString.isEmpty())
          {
             // In this case whole section and all key-value pairs in this section be removed
             removeSection(sectionAsString);
          }
          else if (valueAsString == null || valueAsString.isEmpty())
          {
             // In this case the specific key-value pair should be removed
             removeKey(sectionAsString, keyAsString);
          }
          else
          {
             // We have all options presented, do usual PUT-KEY-VALUE work
             SectionContent sectionEntry = getSection(sectionAsString);
             if (sectionEntry == null)
             {
                // This is the first key=value pair for new section
                sectionEntry = new SectionContent(sectionAsString);
                sectionEntry.store(keyAsString, valueAsString);
                contList.add(sectionEntry);
             }
             else
             {
                // Refresh already existed section
                int entryNdx = contList.indexOf(sectionEntry);
                sectionEntry.store(keyAsString, valueAsString);
                contList.set(entryNdx, sectionEntry);
             }
             // And refresh the section map too
             sectMap.put(sectionAsString, sectionEntry);

             commitIniFile();
          }
       }

       /**
        * Getting the key value from the current environment.  The real work it performs is
        * controlled by the input parameters.  In a simplest case when all two inputs have the real
        * not null and not empty string the method gets the value of the given key.
        * <p>
        * The other possible cases are("" means the parameter is null or empty string if the
        * difference is not important):
        * section == "" gets the comma separated list of all sections for the current INI file.
        * key == "" gets the comma separated list of all keys for the given section for the current
        *           INI file.
        *
        * @param section
        *        The name of the section containing the key to get.
        * @param key
        *        The name of the key to get or default key if not specified.
        *
        * @return The value of the key.
        */
       public String getKeyValue(String section, String key)
       {
          String sectionAsString = section.trim();
          String keyAsString = key.trim();
          String valueAsString = null;

          if (sectionAsString == null || sectionAsString.isEmpty())
          {
             // In this case we need to return the comma-separated list of the section in given file
             return getSections();
          }
          else if (keyAsString == null || keyAsString.isEmpty())
          {
             // In this case we need to return the comma-separated list of the keys in given section
             return getKeys(sectionAsString);
          }
          else
          {
             // We have all options presented, do usual GET-KEY-VALUE work
             SectionContent sectionEntry = getSection(sectionAsString);
             if (sectionEntry != null)
             {
                valueAsString = sectionEntry.getValueForKey(keyAsString);
             }
             // For missing entries 4GL returns empty string
             if (valueAsString == null)
             {
                return null;
             }
             else
             {
                // For statement GET-KEY-VALUE 4GL does not allow to return extra spaces unlike to
                // PUT-KEY-VALUE. Pretty strange behavior but was tested on real Windows 4GL system.
                return valueAsString.trim();
             }
          }
       }

       /**
        * Unloads specifies environment from the current one.
        */
       public void unload()
       {
          // Clean up memory allocated for this instance internals
          sectMap.clear();
          sectMap = null;
          contList.clear();
          contList = null;
       }

       /**
        * Get the environment's name.
        *
        * @return   See above.
        */
       public String getEnvironmentName()
       {
          return envName;
       }

       /**
        * Get the environment's type.
        *
        * @return   See above.
        */
       @Override
       public String getEnvironmentType()
       {
          return TYPE_INI;
       }

       /**
        * Removing the given section with all key-value pairs from the current environment.
        *
        * @param section
        *        The name of the whole section to delete.
        */
       private void removeSection(String section)
       {
          if (section != null && sectMap.containsKey(section))
          {
             // Get the section object to remove from the list
             SectionContent sectionEntry = getSection(section);
             // Yes, the behaviour in CHUI and Graphical interface is different
             if (isChui)
             {
                // For now 4GL CHUI adds = char to the end of the meaningfull part of the
                // section as if it is kew-value pair
                sectionEntry.storeAsPseudoKey("=");
                // Write changes to the file system
                commitIniFile();
             }
             else
             {
                // Remove section from map
                sectMap.remove(section);
                // And from list as well
                contList.remove(sectionEntry);
                // After deletion we need to write new content on disk first
                commitIniFile();
                // And load them into internal maps. This is not absurd, the modified INI file can
                // have different interpretation than before, for example if we had two sections with
                // the same name and one of them was just removed
                reloadIniFile();
             }
          }
       }

       /**
        * Removing the given key-value pair from the current environment in a given section.
        *
        * @param section
        *        The name of the section to delete the key-value pair.
        * @param key
        *        The name of the key to delete with its value.
        */
       private void removeKey(String section, String key)
       {
          // Take the section object
          SectionContent sectionEntry = getSection(section);
          // Yes, the behaviour in CHUI and Graphical interface is different
          if (isChui)
          {
             // For now instead of delete the key value become just empty
             if (sectionEntry != null)
             {
                // Refresh already existed section by storing empty value
                int entryNdx = contList.indexOf(sectionEntry);
                sectionEntry.store(key, "");
                contList.set(entryNdx, sectionEntry);
             }
             // And refresh the section map too
             sectMap.put(section, sectionEntry);
             // Write changes to file system
             commitIniFile();
          }
          else
          {
             if (sectionEntry != null)
             {
                sectionEntry.remove(key);
             }
             // After deletion we need to write new content on disk first
             commitIniFile();
             // And load them into internal maps. This is not absurd, the modified INI file can have
             // different interpretation than before, for example if we had two keys with the same
             // name and one of them was just removed
             reloadIniFile();
          }
       }

       /**
        * Getting the comma-separated list of the keys for the given section.
        *
        * @return The String with comma-separated key names.
        */
       private String getSections()
       {
          StringBuilder sb = new StringBuilder();

          // Yes, the behaviour in CHUI and Graphical interface is different
          if (isChui)
          {
             // 4GL Returns unknown character, this looks like a 4GL bug
             return null;
          }
          else
          {
             // Enumerate ordering list
             Iterator<Content> iterSect = contList.iterator();
             int count = 0;
             while (iterSect.hasNext())
             {
                // Get next available Renderable object
                Content objToCheck = (Content)iterSect.next();
                // Check if it is a member of the section map
                if (sectMap.containsValue(objToCheck))
                {
                   // This is the comma-separator if there is more than 1 name
                   if (count > 0)
                   {
                      sb.append(EnvironmentOps.PROPATH_SEPARATOR);
                   }
                   sb.append(((SectionContent)objToCheck).getName());
                   count++;
                }
             }
          }

          return sb.toString();
       }

       /**
        * Getting the comma-separated list of the keys for the given section.
        *
        * @param section
        *        The name of the section to analyze.
        *
        * @return The String with comma-separated key names.
        */
       private String getKeys(String section)
       {
          SectionContent sectionEntry = getSection(section);

          // Yes, the behaviour in CHUI and Graphical interface is different
          if (isChui)
          {
             // 4GL Returns unknown character, this looks like a 4GL bug
             return null;
          }
          else
          {
             if (sectionEntry != null)
             {
                return sectionEntry.getKeys();
             }
          }

          return null;
       }

       /**
        * Getting the object that holds the section from the previously formed environment.
        *
        * @param name
        *        The name of the section to look for.
        *
        * @return The SectionContent object or null if the section was not found.
        */
       private SectionContent getSection(String name)
       {
          if (sectMap != null)
          {
             return (SectionContent)sectMap.get(name);
          }

          return null;
       }

       /**
        * Reconstructs full initial file name with possible path.
        */
       private String getFullIniFileName()
       {
          StringBuilder fullName = new StringBuilder();

          // We have non-empty directory value, add it first
          if (workingDirectory != null && workingDirectory.length() > 0)
          {
             String wDir = FileSystemDaemon.normalizeFileSeparators(workingDirectory);
             fullName.append(wDir);
             if (!wDir.endsWith(File.separator))
             {
                fullName.append(File.separator);
             }
          }
          // And we have the non-empty file name, add it
          if (name != null && name.length() > 0)
          {
             fullName.append(name);
          }
          // If something was added, convert to String
          if (fullName.length() > 0)
          {
             return fullName.toString();
          }
          else // Otherwise return null
          {
             return null;
          }
       }

       /**
        * Open an INI file reader. By default INI files are read from file system.
        * Read only INI files are read as resources from jar files.
        *
        * @param   fileName
        *          INI File name.
        *
        * @return  Buffered reader object.
        *
        * @throws  FileNotFoundException if file not found
        */
       private BufferedReader openReader(String fileName)
       throws FileNotFoundException
       {
          BufferedReader br = null;
          ThinClient tc = ThinClient.getInstance();
          FileSystem fs = OSResourceManager.getFileSystem();

          // Read only INI files are loaded from jar files as resources.
          if (isReadOnly)
          {
             // load INI file as resource from server
             byte[] data = tc.getServer().loadEnvironment(fileName);
             if (data == null)
             {
                throw new FileNotFoundException("Cannot load " + fileName + " from server.");
             }
             ByteArrayInputStream inp = new ByteArrayInputStream(data);
             br = new BufferedReader(new InputStreamReader(inp));
          }
          else
          {
             // Normalize the filename
             String fN = FileSystemDaemon.search(null, fs.isCaseSensitive(), fileName, false);
             if (fN == null)
             {
                throw new FileNotFoundException("Cannot load " + fileName + " from server.");
             }

             // Open file stream for reading
             br = new BufferedReader(new FileReader(fN));
          }

          return br;
       }

       /**
        * Reads the current environment set from appropriate INI file in file system.
        *
        * @return  <code>true</code> if loaded false otherwise.
        */
       private boolean loadIniFile()
       {
          boolean loaded = false;

          String fileName = getFullIniFileName();

          if (fileName != null && fileName.length() > 0)
          {
             BufferedReader br = null;

             try
             {
                // open INI file reader
                br = openReader(fileName);

                String sectionName = null;
                Content contentEntry = null;

                String nextLine = br.readLine();
                // Read until EOF(nextLine == null)
                while (nextLine != null)
                {
                   // Next INI file section should start here
                   if (nextLine.length() > 2 && nextLine.charAt(0) == '[' &&
                       nextLine.charAt(nextLine.length() - 1) == ']')
                   {
                      // The previously constructed section should be saved before starting to
                      // process the next section
                      if (sectionName != null && contentEntry != null && contentEntry.notEmpty())
                      {
                         contList.add(contentEntry);
                         sectMap.put(sectionName, contentEntry);
                      }
                      // The new section name candidate
                      sectionName = new String(nextLine.substring(1,nextLine.length() - 1));
                      // Purge the previous one if it was existed
                      if (contentEntry != null)
                      {
                         contentEntry = null;
                      }
                      // Check if the section with the same name already added to the map. In this
                      // case we have to completely ignore more than one section occurrence
                      if (sectMap.containsKey(sectionName))
                      {
                         // Decline section name candidate
                         sectionName = null;
                         // And add the line with section name to the ordered list as simple line
                         contentEntry = new LineContent(nextLine);
                         // Put the simple line into ordered list
                         contList.add(contentEntry);
                         // And mark this is not a section start
                         contentEntry = null;
                      }
                   }
                   // Section processing
                   else
                   {
                      // Key-value candidate, get the index of separator first
                      int separatorNdx = nextLine.indexOf('=');
                      // Something should exist before and after separator and
                      // line is not a comment and we have the section to add the key
                      if (separatorNdx > 0 && nextLine.length() > separatorNdx + 1 &&
                          nextLine.trim().indexOf(';') != 0 && sectionName != null)
                      {
                         if (contentEntry == null)
                         {
                            contentEntry = new SectionContent(sectionName);
                         }
                         // Construct key-value pair from line string
                         // Key name should be trimmed, need to check other cases
                         String keyName = new String(nextLine.substring(0,separatorNdx).trim());
                         // The value should remain as it is in INI file on reading stage.
                         String keyValue = new String(nextLine.substring(separatorNdx + 1));
                         // Store the next key-value pair into section content
                         if (!((SectionContent)contentEntry).alreadyInSection(keyName))
                         {
                            ((SectionContent)contentEntry).add(keyName, keyValue);
                         }
                         // This is the case when the key already exists so just ignore second or
                         // more occurrence of the same key considering the key-value as simple line
                         else
                         {
                            ((SectionContent)contentEntry).store(nextLine);
                         }
                      }
                      // Here we add artificial meaningless data including empty lines between
                      // sections
                      else
                      {
                         // There was no sections at this time, create simple line
                         if (sectionName == null)
                         {
                            contentEntry = new LineContent(nextLine);
                            // Put the simple line into ordered list
                            contList.add(contentEntry);
                            // And mark this is not a section start
                            contentEntry = null;
                         }
                         // The line is a part of the section already created or just found
                         else
                         {
                            // This is the comment or space in the head of the section
                            if (contentEntry == null)
                            {
                               contentEntry = new SectionContent(sectionName);
                            }
                            // Store the simple line inside the section
                            ((SectionContent)contentEntry).store(nextLine);
                         }
                      }
                   }

                   // Read the next line from file
                   nextLine = br.readLine();
                   if (nextLine == null && sectionName != null &&
                       contentEntry != null && contentEntry.notEmpty())
                   {
                      // Possible last section in the file
                      contList.add(contentEntry);
                      sectMap.put(sectionName, contentEntry);
                   }
                }
                br.close();
                loaded = true;
             }
             catch (IOException ioex)
             {
             }
          }

          return loaded;
       }

       /**
        * Creates the empty INI file for current environment set in file system.
        */
       private boolean createEmptyIniFile()
       {
          boolean created = false;

          String fileName = getFullIniFileName();

          if (fileName != null && fileName.length() > 0)
          {
             PrintWriter pw = null;

             try
             {
                // Open file for writing
                pw = new PrintWriter(new FileWriter(fileName, false));
                created = true;
             }
             catch (IOException ioex)
             {
             }
             finally
             {
                // Do nothing and close it
                if (pw != null)
                {
                   pw.close();
                }
             }
          }

          return created;
       }

       /**
        * Writes the current environment set to the appropriate INI file in file system.
        */
       private void commitIniFile()
       {
          String fileName = getFullIniFileName();

          if (fileName != null && fileName.length() > 0)
          {
             PrintWriter pw = null;

             try
             {
                // Open file for writing
                pw = new PrintWriter(new FileWriter(fileName, false));
                // Sections iterator
                Iterator<Content> iterEntry = contList.iterator();
                while (iterEntry.hasNext())
                {
                   // Get next available content entry, section or ignoring line
                   Content contEntry = (Content)iterEntry.next();
                   // If entry is not empty write the content to file
                   if (contEntry.notEmpty())
                   {
                      contEntry.write(pw);
                   }
                }
                // Commit the changes
                pw.flush();
             }
             catch (IOException ioex)
             {
             }
             finally
             {
                if (pw != null)
                {
                   // And finally close the file
                   pw.close();
                }
             }
          }
       }

       /**
        * Reloads the INI file replacing the given internal maps with the new ones from changed INI
        * file taken from file system.
        */
       private void reloadIniFile()
       {
          sectMap.clear();
          contList.clear();
          loadIniFile();
       }
    }