/********************************************************************
 * Copyright © 2004 VHA. All rights reserved
 ********************************************************************/
package gov.va.med.fw.hl7;

// Java classes
import java.io.BufferedReader;
import java.io.StringReader;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.StringTokenizer;

// Apache logging classes
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

// Framework Classes
import gov.va.med.fw.hl7.constants.SegmentConstants;
import gov.va.med.fw.util.StringUtils;
import gov.va.med.fw.util.SystemUtils;

/**
 * A parser class that is used by a Message and a Segment class to parse HL7
 * data either in a list of segments, arrays, or a string of raw data.
 * 
 * @author Vu Le
 * @version 1.0
 */
public class MessageParser
{
    /** The indentation characters to indent a formatted message. */
    public static final String INDENT_CHARS = "    ";

    /** The string used as a line separator on HL7 messages. */
    public static final String MESSAGE_LINE_SEPARATOR = "\r";

   /**
    * An logger instance of log debug information in this class
    */
   private static Log logger = LogFactory.getLog(MessageParser.class);

   /**
    * Parse a list of elements of a segment into a string with the specific
    * segment name and delimiter. The parsed string starts with a segment name
    * followed by the field delimiter and elements delimited by the field
    * delimiter.
    * 
    * @param name A segment name
    * @param delimiter An element delimiter
    * @param list A list of elements
    * @return A parsed string containing name and elements delimited by a delimiter
    * @throws IllegalArgumentException Thrown if parameters are missing
    */
   public static String parseElements(String name, String delimiter, List list) {

      if (name == null || delimiter == null || list == null )
      {
         throw new IllegalArgumentException("Invalid parameter to parse elements.");
      }

      StringBuffer message = new StringBuffer(name);
      for (int i = 0; i < list.size(); i++) {
         Object element = list.get(i);
         if (element != null && element.equals("")) {
            element = "\"\"";
         }
         // Only for a BHS and MHS segment, the first element in the list is a field separator
         // which is also a delimiter so we don't need to add it again. - VL
         /*         
         if( (name.equals( SegmentConstants.BHS ) || name.equals( SegmentConstants.MSH )) && i != 0 ) {
	         message.append(delimiter);
	         if (element != null) {
	            message.append(element);
	         }
         }
         */
         if(!((name.equals( SegmentConstants.BHS ) || name.equals( SegmentConstants.MSH )) && i == 0)) {
             message.append(delimiter);
             if (element != null) {
                message.append(element);
             }
         }
      }
      return message.toString();
   }

   /**
    * Parse a string containing raw segment data into a list of segment's
    * elements.
    * 
    * @param name A segment name
    * @param delimiter A segment's element delimiter
    * @param data A segment data string
    * @return A list of elements containing in a segment data string
    * @throws IllegalArgumentException thrown if parameters are missing
    */
   public static List parseElements(String name, String delimiter, String data) {

      if (name == null || delimiter == null || data == null || data.length() == 0) {
         throw new IllegalArgumentException("Invalid parameter to parse elements.");
      }
      if (logger.isDebugEnabled()) {
         logger.debug("Building:" + name);
      }

      List elements = null;

      // A segment data doesn't contain a header
      StringTokenizer tokens = new StringTokenizer(data, delimiter, true);

      if (tokens.hasMoreTokens()) {
         elements = new ArrayList();

         // The first token is a field separator
         String current = tokens.nextToken();

         if (name.equals( SegmentConstants.BHS ) || name.equals( SegmentConstants.MSH )) {
            // Add a field separator to a element list
            if (logger.isDebugEnabled()) {
               logger.debug("Adding:" + delimiter);
            }
            elements.add(delimiter);
         }

         String prev = current;
         while (tokens.hasMoreTokens()) {
            String token = tokens.nextToken();

            if (token.equals(prev) && token.equals(delimiter)) {
               if (logger.isDebugEnabled()) {
                  logger.debug("Adding:" + null);
               }
               elements.add(null);
            }
            else {
               if (!token.equals(delimiter)) {
                  if (logger.isDebugEnabled()) {
                     logger.debug("Adding:" + (token.equals("\"\"") ? "" : token));
                  }
                  elements.add(token.equals("\"\"") ? "" : token);
               }
            }
            prev = token;
         }
      }
      return elements;
   }

   /** Parse elements delimited by a field delimiter into a string array
    * 
    * @param data A raw string containing elements delimited by a delimiter
    * @param delimiter A delimiter used to delimit elements in a string
    * @return A string array of elements
    */
   public static String[] parseElement(String data, String delimiter) {

      String[] values = null;
      if (data != null && data.indexOf(delimiter) != -1) {
         values = data.split(delimiter);
      }
      return values;
   }

   /** Parse a list of segments into a hl7 message string
    * 
    * @param list A list of hl7 segments
    * @return A HL7 message string 
    * @throws IllegalArgumentException Thrown if a list is missing or it doesn't contain segment objects.
    */
   public static String parseSegments(List list)
       {
       //Segments with long strings should not be wrapped into multiple lines
       return parseSegments(list, false);
   }

   /** Parse a list of segments into a hl7 message string
    * 
    * @param list A list of hl7 segments
    * @param wrapSegmentData - Flag to indicate if a segment data needs
    * 		 to be wrapped into multiple lines if it exceeds a certain limit(244 chars) 
    * 
    * @return A HL7 message string 
    * @throws IllegalArgumentException Thrown if a list is missing or it doesn't contain segment objects.
    */
   public static String parseSegments(List list, boolean wrapSegmentData)
   {
      StringBuffer message = new StringBuffer();

      if (list == null || list.isEmpty()) {
         throw new IllegalArgumentException("Invalid list of message segments.");
      }

      for (Iterator i = list.iterator(); i.hasNext();) {
         Object obj = i.next();
         if (!(obj instanceof Segment)) {
            throw new IllegalArgumentException("Invalid list of message segments");
         }

         // a line is considered to be terminated by any of the following characters
         // '\n', '\r', or '\r\n'.  MLLP protocol used by HL7 messages required a line
         // to be terminated by a '\r'  

         StringBuffer segmentData = new StringBuffer().append(
                    ((Segment) obj).getName()).append(
                    ((Segment) obj).getElementData());

         //Wrap segments with long lines as Vista cannot handle strings > 244 chars 
         if(wrapSegmentData)
         {
             segmentData = getWrappedSegmentData(segmentData);
         }

         message.append(segmentData.append(MESSAGE_LINE_SEPARATOR));

      }
      return message.toString();
   }

   /**
    * Wraps the segment data to a next line if no of characters > 244
    * 
    * @param segmentData
    */
    private static StringBuffer getWrappedSegmentData(StringBuffer segmentData)
    {
        //If segment size > 244 characters, wrap to next line
        if (segmentData.length() > 244)
        {
            //The # of lines to be used for the segment
            int numberOfLines = segmentData.length() / 244;
            for (int j = 1; j <= numberOfLines; j++)
            {
                //Insert a carriage return at the right places to split
                //the data into multiple lines, each line ending with a \r
                segmentData.insert(243 * j, MESSAGE_LINE_SEPARATOR);
            }
        }

        return segmentData;
    }

    /**
     * Parses a raw string data into a list of segments using the specific field
     * delimmiter and encoding characters.
     * 
     * @param delimiter A field delimiter
     * @param encoders An array of encoding characters
     * @param data A raw data of HL7 message
     * @return A list of HL7 message's segments.
     * @throws InvalidMessageException
     *             thrown if failed to parse segments
     * @throws IllegalArgumentException
     *             thrown if parameters are missing
     */
   public static List parseSegments(String delimiter, String[] encoders,
            String data) throws InvalidMessageException
    {
        List segments = null;

        if (data == null || data.length() == 0 || delimiter == null || encoders == null)
        {
            throw new IllegalArgumentException("Invalid parameters to parse a list of segments");
        }

        try
        {
            BufferedReader reader = new BufferedReader(new StringReader(data));
            String currentLine = null;

            segments = new ArrayList();
            SegmentFactory factory = SegmentFactory.getInstance();

            //With R3/R4, the segments can come wrapped in multiple lines. The logic below handles that.
            //Check if the line starts with one of the predefined segments(e.g MSH, PID, etc) 
            //IF it is, then set the isSegmentStarted flag to true and create a new working segment.
            //The working segment will keep on appending the wrapped lines if they show up in the next line.
            //If the the next line starts with a segment and isSegmentStarted=true, that means the previous
            //and the current lines are two consecutive segments. So create a segment for the previous one
            //and set the workinng segment as current line
            //If the next line is a wrapped line i.e does not start with one of the segments, then add the
            //wrapped line to the working segment.
            List segmentsList = Arrays.asList(SegmentConstants.HL7_SEGMENTS_LIST);

            boolean isSegmentStarted = false;
            String workingSegment = null;

            while ((currentLine = reader.readLine()) != null)
            {
                if (logger.isDebugEnabled())
                {
                    logger.debug("Segment: " + currentLine);
                }

                //Check if the line starts with one of the predefined segments(e.g MSH, PID, etc)
                //and if the segment has already started in the previous line

                if (StringUtils.trim(currentLine).length() >= 3 && segmentsList.contains(currentLine.subSequence(0, 3))
                        && (!isSegmentStarted))
                {
                    isSegmentStarted = true;
                    workingSegment = currentLine;

                } else if (StringUtils.trim(currentLine).length() >= 3 && segmentsList.contains(currentLine.subSequence(0, 3))
                        && isSegmentStarted)
                {
                    //Previous and the current lines are two consecutive segments. Call segment factory to create
                    // the previous one
                    segments.add(factory.createSegment(encoders, delimiter,
                            workingSegment));
                    isSegmentStarted = true;
                    workingSegment = currentLine;

                } else if (StringUtils.trim(currentLine).length() < 3 || !segmentsList
                        .contains(currentLine.subSequence(0, 3))
                        && isSegmentStarted)
                {
                    //Segment already started in previous lines and this is a wrapped line
                    workingSegment += currentLine;
                }
            }
            //Now build the last working segment
            segments.add(factory.createSegment(encoders, delimiter,
                    workingSegment));

        } catch (Exception e)
        {
            throw new InvalidMessageException("Invalid message data to parse:  " + data + " "
                    + e.getMessage(), e);
        }

        return segments;
    }



   /** Returns a field delimiter 
    * @param data A message data string
    * @return A field delimiter
    * @throws InvalidMessageException thrown if a message doesn't start with MSH and BHS
    * @throws IllegalArgumentException thrown if parameters are missing
    */
   public static String getDelimiterCharacter(String data)
         throws InvalidMessageException {

      if (data == null) {
         throw new IllegalArgumentException("Missing a message data string");
      }

      String delimiter = null;
      // A segment name is always 3 characters in length
      if (data.startsWith( SegmentConstants.BHS ) || data.startsWith( SegmentConstants.MSH )) {
         delimiter = String.valueOf(data.substring(3).charAt(0));
      }

      if (delimiter == null) {
         throw new InvalidMessageException( "Invalid message format." );
      }
      return delimiter;
   }

   /** Returns an array of encoding characters
    * @param data A raw data string of HL7Message
    * @throws InvalidMessageException thrown if a message doesn't start with MSH and BHS
    * @throws IllegalArgumentException thrown if parameters are missing
    */
   public static String[] getEncodingCharacters(String data)
         throws InvalidMessageException {

      if (data == null) {
         throw new IllegalArgumentException("Missing a message data string");
      }

      String delimiter = getDelimiterCharacter(data);

      // A valid message should always start out with either MSH or BHS
      // segment followed by delimiter and
      // 4 encoding characters:
      // COMPONENT = ~ (TILDE)
      // REPEAT = | (BAR)
      // ESCAPE = \ (BACK SLASH)
      // SUBCOMPONENT = & (AMPERSAND)
      //
      // Sample segment fragment: MSH^~\|& or BHS^~\|&

      String[] encoders = null;
      try {
         int delimiterPos = data.indexOf(delimiter);
         encoders = new String[] { String.valueOf(data.charAt(++delimiterPos)),
               String.valueOf(data.charAt(++delimiterPos)),
               String.valueOf(data.charAt(++delimiterPos)),
               String.valueOf(data.charAt(++delimiterPos)) };
      }
      catch (Exception e) {
         throw new InvalidMessageException( "Failed to parse encoding characters");
      }
      return encoders;
   }

    /**
     * Returns a formatted version of the raw message.  The formatted version will simply add carriage returns
     * at the closest delimiter or encoding character so a line doesn't exceed the specified maxCharsPerLine.
     * If a line contains no delimeters or encoding characters, a carriage return will be placed at the
     * max character position to ensure no line exceeds the maxCharsPerLine.
     *
     * @param data The raw message
     * @param maxCharsPerLine the maximum number of characters that are allowed per line
     * @return the formatted raw message
     * @throws InvalidMessageException if the passed in message is invalid.
     */
    public static String getFormattedRawMessage(String data, int maxCharsPerLine) throws InvalidMessageException
    {
        // Handle the empty string by returning the empty string
        if ((StringUtils.isEmpty(data)) || (maxCharsPerLine <= 0))
        {
            return "";
        }

        // Get the delimiter and encoding characters and store them as a list of separators.
        String delimiter = getDelimiterCharacter(data);
        String[] encodingChars = getEncodingCharacters(data);
        List separators = new ArrayList(Arrays.asList(encodingChars));
        separators.add(delimiter);

        // Get the list of segments and ensure they are valid
        List segments = parseSegments(delimiter, encodingChars, data);
        if (segments == null || segments.isEmpty())
        {
           throw new InvalidMessageException("Invalid list of message segments.");
        }

        // Create a message buffer to hold the formatted message
        StringBuffer messageBuffer = new StringBuffer();

        // Iterate and process each segment
        for (Iterator i = segments.iterator(); i.hasNext();)
        {
            // Get a segment
            Object segmentObject = i.next();
            if (!(segmentObject instanceof Segment))
            {
                throw new InvalidMessageException("Invalid list of message segments");
            }
            Segment segment = (Segment)segmentObject;

            // Convert the segment into a string buffer
            StringBuffer segmentBuffer = new StringBuffer().append(segment.getName()).append(segment.getElementData());

            // Keep looping through the segment and add carriage returns appropriately
            StringBuffer dataLeftBuffer = new StringBuffer(segmentBuffer.toString());
            StringBuffer formattedSegmentBuffer = new StringBuffer();
            for (int index=0; index >= 0;)
            {
                // Get the place to insert the next return.
                index = getIndexToInsertReturn(dataLeftBuffer, separators, maxCharsPerLine);
                if (index == -1)
                {
                    // -1 means that the rest of the buffer should be added.
                    formattedSegmentBuffer.append(dataLeftBuffer);
                }
                else
                {
                    // Append this part at the index specified into the formatted buffer
                    formattedSegmentBuffer.append(dataLeftBuffer.substring(0, index+1));

                    // Delete the part we just added from what's left to process
                    dataLeftBuffer.delete(0, index+1);

                    // If we still have more to process, add the return.  Otherwise, set the index to -1
                    // to mark the end of processing this segment.
                    if (dataLeftBuffer.length() > 0)
                    {
                        formattedSegmentBuffer.append(SystemUtils.LINE_SEPARATOR).append(MessageParser.INDENT_CHARS);
                    }
                    else
                    {
                        index = -1;
                    }
                }
            }

            // Add this segment to the overall formatted message buffer
            messageBuffer.append(formattedSegmentBuffer);

            // If there are more rows, add a line separator
            if (i.hasNext())
            {
                messageBuffer.append(SystemUtils.LINE_SEPARATOR);
            }
        }

        // Return the formatted buffer
        return messageBuffer.toString();
     }

    /**
     * Gets the positional index in the buffer where a carriage return should be inserted.
     * @param buffer the buffer
     * @param separators the list of separators
     * @param maxCharsPerLine the maximum number of characters per line
     * @return the index or -1 if a separator was not found or the buffer is less than the maxCharsPerLine
     */
    private static int getIndexToInsertReturn(StringBuffer buffer, List separators, int maxCharsPerLine)
    {
        if (buffer.length() <= maxCharsPerLine)
        {
            return -1;
        }
        else
        {
            int index = maxCharsPerLine - 1;
            while (index >= 0)
            {
                String currentChar = String.valueOf(buffer.charAt(index));
                if (separators.contains(currentChar))
                {
                    return index;
                }
                else
                {
                    index--;
                }
            }

            // No separators were found so return the max characters per line position.
            return maxCharsPerLine - 1;
        }
    }


   /** Gets a transmission date from a string in the specific format.
    * 
    * @param message message to look for a transmission date
    * @return A transmission date
    * @throws ParseException if failed to parse a date
    * @throws InvalidMessageException if a message doesn't have a header segment
    */
   public static Date getTransmissionDate( Message message ) throws ParseException, InvalidMessageException {

      if( message == null ) {
         throw new IllegalArgumentException( "Missing a message" );
      }

      Segment header = message.getSegment( SegmentConstants.MSH );

      if( header == null ) {
         throw new InvalidMessageException( "Invalid message. A message must have a header segment");
      }

      List elements = header.getElements();

      Object date = null;
      if( elements != null && elements.size() >=7 ) {
         date = elements.get( 6 );
      }
      return (date == null) ? null : (new SimpleDateFormat("yyyyMMddHHmmssZ")).parse( date.toString() );
   }
}