# JsonBackend.java

    /*
    ** Module   : JsonBackend.java
    ** Abstract : Json backend.
    **
    ** Copyright (c) 2019-2024, Golden Code Development Corporation.
    **
    ** -#- -I- --Date-- ---------------------------------------Description----------------------------------------
    ** 001 HC  20190701 Initial version.
    ** 002 HC  20190714 Added more serializable number types.
    ** 003 ME  20210128 Escape forward slash as in 4GL, json data type names.
    **         20210215 Extend pretty printer to match the 4GL formatted output.
    **         20210319 Flush output stream on write, replace BigDecimal use for integer/int64.
    ** 004 CA  20210709 JSON parse must use a pristine byte stream and use the reader to decode it.
    **     OM  20210809 Added write/convert support for more object types. Fixed javadoc errors.
    **     OM  20210825 Added public constants for numbered JSON types. Added missing javadoc.
    **     CA  20220127 Don't use scientific notation for decimal values.
    **     CA  20220218 Force scale 1 for decimal values with trailing zeroes.
    **     ME  20220427 Check for invalid json construct object while parsing tokens. See #6048.
    **     VVT 20220427 Missing Javadocs comments added, some methods made static as appropriate.
    **     TJD 20240508 Fixed compiler warnings related with dependencies updates
    */

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

    import com.fasterxml.jackson.core.JsonParser;
    import com.fasterxml.jackson.core.io.CharacterEscapes;
    import com.fasterxml.jackson.core.io.SerializedString;
    import com.fasterxml.jackson.core.*;
    import com.fasterxml.jackson.core.JsonGenerator.Feature;
    import com.fasterxml.jackson.core.util.*;
    import com.fasterxml.jackson.core.util.DefaultPrettyPrinter.Indenter;
    import com.goldencode.p2j.oo.json.objectmodel.*;
    import com.goldencode.p2j.oo.lang.*;
    import com.goldencode.p2j.util.*;
    import java.io.*;
    import java.math.*;
    import java.nio.charset.Charset;
    import java.util.*;

    /**
     * Json backend implementation providing methods for the json legacy classes and functions. The idea is to
     * hide the specific json implementation behind this facade.
     */
    public class JsonBackend
    {
       /** Numeric value for JSON String type. Returned by {@code getType()} / {@code getJsonDataType()}. */
       public static final int JSON_STRING = 1;

       /** Numeric value for JSON Number type. Returned by {@code getType()} / {@code getJsonDataType()}. */
       public static final int JSON_NUMBER = 2;

       /** Numeric value for JSON Logical type. Returned by {@code getType()} / {@code getJsonDataType()}. */
       public static final int JSON_BOOLEAN = 3;

       /** Numeric value for JSON Object type. Returned by {@code getType()} / {@code getJsonDataType()}. */
       public static final int JSON_OBJECT = 4;

       /** Numeric value for JSON Array type. Returned by {@code getType()} / {@code getJsonDataType()}. */
       public static final int JSON_ARRAY = 5;

       /** Numeric value for JSON null type. Returned by {@code getType()} / {@code getJsonDataType()}. */
       public static final int JSON_NULL = 6;

       /**
        * The singleton instance.
        */
       private static final JsonBackend instance = new JsonBackend();

       /**
        * Thread-safe factory instance.
        */
       private final JsonFactory factory = new JsonFactory().configure(Feature.WRITE_BIGDECIMAL_AS_PLAIN, true);

       /**
        * Default constructor.
        */
       private JsonBackend()
       {
       }

       /**
        * Returns the singleton instance.
        *
        * @return  see above.
        */
       public static JsonBackend instance()
       {
          return instance;
       }

       /**
        * Serializes the supplied json construct instance to the target output stream.
        *
        * @param   target
        *          Target output stream.
        * @param   construct
        *          The json construct instance to serialize.
        * @param   prettyPrint
        *          If {@code true} the output will be formatted.
        * @param   legacyEnc
        *          The character encoding name to use.
        *
        * @return  the Java encoding name resolved from the legacy encoding name.
        *
        * @throws  IOException
        *          in case of an IO error.
        */
       public String write(OutputStream target, JsonConstruct construct, boolean prettyPrint, String legacyEnc)
       throws IOException
       {
          JsonEncoding enc = getEncoding(legacyEnc);
          JsonGenerator gen = factory.createGenerator(target, enc);
          if (prettyPrint)
          {
             gen.setPrettyPrinter(ProPrettyPrinter.instance());
          }

          // 4GL escapes forward slash
          gen.setCharacterEscapes(ProCharacterEscapes.instance());
          writeConstruct(gen, construct);
          gen.flush();

          // skip this as it will close the stream and not always what we need gen.close();

          // 4GL add a new line at the end of document
          if (prettyPrint)
          {
             target.write("\n".getBytes(Charset.forName(enc.getJavaName())));
          }

          // make sure we write everything, do not close the stream though
          target.flush();

          return enc.getJavaName();
       }

       /**
        * Deserializes the source text into a json object tree.
        *
        * @param   source
        *          The source text to parse.
        *
        * @return  json construct instance.
        *
        * @throws IOException
        * @throws JsonParseException
        */
       public object<? extends JsonConstruct> parse(String source) throws JsonParseException, IOException
       {
             JsonParser parser = factory.createParser(source);
             return parseImpl(parser);
       }

       /**
        * Deserializes the supplied input stream into a json object tree.
        *
        * @param   source
        *          The input reader to parse.
        *
        * @return  json construct instance.
        *
        * @throws IOException
        * @throws JsonParseException
        */
       public object<? extends JsonConstruct> parse(Reader source) throws JsonParseException, IOException
       {
             JsonParser parser = factory.createParser(source);
             return parseImpl(parser);
       }

       /**
        * Converts the legacy value to a native Java value suitable for json serialization.
        *
        * @param   value
        *          The legacy value to convert.
        *
        * @return  the converted value.
        */
       public static Object convertLegacyValueToJson(BaseDataType value)
       {
          if (value == null)
          {
             throw new IllegalArgumentException();
          }

          if (value.isUnknown())
          {
             return null;
          }

          Object targetValue = null;

          // convert the value to json-compatible data type
          if (value instanceof integer)
          {
             targetValue = ((integer) value).toJavaIntegerType();
          }
          else if (value instanceof int64)
          {
             targetValue = ((int64) value).toJavaLongType();
          }
          else if (value instanceof decimal)
          {
             decimal d = (decimal) value;
             BigDecimal bd = d.toBigDecimal().stripTrailingZeros();
             targetValue = bd;

             if (d.getPrecision() != 0 && bd.toPlainString().indexOf('.') < 0)
             {
                targetValue = bd.setScale(1);
             }
          }
          else if (value instanceof NumberType)
          {
             targetValue = ((NumberType) value).toBigDecimal().stripTrailingZeros();
          }
          else if (value instanceof Text)
          {
             targetValue = ((Text) value).getValue();
          }
          else if (value instanceof logical)
          {
             targetValue = ((logical) value).booleanValue() ? Boolean.TRUE : Boolean.FALSE;
          }
          else if (value instanceof date)
          {
             targetValue = date.isoDate((date) value).toStringMessage();
          }
          else if (value instanceof handle)
          {
             targetValue = BigInteger.valueOf(((handle) value).getResourceId());
          }
          else if (value instanceof comhandle)
          {
             Long id = comhandle.resourceId(((comhandle) value).getResource());
             targetValue = BigInteger.valueOf(id);
          }
          else if (value instanceof BinaryData)
          {
             Base64.Encoder enc = Base64.getEncoder();
             targetValue = enc.encodeToString(((BinaryData) value).getByteArray());
          }
          else if (value instanceof object)
          {
             // do the type cast, we want to see the class cast exception eventually
             targetValue = ((object) value).ref();
          }
          else if (value instanceof rowid)
          {
             Base64.Encoder enc = Base64.getEncoder();
             targetValue = enc.encodeToString(Utils.longToBytesLE(((rowid) value).getValue()));
          }

          return targetValue;
       }

       /**
        * Implementation of {@code getType} legacy method of {@linkplain JsonArray} and
        * {@linkplain JsonConstruct}.
        *
        * @param   value
        *          Json value.
        *
        * @return  the numeric value representing the value type.
        */
       public static int getJsonDataType(Object value)
       {
          if (value == null)
          {
             return JSON_NULL;
          }
          else if (value instanceof String)
          {
             return JSON_STRING;
          }
          else if (value instanceof Number)
          {
             return JSON_NUMBER;
          }
          else if (value instanceof Boolean)
          {
             return JSON_BOOLEAN;
          }
          else if (value instanceof JsonObject)
          {
             return JSON_OBJECT;
          }
          else if (value instanceof JsonArray)
          {
             return JSON_ARRAY;
          }
          else
          {
             throw new RuntimeException("Unexpected json value data type " + value.getClass());
          }
       }

       /**
        * Returns the string representation of object's corresponding JSON data type.
        *
        * @param   type
        *          Json data type.
        *
        * @return  the string value representing the JSON data type.
        */
       public static String getJsonDataTypeName(int type)
       {
          switch (type)
          {
             case JSON_ARRAY:   return "array";
             case JSON_OBJECT:  return "object";
             case JSON_BOOLEAN: return "boolean";
             case JSON_NUMBER:  return "number";
             case JSON_STRING:  return "string";
             default:           return "null";
          }
       }

       /**
        * Implements serialization of the supplied json construct instance using the supplied json generator.
        *
        * @param   jsonGen
        *          Json generator.
        * @param   construct
        *          The json construct instance to serialize.
        *
        * @throws  IOException
        *          in case of an IO error.
        */
       private void writeConstruct(JsonGenerator jsonGen, JsonConstruct construct)
       throws IOException
       {
          if (construct instanceof JsonArray)
          {
             jsonGen.writeStartArray();
             for (Object element : ((JsonArray) construct).getElements())
             {
                writeValue(jsonGen, element);
             }
             jsonGen.writeEndArray();
          }
          else
          {
             jsonGen.writeStartObject();
             for (Map.Entry<String, Object> prop : ((JsonObject) construct).getProperties())
             {
                jsonGen.writeFieldName(prop.getKey());
                writeValue(jsonGen, prop.getValue());
             }
             jsonGen.writeEndObject();
          }
       }

       /**
        * Implements serialization of the supplied json construct instance using the supplied json generator.
        *
        * @param   jsonGen
        *          Json generator.
        * @param   value
        *          The value to serialize.
        *
        * @throws  IOException
        *          in case of an IO error.
        */
       private void writeValue(JsonGenerator jsonGen, Object value)
       throws IOException
       {
          if (value == null)
          {
             jsonGen.writeNull();
          }
          else if (value instanceof JsonConstruct)
          {
             writeConstruct(jsonGen, (JsonConstruct) value);
          }
          else if (value instanceof BaseObject)
          {
             BaseObject bo = (BaseObject) value;

             jsonGen.writeStartObject();
                jsonGen.writeFieldName("prods:objId");
                jsonGen.writeNumber(object.resourceId(new object<>(bo)));

                jsonGen.writeFieldName(bo.getLegacyClass().ref().getTypeName().toStringMessage());
                jsonGen.writeStartObject();
                   // what here?
                jsonGen.writeEndObject();
             jsonGen.writeEndObject();
          }
          else if (value instanceof Boolean)
          {
             jsonGen.writeBoolean((Boolean) value);
          }
          else if (value instanceof BigInteger)
          {
             jsonGen.writeNumber((BigInteger) value);
          }
          else if (value instanceof BigDecimal)
          {
             BigDecimal bd = (BigDecimal) value;

             if (bd.scale() == 0)
             {
                jsonGen.writeNumber(bd.doubleValue());
             }
             else
             {
                jsonGen.writeNumber(bd);
             }
          }
          else if (value instanceof String)
          {
             jsonGen.writeString((String) value);
          }
          else if (value instanceof Integer)
          {
             jsonGen.writeNumber((Integer) value);
          }
          else if (value instanceof Long)
          {
             jsonGen.writeNumber((Long) value);
          }
          else if (value instanceof Float)
          {
             jsonGen.writeNumber((Float) value);
          }
          else if (value instanceof Short)
          {
             jsonGen.writeNumber((Short) value);
          }
          else if (value instanceof Double)
          {
             jsonGen.writeNumber((Double) value);
          }
          else
          {
             throw new RuntimeException("Unexpected primitive value type " + value.getClass());
          }
       }

       /**
        * Deserializes json object tree using the supplied parser as the input of json tokens.
        *
        * @param   parser
        *          Setup json parser.
        *
        * @return  json construct instance.
        *
        * @throws JsonParseException
        */
       private static object<? extends JsonConstruct> parseImpl(JsonParser parser) throws JsonParseException
       {
          try
          {
             Stack<object<? extends JsonConstruct>> constructStack = new Stack<>();
             JsonToken tok;
             object<? extends JsonConstruct> result = null;

             while ((tok = parser.nextToken()) != null)
             {
                JsonConstruct con = constructStack.isEmpty() ? null : constructStack.peek().ref();
                String name = parser.currentName();

                switch (tok)
                {
                   case START_OBJECT:
                   case START_ARRAY:
                      object<? extends JsonConstruct> newCon =
                         tok == JsonToken.START_OBJECT ? ObjectOps.newInstance(JsonObject.class) :
                                                         ObjectOps.newInstance(JsonArray.class);

                      if (con != null)
                      {
                         con.addElement(name, newCon);
                      }
                      constructStack.push(newCon);
                      if (result == null)
                      {
                         result = newCon;
                      }
                      break;
                   case END_ARRAY:
                   case END_OBJECT:
                      constructStack.pop();
                      break;
                   case VALUE_TRUE:
                      checkValidConstruct(con, parser);
                      con.addElement(name, true);
                      break;
                   case VALUE_FALSE:
                      checkValidConstruct(con, parser);
                      con.addElement(name, false);
                      break;
                   case VALUE_NUMBER_FLOAT:
                      checkValidConstruct(con, parser);
                      con.addElement(name, parser.getDecimalValue());
                      break;
                   case VALUE_NUMBER_INT:
                      checkValidConstruct(con, parser);
                      con.addElement(name, parser.getBigIntegerValue());
                      break;
                   case VALUE_STRING:
                      checkValidConstruct(con, parser);
                      con.addElement(name, parser.getValueAsString());
                      break;
                   case VALUE_NULL:
                      checkValidConstruct(con, parser);
                      con.addElementNull(name);
                      break;
                   case FIELD_NAME:
                      // do nothing, the name is retrieved from getCurrentName
                      break;
                   default:
                      throwParserException(parser);
                }
             }

             if (result == null)
             {
                throwParserException(parser);
             }

             return result;
          }
          catch (IOException ex)
          {
             throw new JsonParseException(parser, ex.getMessage(), ex);
          }
       }

       /**
        * Throw a {@link JsonParseException} if the the argument is not a valid construct.
        *
        * @param con
        *        the construct to check.
        * @param parser
        *        the parser to use to create the exception
        *
        * @throws JsonParseException
        *         if the argument is {@code null}
        */
       private static void checkValidConstruct(JsonConstruct con, JsonParser parser) throws JsonParseException {
          if (con == null)
          {
             throwParserException(parser);
          }
       }

       /**
        * Convenience function to throw {@link JsonParseException}.
        *
        * @param parser
        *        the parser to use to create the exception
        *
        * @throws JsonParseException
        */
       private static void throwParserException(JsonParser parser) throws JsonParseException {
          throw new JsonParseException(parser, "Invalid JSON");
       }

       /**
        * Converts the legacy encoding name to JsonEncoding.
        *
        * @param   legacyEncoding
        *          Legacy encoding.
        *
        * @return  see above.
        */
       private static JsonEncoding getEncoding(String legacyEncoding)
       {
          if (legacyEncoding == null)
          {
             return JsonEncoding.UTF8;
          }

          switch (legacyEncoding.toUpperCase())
          {
             case "":
             case "UTF-8":
                return JsonEncoding.UTF8;
             case "UTF-16":
             case "UTF-16LE":
                return JsonEncoding.UTF16_LE;
             case "UTF-16BE":
                return JsonEncoding.UTF16_BE;
             case "UTF-32":
             case "UTF-32LE":
                return JsonEncoding.UTF32_LE;
             case "UTF-32BE":
                return JsonEncoding.UTF32_BE;
             default:
                throw new RuntimeException("Unsupported encoding " + legacyEncoding);
          }
       }

       /**
        * A singleton class used for escaping characters while writing JSON strings. Takes special care for the
        * '/' character.
        */
       private static class ProCharacterEscapes
       extends CharacterEscapes
       {
          /** The singleton instance. */
          private static final ProCharacterEscapes _escapeInstance = new ProCharacterEscapes();

          /**
           * Getter for the singleton instance.
           *
           * @return  the singleton instance.
           */
          public static ProCharacterEscapes instance() { return _escapeInstance; }

          /** The special character '/'. */
          private final SerializedString fwdSlash;

          /** Codes of all escaped characters. */
          private final int[] asciiEscapes;

          /** The private constructor of the singleton. */
          private ProCharacterEscapes()
          {
             fwdSlash = new SerializedString("\\/");
             asciiEscapes = standardAsciiEscapesForJSON();
             asciiEscapes['/'] = CharacterEscapes.ESCAPE_CUSTOM;
          }

          /**
           * Obtain the full set of escaped ASCII codes.
           *
           * @return  the full set of escaped ASCII codes.
           */
          @Override
          public int[] getEscapeCodesForAscii()
          {
             return asciiEscapes;
          }

          /**
           * Get the escape string for a character {@code ch}.
           *
           * @param   ch
           *          The character to be escaped.
           *
           * @return  The escape string {@code ch} character.
           */
          @Override
          public SerializableString getEscapeSequence(int ch)
          {
             if (ch == '/')
             {
                return fwdSlash;
             }

             return null;
          }
       }

       /** Singleton used for overwriting the separator between the field name and its value in a JSON string. */
       private static class ProPrettyPrinter
       extends DefaultPrettyPrinter
       {
          /** The singleton instance. */
          private static final ProPrettyPrinter _printerInstance = new ProPrettyPrinter();

          /**
           * Getter for the singleton instance.
           *
           * @return  The singleton instance.
           */
          public static ProPrettyPrinter instance() { return _printerInstance; }

          /** The private constructor */
          private ProPrettyPrinter()
          {
             indentArraysWith(TwoSpacesIndenter.instance());
             indentObjectsWith(TwoSpacesIndenter.instance());
          }

          /**
           * Core method of the class. Overwrites the separator to match 4GL output.
           * @param   g
           *          The {@code JsonGenerator} in use.
           *
           * @throws  IOException
           *          if {@code g.writeRaw} fails.
           */
          @Override
          public void writeObjectFieldValueSeparator(JsonGenerator g)
          throws IOException
          {
             // no space between name and colon
             g.writeRaw(": ");
          }
       }

       /** Singleton used for matching the indentation of the output JSON string with 4GL.*/
       private static class TwoSpacesIndenter
       implements Indenter
       {
          /** The singleton instance. */
          private static final TwoSpacesIndenter _indenterInstance = new TwoSpacesIndenter();

          /**
           * The singleton getter.
           *
           * @return  The singleton instance.
           */
          public static TwoSpacesIndenter instance() { return _indenterInstance; }

          /**
           * Check whether the JSON string is inlined.
           *
           * @return  always {@code false}.
           */
          @Override
          public boolean isInline()
          {
             return false;
          }

          /**
           * Core method of the utility class. Prepares an indented new line.
           *
           * @param   g
           *          The {@code JsonGenerator} in use.
           * @param   level
           *          The indentation level.
           * @throws  IOException
           *          if {@code g.writeRaw} fails.
           */
          @Override
          public void writeIndentation(JsonGenerator g, int level)
          throws IOException
          {
             g.writeRaw('\n');

             for (int i = 0; i < level; i++)
             {
                g.writeRaw("  ");
             }
          }
       }
    }