content: /*
   * Licensed to the Apache Software Foundation (ASF) under one or more
   * contributor license agreements.  See the NOTICE file distributed with
   * this work for additional information regarding copyright ownership.
   * The ASF licenses this file to You under the Apache License, Version 2.0
   * (the "License"); you may not use this file except in compliance with
   * the License.  You may obtain a copy of the License at
   *
   *      http://www.apache.org/licenses/LICENSE-2.0
   *
   * Unless required by applicable law or agreed to in writing, software
   * distributed under the License is distributed on an "AS IS" BASIS,
   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   * See the License for the specific language governing permissions and
   * limitations under the License.
   */
  package org.apache.camel.dataformat.bindy;
  
  import java.lang.reflect.Field;
  import java.lang.reflect.Method;
  import java.util.ArrayList;
  import java.util.Collection;
  import java.util.HashMap;
  import java.util.Iterator;
  import java.util.LinkedList;
  import java.util.List;
  import java.util.Map;
  import java.util.Map.Entry;
  import java.util.TreeMap;
  
  import org.apache.camel.CamelContext;
  import org.apache.camel.dataformat.bindy.annotation.BindyConverter;
  import org.apache.camel.dataformat.bindy.annotation.DataField;
  import org.apache.camel.dataformat.bindy.annotation.FixedLengthRecord;
  import org.apache.camel.dataformat.bindy.annotation.Link;
  import org.apache.camel.dataformat.bindy.format.FormatException;
  import org.apache.camel.dataformat.bindy.util.ConverterUtils;
  import org.apache.camel.support.ObjectHelper;
  import org.apache.camel.util.ReflectionHelper;
  import org.slf4j.Logger;
  import org.slf4j.LoggerFactory;
  
  /**
   * The BindyFixedLengthFactory is the class who allows to : Generate a model associated to a fixed length record, bind
   * data from a record to the POJOs, export data of POJOs to a fixed length record and format data into String, Date,
   * Double, ... according to the format/pattern defined
   */
  public class BindyFixedLengthFactory extends BindyAbstractFactory implements BindyFactory {
  
      private static final Logger LOG = LoggerFactory.getLogger(BindyFixedLengthFactory.class);
  
      boolean isOneToMany;
  
      private Map<Integer, DataField> dataFields = new TreeMap<>();
      private Map<Integer, Field> annotatedFields = new TreeMap<>();
  
      private int numberOptionalFields;
      private int numberMandatoryFields;
      private int totalFields;
  
      private boolean hasHeader;
      private boolean skipHeader;
      private boolean isHeader;
      private boolean hasFooter;
      private boolean skipFooter;
      private boolean isFooter;
      private char paddingChar;
      private int recordLength;
      private boolean ignoreTrailingChars;
      private boolean ignoreMissingChars;
      private boolean countGrapheme;
  
      private Class<?> header;
      private Class<?> footer;
  
      public BindyFixedLengthFactory(Class<?> type) throws Exception {
          super(type);
  
          header = void.class;
          footer = void.class;
  
          // initialize specific parameters of the fixed length model
          initFixedLengthModel();
      }
  
      /**
       * method uses to initialize the model representing the classes who will bind the data. This process will scan for
       * classes according to the package name provided, check the annotated classes and fields
       */
      public void initFixedLengthModel() {
  
          // Find annotated fields declared in the Model classes
          initAnnotatedFields();
  
          // initialize Fixed length parameter(s)
          // from @FixedLengthrecord annotation
          initFixedLengthRecordParameters();
      }
  
      @Override
      public void initAnnotatedFields() {
  
          for (Class<?> cl : models) {
  
              List<Field> linkFields = new ArrayList<>();
  
              if (LOG.isDebugEnabled()) {
                  LOG.debug("Class retrieved: {}", cl.getName());
              }
  
              for (Field field : cl.getDeclaredFields()) {
                  DataField dataField = field.getAnnotation(DataField.class);
                  if (dataField != null) {
  
                      if (LOG.isDebugEnabled()) {
                          LOG.debug("Position defined in the class: {}, position: {}, Field: {}", cl.getName(), dataField.pos(),
                                  dataField);
                      }
  
                      if (dataField.required()) {
                          ++numberMandatoryFields;
                      } else {
                          ++numberOptionalFields;
                      }
  
                      dataFields.put(dataField.pos(), dataField);
                      annotatedFields.put(dataField.pos(), field);
                  }
  
                  Link linkField = field.getAnnotation(Link.class);
  
                  if (linkField != null) {
                      if (LOG.isDebugEnabled()) {
                          LOG.debug("Class linked: {}, Field: {}", cl.getName(), field);
                      }
                      linkFields.add(field);
                  }
  
              }
  
              if (!linkFields.isEmpty()) {
                  annotatedLinkFields.put(cl.getName(), linkFields);
              }
  
              totalFields = numberMandatoryFields + numberOptionalFields;
  
              if (LOG.isDebugEnabled()) {
                  LOG.debug("Number of optional fields: {}", numberOptionalFields);
                  LOG.debug("Number of mandatory fields: {}", numberMandatoryFields);
                  LOG.debug("Total: {}", totalFields);
              }
  
          }
      }
  
      // Will not be used in the case of a Fixed Length record
      // as we provide the content of the record and
      // we don't split it as this is the case for a CSV record
      @Override
      public void bind(CamelContext camelContext, List<String> data, Map<String, Object> model, int line) throws Exception {
          // noop
      }
  
      public void bind(CamelContext camelContext, String recordStr, Map<String, Object> model, int line) throws Exception {
  
          int pos = 1;
          int counterMandatoryFields = 0;
          DataField dataField;
          String token;
          int offset = 1;
          int length;
          String delimiter;
          Field field;
  
          final UnicodeHelper unicodeHelper = new UnicodeHelper(
                  recordStr, (this.countGrapheme) ? UnicodeHelper.Method.GRAPHEME : UnicodeHelper.Method.CODEPOINTS);
  
          // Iterate through the list of positions
          // defined in the @DataField
          // and grab the data from the line
          Collection<DataField> c = dataFields.values();
          Iterator<DataField> itr = c.iterator();
  
          // this iterator is for a link list that was built using items in order
          while (itr.hasNext()) {
              dataField = itr.next();
              length = dataField.length();
              delimiter = dataField.delimiter();
  
              if (length == 0 && dataField.lengthPos() != 0) {
                  Field lengthField = annotatedFields.get(dataField.lengthPos());
                  lengthField.setAccessible(true);
                  Object modelObj = model.get(lengthField.getDeclaringClass().getName());
                  Object lengthObj = lengthField.get(modelObj);
                  length = ((Integer) lengthObj).intValue();
              }
              if (length < 1 && delimiter == null && dataField.lengthPos() == 0) {
                  throw new IllegalArgumentException(
                          "Either length or delimiter must be specified for the field : " + dataField.toString());
              }
              if (offset - 1 <= -1) {
                  throw new IllegalArgumentException(
                          "Offset/Position of the field " + dataField.toString()
                                                     + " cannot be negative");
              }
  
              // skip ahead if the expected position is greater than the offset
              if (dataField.pos() > offset) {
                  LOG.debug("skipping ahead [{}] chars.", dataField.pos() - offset);
                  offset = dataField.pos();
              }
  
              if (length > 0) {
                  if (unicodeHelper.length() < offset) {
                      token = "";
                  } else {
                      int endIndex = offset + length - 1;
                      if (endIndex > unicodeHelper.length()) {
                          endIndex = unicodeHelper.length();
                      }
                      token = unicodeHelper.substring(offset - 1, endIndex);
                  }
                  offset += length;
              } else if (!delimiter.isEmpty()) {
                  final UnicodeHelper tempToken = new UnicodeHelper(
                          unicodeHelper.substring(offset - 1, unicodeHelper.length()),
                          (this.countGrapheme) ? UnicodeHelper.Method.GRAPHEME : UnicodeHelper.Method.CODEPOINTS);
                  token = tempToken.substring(0, tempToken.indexOf(delimiter));
                  // include the delimiter in the offset calculation
                  offset += token.length() + 1;
              } else {
                  // defined as a zero-length field
                  token = "";
              }
  
              if (dataField.trim()) {
                  token = trim(token, dataField, paddingChar);
                  //token = token.trim();
              }
  
              // Check mandatory field
              if (dataField.required()) {
  
                  // Increment counter of mandatory fields
                  ++counterMandatoryFields;
  
                  // Check if content of the field is empty
                  // This is not possible for mandatory fields
                  if (token.isEmpty()) {
                      throw new IllegalArgumentException(
                              "The mandatory field defined at the position " + pos
                                                         + " is empty for the line: " + line);
                  }
              }
  
              // Get Field to be set
              field = annotatedFields.get(dataField.pos());
              field.setAccessible(true);
  
              if (LOG.isDebugEnabled()) {
                  LOG.debug("Pos/Offset: {}, Data: {}, Field type: {}", offset, token, field.getType());
              }
  
              // Create format object to format the field
              FormattingOptions formattingOptions = ConverterUtils.convert(dataField,
                      field.getType(),
                      field.getAnnotation(BindyConverter.class),
                      getLocale());
              Format<?> format = formatFactory.getFormat(formattingOptions);
  
              // field object to be set
              Object modelField = model.get(field.getDeclaringClass().getName());
  
              // format the data received
              Object value;
  
              if (token.isEmpty()) {
                  token = dataField.defaultValue();
              }
              if (!token.isEmpty()) {
                  try {
                      value = format.parse(token);
                  } catch (FormatException ie) {
                      throw new IllegalArgumentException(ie.getMessage() + ", position: " + offset + ", line: " + line, ie);
                  } catch (Exception e) {
                      throw new IllegalArgumentException(
                              "Parsing error detected for field defined at the position/offset: " + offset + ", line: " + line,
                              e);
                  }
              } else {
                  value = getDefaultValueForPrimitive(field.getType());
              }
  
              if (value != null && !dataField.method().isEmpty()) {
                  Class<?> clazz;
                  if (dataField.method().contains(".")) {
                      clazz = camelContext.getClassResolver()
                              .resolveMandatoryClass(dataField.method().substring(0, dataField.method().lastIndexOf('.')));
                  } else {
                      clazz = field.getType();
                  }
  
                  String methodName = dataField.method().substring(dataField.method().lastIndexOf('.') + 1,
                          dataField.method().length());
  
                  Method m = ReflectionHelper.findMethod(clazz, methodName, field.getType());
                  if (m != null) {
                      // this method must be static and return type
                      // must be the same as the datafield and
                      // must receive only the datafield value
                      // as the method argument
                      value = ObjectHelper.invokeMethod(m, null, value);
                  } else {
                      // fallback to method without parameter, that is on the value itself
                      m = ReflectionHelper.findMethod(clazz, methodName);
                      value = ObjectHelper.invokeMethod(m, value);
                  }
              }
  
              field.set(modelField, value);
  
              ++pos;
  
          }
  
          // check for unmapped non-whitespace data at the end of the line
          if (offset <= unicodeHelper.length() && !(unicodeHelper.substring(offset - 1, unicodeHelper.length())).isBlank()
                  && !isIgnoreTrailingChars()) {
              throw new IllegalArgumentException(
                      "Unexpected / unmapped characters found at the end of the fixed-length record at line : " + line);
          }
  
          LOG.debug("Counter mandatory fields: {}", counterMandatoryFields);
  
          if (pos < totalFields) {
              throw new IllegalArgumentException("Some fields are missing (optional or mandatory), line: " + line);
          }
  
          if (counterMandatoryFields < numberMandatoryFields) {
              throw new IllegalArgumentException("Some mandatory fields are missing, line: " + line);
          }
  
      }
  
      private String trim(String token, DataField dataField, char paddingChar) {
          char myPaddingChar = dataField.paddingChar();
          if (dataField.paddingChar() == 0) {
              myPaddingChar = paddingChar;
          }
          if ("R".equals(dataField.align())) {
              return leftTrim(token, myPaddingChar);
          } else if ("L".equals(dataField.align())) {
              return rightTrim(token, myPaddingChar);
          } else {
              token = leftTrim(token, myPaddingChar);
              return rightTrim(token, myPaddingChar);
          }
      }
  
      private String rightTrim(String token, char myPaddingChar) {
          StringBuilder sb = new StringBuilder(token);
  
          while (sb.length() > 0 && myPaddingChar == sb.charAt(sb.length() - 1)) {
              sb.deleteCharAt(sb.length() - 1);
          }
  
          return sb.toString();
      }
  
      private String leftTrim(String token, char myPaddingChar) {
          StringBuilder sb = new StringBuilder(token);
  
          while (sb.length() > 0 && myPaddingChar == (sb.charAt(0))) {
              sb.deleteCharAt(0);
          }
  
          return sb.toString();
      }
  
      @Override
      public String unbind(CamelContext camelContext, Map<String, Object> model) throws Exception {
  
          StringBuilder buffer = new StringBuilder(256);
          Map<Integer, List<String>> results = new HashMap<>();
  
          for (Class<?> clazz : models) {
  
              if (model.containsKey(clazz.getName())) {
  
                  Object obj = model.get(clazz.getName());
  
                  if (LOG.isDebugEnabled()) {
                      LOG.debug("Model object: {}, class: {}", obj, obj.getClass().getName());
                  }
  
                  if (obj != null) {
  
                      // Generate Fixed Length table
                      // containing the positions of the fields
                      generateFixedLengthPositionMap(clazz, obj, results);
  
                  }
              }
          }
  
          // Convert Map<Integer, List> into List<List>
          Map<Integer, List<String>> sortValues = new TreeMap<>(results);
          for (Entry<Integer, List<String>> entry : sortValues.entrySet()) {
  
              // Get list of values
              List<String> val = entry.getValue();
              String value = val.get(0);
  
              buffer.append(value);
          }
  
          return buffer.toString();
      }
  
      /**
       *
       * Generate a table containing the data formatted and sorted with their position/offset The result is placed in the
       * Map<Integer, List> results
       */
      private void generateFixedLengthPositionMap(Class<?> clazz, Object obj, Map<Integer, List<String>> results)
              throws Exception {
  
          String result = "";
  
          for (Field field : clazz.getDeclaredFields()) {
  
              field.setAccessible(true);
  
              DataField datafield = field.getAnnotation(DataField.class);
  
              if (datafield != null) {
  
                  if (obj != null) {
  
                      // Create format
                      FormattingOptions formattingOptions = ConverterUtils.convert(datafield,
                              field.getType(),
                              field.getAnnotation(BindyConverter.class),
                              getLocale());
                      Format<?> format = formatFactory.getFormat(formattingOptions);
  
                      // Get field value
                      Object value = field.get(obj);
  
                      // If the field value is empty, populate it with the default value
                      if (org.apache.camel.util.ObjectHelper.isNotEmpty(datafield.defaultValue())
                              && org.apache.camel.util.ObjectHelper.isEmpty(value)) {
                          value = datafield.defaultValue();
                      }
  
                      result = formatString(format, value);
  
                      // trim if enabled
                      if (datafield.trim()) {
                          result = result.trim();
                      }
  
                      int fieldLength = datafield.length();
  
                      if (fieldLength == 0 && datafield.lengthPos() > 0) {
                          List<String> resultVals = results.get(datafield.lengthPos());
                          fieldLength = Integer.parseInt(resultVals.get(0));
                      }
  
                      if (fieldLength <= 0 && datafield.delimiter().isEmpty() && datafield.lengthPos() == 0) {
                          throw new IllegalArgumentException(
                                  "Either a delimiter value or length for the field: "
                                                             + field.getName() + " is mandatory.");
                      }
  
                      if (!datafield.delimiter().isEmpty()) {
                          result = result + datafield.delimiter();
                      } else {
                          // Get length of the field, alignment (LEFT or RIGHT), pad
                          String align = datafield.align();
                          char padCharField = datafield.paddingChar();
                          char padChar;
  
                          StringBuilder temp = new StringBuilder();
  
                          // Check if we must pad
                          if (result.length() < fieldLength) {
  
                              // No padding defined for the field
                              if (padCharField == 0) {
                                  // We use the padding defined for the Record
                                  padChar = paddingChar;
                              } else {
                                  padChar = padCharField;
                              }
  
                              if (align.contains("R")) {
                                  temp.append(generatePaddingChars(padChar, fieldLength, result.length()));
                                  temp.append(result);
                              } else if (align.contains("L")) {
                                  temp.append(result);
                                  temp.append(generatePaddingChars(padChar, fieldLength, result.length()));
                              } else if (align.contains("B")) {
                                  temp.append(generatePaddingChars(padChar, fieldLength, result.length()));
                                  temp.append(result);
                              } else {
                                  throw new IllegalArgumentException(
                                          "Alignment for the field: " + field.getName()
                                                                     + " must be equal to R for RIGHT or L for LEFT or B for trimming both ends");
                              }
  
                              result = temp.toString();
                          } else if (result.length() > fieldLength) {
                              // we are bigger than allowed
  
                              // is clipped enabled? if so clip the field
                              if (datafield.clip()) {
                                  result = result.substring(0, fieldLength);
                              } else {
                                  throw new IllegalArgumentException(
                                          "Length for the " + field.getName()
                                                                     + " must not be larger than allowed, was: " + result.length()
                                                                     + ", allowed: " + fieldLength);
                              }
                          }
                      }
  
                      if (LOG.isDebugEnabled()) {
                          LOG.debug("Value to be formatted: {}, position: {}, and its formatted value: {}", value,
                                  datafield.pos(), result);
                      }
  
                  } else {
                      result = "";
                  }
  
                  Integer key;
                  key = datafield.pos();
  
                  if (!results.containsKey(key)) {
                      List<String> list = new LinkedList<>();
                      list.add(result);
                      results.put(key, list);
                  } else {
                      List<String> list = results.get(key);
                      list.add(result);
                  }
  
              }
  
          }
  
      }
  
      private String generatePaddingChars(char pad, int lengthField, int lengthString) {
          int size = lengthField - lengthString;
  
          return Character.toString(pad).repeat(size);
      }
  
      /**
       * Get parameters defined in @FixedLengthRecord annotation
       */
      private void initFixedLengthRecordParameters() {
  
          for (Class<?> cl : models) {
  
              // Get annotation @FixedLengthRecord from the class
              FixedLengthRecord fixedLengthRecord = cl.getAnnotation(FixedLengthRecord.class);
  
              if (fixedLengthRecord != null) {
                  LOG.debug("Fixed length record: {}", fixedLengthRecord);
  
                  // Get carriage return parameter
                  crlf = fixedLengthRecord.crlf();
                  LOG.debug("Carriage return defined for the CSV: {}", crlf);
  
                  eol = fixedLengthRecord.eol();
                  LOG.debug("EOL(end-of-line) defined for the CSV: {}", eol);
  
                  // Get header parameter
                  header = fixedLengthRecord.header();
                  LOG.debug("Header: {}", header);
                  hasHeader = header != void.class;
                  LOG.debug("Has Header: {}", hasHeader);
  
                  // Get skipHeader parameter
                  skipHeader = fixedLengthRecord.skipHeader();
                  LOG.debug("Skip Header: {}", skipHeader);
  
                  // Get footer parameter
                  footer = fixedLengthRecord.footer();
                  LOG.debug("Footer: {}", footer);
                  hasFooter = fixedLengthRecord.footer() != void.class;
                  LOG.debug("Has Footer: {}", hasFooter);
  
                  // Get skipFooter parameter
                  skipFooter = fixedLengthRecord.skipFooter();
                  LOG.debug("Skip Footer: {}", skipFooter);
  
                  // Get isHeader parameter
                  isHeader = hasHeader && cl.equals(header);
                  LOG.debug("Is Header: {}", isHeader);
  
                  // Get isFooter parameter
                  isFooter = hasFooter && cl.equals(footer);
                  LOG.debug("Is Footer: {}", isFooter);
  
                  // Get padding character
                  paddingChar = fixedLengthRecord.paddingChar();
                  LOG.debug("Padding char: {}", paddingChar);
  
                  // Get length of the record
                  recordLength = fixedLengthRecord.length();
                  LOG.debug("Length of the record: {}", recordLength);
  
                  // Get flag for ignore trailing characters
                  ignoreTrailingChars = fixedLengthRecord.ignoreTrailingChars();
                  LOG.debug("Ignore trailing chars: {}", ignoreTrailingChars);
  
                  ignoreMissingChars = fixedLengthRecord.ignoreMissingChars();
                  LOG.debug("Enable ignore missing chars: {}", ignoreMissingChars);
  
                  countGrapheme = fixedLengthRecord.countGrapheme();
                  LOG.debug("Enable grapheme counting instead of codepoints: {}", countGrapheme);
              }
          }
  
          if (hasHeader && isHeader) {
              throw new java.lang.IllegalArgumentException(
                      "Record can not be configured with both 'isHeader=true' and 'hasHeader=true'");
          }
  
          if (hasFooter && isFooter) {
              throw new java.lang.IllegalArgumentException(
                      "Record can not be configured with both 'isFooter=true' and 'hasFooter=true'");
          }
  
          if ((isHeader || isFooter) && (skipHeader || skipFooter)) {
              throw new java.lang.IllegalArgumentException(
                      "skipHeader and/or skipFooter can not be configured on a record where 'isHeader=true' or 'isFooter=true'");
          }
  
      }
  
      /**
       * Gets the type of the header record.
       *
       * @return The type of the header record if any, otherwise <code>void.class</code>.
       */
      public Class<?> header() {
          return header;
      }
  
      /**
       * Flag indicating if we have a header
       */
      public boolean hasHeader() {
          return hasHeader;
      }
  
      /**
       * Gets the type of the footer record.
       *
       * @return The type of the footer record if any, otherwise <code>void.class</code>.
       */
      public Class<?> footer() {
          return footer;
      }
  
      /**
       * Flag indicating if we have a footer
       */
      public boolean hasFooter() {
          return hasFooter;
      }
  
      /**
       * Flag indicating whether to skip the header parsing
       */
      public boolean skipHeader() {
          return skipHeader;
      }
  
      /**
       * Flag indicating whether to skip the footer processing
       */
      public boolean skipFooter() {
          return skipFooter;
      }
  
      /**
       * Flag indicating whether this factory is for a header
       */
      public boolean isHeader() {
          return isHeader;
      }
  
      /**
       * Flag indicating whether this factory is for a footer
       */
      public boolean isFooter() {
          return isFooter;
      }
  
      /**
       * Padding char used to fill the field
       */
      public char paddingchar() {
          return paddingChar;
      }
  
      /**
       * Expected fixed length of the record
       */
      public int recordLength() {
          return recordLength;
      }
  
      /**
       * Flag indicating whether trailing characters beyond the last declared field may be ignored
       */
      public boolean isIgnoreTrailingChars() {
          return this.ignoreTrailingChars;
      }
  
      /**
       * Flag indicating whether too short lines are ignored
       */
      public boolean isIgnoreMissingChars() {
          return ignoreMissingChars;
      }
  
      /**
       * Flag indicating whether graphemes or codepoints are counted.
       */
      public boolean isCountGrapheme() {
          return countGrapheme;
      }
  
  }
  