/* $Id$ */

/**
* 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.manifoldcf.agents.transformation.forcedmetadata;

import org.apache.manifoldcf.core.interfaces.*;
import org.apache.manifoldcf.agents.interfaces.*;

import java.io.*;
import java.util.*;

/** This connector works as a transformation connector, and merely adds specified metadata items.
*
*/
public class ForcedMetadataConnector extends org.apache.manifoldcf.agents.transformation.BaseTransformationConnector
{
  public static final String _rcsid = "@(#)$Id$";

  // Nodes and attributes representing parameters and values.
  // There will be node for every parameter/value pair.
  
  public static final String NODE_EXPRESSION = "expression";
  public static final String NODE_PAIR = "pair";
  public static final String ATTRIBUTE_PARAMETER = "parameter";
  public static final String NODE_FIELDMAP = "fieldmap";
  public static final String NODE_KEEPMETADATA = "keepAllMetadata";
  public static final String NODE_FILTEREMPTY = "filterEmpty";
  public static final String ATTRIBUTE_SOURCE = "source";
  public static final String ATTRIBUTE_TARGET = "target";
  public static final String ATTRIBUTE_VALUE = "value";

  // Templates
  
  private static final String VIEW_SPEC = "viewSpecification.html";
  private static final String EDIT_SPEC_HEADER = "editSpecification.js";
  private static final String EDIT_SPEC_EXPRESSIONS = "editSpecification_Expressions.html";

  /** Get a pipeline version string, given a pipeline specification object.  The version string is used to
  * uniquely describe the pertinent details of the specification and the configuration, to allow the Connector 
  * Framework to determine whether a document will need to be processed again.
  * Note that the contents of any document cannot be considered by this method; only configuration and specification information
  * can be considered.
  *
  * This method presumes that the underlying connector object has been configured.
  *@param spec is the current pipeline specification object for this connection for the job that is doing the crawling.
  *@return a string, of unlimited length, which uniquely describes configuration and specification in such a way that
  * if two such strings are equal, nothing that affects how or whether the document is indexed will be different.
  */
  @Override
  public VersionContext getPipelineDescription(Specification spec)
    throws ManifoldCFException, ServiceInterruption
  {
    SpecPacker sp = new SpecPacker(spec);
    return new VersionContext(sp.toPackedString(),params,spec);
  }

  /** Add (or replace) a document in the output data store using the connector.
  * This method presumes that the connector object has been configured, and it is thus able to communicate with the output data store should that be
  * necessary.
  * The OutputSpecification is *not* provided to this method, because the goal is consistency, and if output is done it must be consistent with the
  * output description, since that was what was partly used to determine if output should be taking place.  So it may be necessary for this method to decode
  * an output description string in order to determine what should be done.
  *@param documentURI is the URI of the document.  The URI is presumed to be the unique identifier which the output data store will use to process
  * and serve the document.  This URI is constructed by the repository connector which fetches the document, and is thus universal across all output connectors.
  *@param pipelineDescription is the description string that was constructed for this document by the getOutputDescription() method.
  *@param document is the document data to be processed (handed to the output data store).
  *@param authorityNameString is the name of the authority responsible for authorizing any access tokens passed in with the repository document.  May be null.
  *@param activities is the handle to an object that the implementer of a pipeline connector may use to perform operations, such as logging processing activity,
  * or sending a modified document to the next stage in the pipeline.
  *@return the document status (accepted or permanently rejected).
  *@throws IOException only if there's a stream error reading the document data.
  */
  @Override
  public int addOrReplaceDocumentWithException(String documentURI, VersionContext pipelineDescription, RepositoryDocument document, String authorityNameString, IOutputAddActivity activities)
    throws ManifoldCFException, ServiceInterruption, IOException
  {
    // Unpack the forced metadata
    SpecPacker sp = new SpecPacker(pipelineDescription.getSpecification());
    
    // Create a structure that will allow us access to fields without sharing Reader objects
    FieldDataFactory fdf = new FieldDataFactory(document);
    try {
      // We have to create a copy of the Repository Document, since we might be rearranging things
      RepositoryDocument docCopy = document.duplicate();
      // We must explicitly copy all fields, since we can't share references to Reader objects and
      // expect anything to work right
      docCopy.clearFields();
      
      // Clear fields, unless we're supposed to keep what we don't specify
      if (sp.filterEmpty()) {
        if (sp.keepAllMetadata()) {
          // Loop through fields and copy them, filtering empties
          Iterator<String> fields = document.getFields();
          while (fields.hasNext())
          {
            String field = fields.next();
            moveData(docCopy,field,fdf,field,true);
          }
        }
      } else if (sp.keepAllMetadata()) {
        // Copy ALL current fields from old document, but go through FieldDataFactory
        Iterator<String> fields = document.getFields();
        while (fields.hasNext())
        {
          String field = fields.next();
          moveData(docCopy,field,fdf,field,false);
        }
      }
      
      // Iterate through the expressions
      Iterator<String> expressionKeys = sp.getExpressionKeys();
      while (expressionKeys.hasNext()) {
        String expressionKey = expressionKeys.next();
        // Get the set of expressions for the key
        Set<String> values = sp.getExpressionValues(expressionKey);
        IDataSource[] dataSources = new IDataSource[values.size()];
        int k = 0;
        for (String expression : values) {
          dataSources[k++] = processExpression(expression, fdf);
        }
        int totalSize = 0;
        for (IDataSource dataSource : dataSources) {
          if (dataSource != null)
            totalSize += dataSource.getSize();
        }
        if (totalSize == 0) {
          docCopy.removeField(expressionKey);
        } else {
          // Each IDataSource will contribute zero or more results to the final array.  But here's the tricky part:
          // the results all must be of the same type.  If there are any differences, then we have to bash them all to
          // strings first.
          Object[] allValues;
          k = 0;
          if (allDates(dataSources)) {
            allValues = new Date[totalSize];
            for (IDataSource dataSource : dataSources) {
              if (dataSource != null) {
                for (Object o : dataSource.getRawForm()) {
                  allValues[k++] = o;
                }
              }
            }
            docCopy.addField(expressionKey,(Date[])conditionallyRemoveNulls(allValues,sp.filterEmpty()));
          } else if (allReaders(dataSources)) {
            if (sp.filterEmpty())
              allValues = new String[totalSize];
            else
              allValues = new Reader[totalSize];
            for (IDataSource dataSource : dataSources) {
              if (dataSource != null) {
                Object[] sources = sp.filterEmpty()?dataSource.getStringForm():dataSource.getRawForm();
                for (Object o : sources) {
                  allValues[k++] = o;
                }
              }
            }
            if (sp.filterEmpty())
              docCopy.addField(expressionKey,removeEmpties((String[])allValues));
            else
              docCopy.addField(expressionKey,(Reader[])allValues);
          } else {
            allValues = new String[totalSize];
            // Convert to strings throughout
            for (IDataSource dataSource : dataSources) {
              if (dataSource != null) {
                for (Object o : dataSource.getStringForm()) {
                  allValues[k++] = o;
                }
              }
            }
            if (sp.filterEmpty())
              docCopy.addField(expressionKey,removeEmpties((String[])allValues));
            else
              docCopy.addField(expressionKey,(String[])allValues);
          }
        }
      }
      
      // Finally, send the modified repository document onward to the next pipeline stage.
      // If we'd done anything to the stream, we'd have needed to create a new RepositoryDocument object and copied the
      // data into it, and closed the new stream after sendDocument() was called.
      return activities.sendDocument(documentURI,docCopy);

    } finally {
      fdf.close();
    }
  }

  protected static boolean allDates(IDataSource[] dataSources)
    throws IOException, ManifoldCFException {
    for (IDataSource ds : dataSources) {
      if (ds != null && !(ds.getRawForm() instanceof Date[]))
        return false;
    }
    return true;
  }

  protected static boolean allReaders(IDataSource[] dataSources)
    throws IOException, ManifoldCFException {
    for (IDataSource ds : dataSources) {
      if (ds != null && !(ds.getRawForm() instanceof Reader[]))
        return false;
    }
    return true;
  }
  
  protected static void moveData(RepositoryDocument docCopy, String target, FieldDataFactory document, String field, boolean filterEmpty)
    throws ManifoldCFException, IOException
  {
    Object[] fieldData = document.getField(field);
    if (fieldData instanceof Date[])
      docCopy.addField(target,(Date[])conditionallyRemoveNulls(fieldData,filterEmpty));
    else if (fieldData instanceof Reader[])
    {
      // To strip out empty fields, we will need to convert readers to strings
      if (filterEmpty)
        docCopy.addField(target,removeEmpties(document.getFieldAsStrings(field)));
      else
        docCopy.addField(target,(Reader[])fieldData);
    }
    else if (fieldData instanceof String[])
    {
      String[] processedFieldData;
      if (filterEmpty)
        processedFieldData = removeEmpties((String[])fieldData);
      else
        processedFieldData = (String[])fieldData;
      docCopy.addField(target,processedFieldData);
    }
  }

  protected static String[] removeEmpties(String[] input)
  {
    int count = 0;
    for (String s : input)
    {
      if (s != null && s.length() > 0)
        count++;
    }
    if (count == input.length)
      return input;
    
    String[] rval = new String[count];
    count = 0;
    for (String s : input)
    {
      if (s != null && s.length() > 0)
        rval[count++] = s;
    }
    return rval;

  }
  
  protected static Object[] conditionallyRemoveNulls(Object[] input, boolean filterEmpty)
  {
    if (!filterEmpty)
      return input;
    int count = 0;
    for (Object o : input)
    {
      if (o != null)
        count++;
    }
    if (count == input.length)
      return input;
    
    Object[] rval = new Object[count];
    count = 0;
    for (Object o : input)
    {
      if (o != null)
        rval[count++] = o;
    }
    return rval;
  }
  
  // UI support methods.
  //
  // These support methods come in two varieties.  The first bunch (inherited from IConnector) is involved in setting up connection configuration information.
  // The second bunch
  // is involved in presenting and editing pipeline specification information for a connection within a job.  The two kinds of methods are accordingly treated differently,
  // in that the first bunch cannot assume that the current connector object is connected, while the second bunch can.  That is why the first bunch
  // receives a thread context argument for all UI methods, while the second bunch does not need one (since it has already been applied via the connect()
  // method, above).

  /** Obtain the name of the form check javascript method to call.
  *@param connectionSequenceNumber is the unique number of this connection within the job.
  *@return the name of the form check javascript method.
  */
  @Override
  public String getFormCheckJavascriptMethodName(int connectionSequenceNumber)
  {
    return "s"+connectionSequenceNumber+"_checkSpecification";
  }

  /** Obtain the name of the form presave check javascript method to call.
  *@param connectionSequenceNumber is the unique number of this connection within the job.
  *@return the name of the form presave check javascript method.
  */
  @Override
  public String getFormPresaveCheckJavascriptMethodName(int connectionSequenceNumber)
  {
    return "s"+connectionSequenceNumber+"_checkSpecificationForSave";
  }

  /** Output the specification header section.
  * This method is called in the head section of a job page which has selected a pipeline connection of the current type.  Its purpose is to add the required tabs
  * to the list, and to output any javascript methods that might be needed by the job editing HTML.
  *@param out is the output to which any HTML should be sent.
  *@param locale is the preferred local of the output.
  *@param os is the current pipeline specification for this connection.
  *@param connectionSequenceNumber is the unique number of this connection within the job.
  *@param tabsArray is an array of tab names.  Add to this array any tab names that are specific to the connector.
  */
  @Override
  public void outputSpecificationHeader(IHTTPOutput out, Locale locale, Specification os,
    int connectionSequenceNumber, List<String> tabsArray)
    throws ManifoldCFException, IOException
  {
    // Output specification header
    
    tabsArray.add(Messages.getString(locale, "ForcedMetadata.Expressions"));

    Map<String, Object> paramMap = new HashMap<String, Object>();
    paramMap.put("SEQNUM",Integer.toString(connectionSequenceNumber));

    Messages.outputResourceWithVelocity(out,locale,EDIT_SPEC_HEADER,paramMap);
  }
  
  /** Output the specification body section.
  * This method is called in the body section of a job page which has selected a pipeline connection of the current type.  Its purpose is to present the required form elements for editing.
  * The coder can presume that the HTML that is output from this configuration will be within appropriate &lt;html&gt;, &lt;body&gt;, and &lt;form&gt; tags.  The name of the
  * form is "editjob".
  *@param out is the output to which any HTML should be sent.
  *@param locale is the preferred local of the output.
  *@param os is the current pipeline specification for this job.
  *@param connectionSequenceNumber is the unique number of this connection within the job.
  *@param actualSequenceNumber is the connection within the job that has currently been selected.
  *@param tabName is the current tab name.
  */
  @Override
  public void outputSpecificationBody(IHTTPOutput out, Locale locale, Specification os,
    int connectionSequenceNumber, int actualSequenceNumber, String tabName)
    throws ManifoldCFException, IOException
  {
    // Output specification body
    Map<String, Object> paramMap = new HashMap<String, Object>();
    paramMap.put("TABNAME", tabName);
    paramMap.put("SEQNUM",Integer.toString(connectionSequenceNumber));
    paramMap.put("SELECTEDNUM",Integer.toString(actualSequenceNumber));

    fillInExpressionsTab(paramMap, os);

    Messages.outputResourceWithVelocity(out,locale,EDIT_SPEC_EXPRESSIONS,paramMap);
  }
  
  /** Process a specification post.
  * This method is called at the start of job's edit or view page, whenever there is a possibility that form data for a connection has been
  * posted.  Its purpose is to gather form information and modify the transformation specification accordingly.
  * The name of the posted form is "editjob".
  *@param variableContext contains the post data, including binary file-upload information.
  *@param locale is the preferred local of the output.
  *@param os is the current pipeline specification for this job.
  *@param connectionSequenceNumber is the unique number of this connection within the job.
  *@return null if all is well, or a string error message if there is an error that should prevent saving of the job (and cause a redirection to an error page).
  */
  @Override
  public String processSpecificationPost(IPostParameters variableContext, Locale locale, Specification os,
    int connectionSequenceNumber)
    throws ManifoldCFException
  {
    // Process specification post
    String seqPrefix = "s"+connectionSequenceNumber+"_";
    String expressionCount = variableContext.getParameter(seqPrefix+"expression_count");
    if (expressionCount != null)
    {
      int count = Integer.parseInt(expressionCount);
      // Delete old spec data, including legacy node types we no longer use
      int i = 0;
      while (i < os.getChildCount())
      {
        SpecificationNode cn = os.getChild(i);
        if (cn.getType().equals(NODE_EXPRESSION) || cn.getType().equals(NODE_PAIR) || cn.getType().equals(NODE_FIELDMAP))
          os.removeChild(i);
        else
          i++;
      }

      // Now, go through form data
      for (int j = 0; j < count; j++)
      {
        String op = variableContext.getParameter(seqPrefix+"expression_"+j+"_op");
        if (op != null && op.equals("Delete"))
          continue;
        String paramName = variableContext.getParameter(seqPrefix+"expression_"+j+"_name");
        String paramRemove = variableContext.getParameter(seqPrefix+"expression_"+j+"_remove");
        String paramValue = variableContext.getParameter(seqPrefix+"expression_"+j+"_value");
        SpecificationNode sn = new SpecificationNode(NODE_EXPRESSION);
        sn.setAttribute(ATTRIBUTE_PARAMETER,paramName);
        if (!(paramRemove != null && paramRemove.equals("true")))
          sn.setAttribute(ATTRIBUTE_VALUE,paramValue);
        os.addChild(os.getChildCount(),sn);
      }
      // Look for add operation
      String addOp = variableContext.getParameter(seqPrefix+"expression_op");
      if (addOp != null && addOp.equals("Add"))
      {
        String paramName = variableContext.getParameter(seqPrefix+"expression_name");
        String paramRemove = variableContext.getParameter(seqPrefix+"expression_remove");
        String paramValue = variableContext.getParameter(seqPrefix+"expression_value");
        SpecificationNode sn = new SpecificationNode(NODE_EXPRESSION);
        sn.setAttribute(ATTRIBUTE_PARAMETER,paramName);
        if (!(paramRemove != null && paramRemove.equals("true")))
          sn.setAttribute(ATTRIBUTE_VALUE,paramValue);
        os.addChild(os.getChildCount(),sn);
      }

    }
    
    String x = variableContext.getParameter(seqPrefix+"keepallmetadata_present");
    if (x != null && x.length() > 0)
    {
      String keepAll = variableContext.getParameter(seqPrefix+"keepallmetadata");
      if (keepAll == null)
        keepAll = "false";
      // About to gather the fieldmapping nodes, so get rid of the old ones.
      int i = 0;
      while (i < os.getChildCount())
      {
        SpecificationNode node = os.getChild(i);
        if (node.getType().equals(NODE_KEEPMETADATA))
          os.removeChild(i);
        else
          i++;
      }

      // Gather the keep all metadata parameter to be the last one
      SpecificationNode node = new SpecificationNode(NODE_KEEPMETADATA);
      node.setAttribute(ATTRIBUTE_VALUE, keepAll);
      // Add the new keepallmetadata config parameter 
      os.addChild(os.getChildCount(), node);
    }

    x = variableContext.getParameter(seqPrefix+"filterempty_present");
    if (x != null && x.length() > 0)
    {
      String filterEmpty = variableContext.getParameter(seqPrefix+"filterempty");
      if (filterEmpty == null)
        filterEmpty = "false";
      // About to gather the fieldmapping nodes, so get rid of the old ones.
      int i = 0;
      while (i < os.getChildCount())
      {
        SpecificationNode node = os.getChild(i);
        if (node.getType().equals(NODE_FILTEREMPTY))
          os.removeChild(i);
        else
          i++;
      }

      // Gather the keep all metadata parameter to be the last one
      SpecificationNode node = new SpecificationNode(NODE_FILTEREMPTY);
      node.setAttribute(ATTRIBUTE_VALUE, filterEmpty);
      // Add the new keepallmetadata config parameter 
      os.addChild(os.getChildCount(), node);
    }

    return null;
  }
  
  /** View specification.
  * This method is called in the body section of a job's view page.  Its purpose is to present the pipeline specification information to the user.
  * The coder can presume that the HTML that is output from this configuration will be within appropriate &lt;html&gt; and &lt;body&gt;tags.
  *@param out is the output to which any HTML should be sent.
  *@param locale is the preferred local of the output.
  *@param connectionSequenceNumber is the unique number of this connection within the job.
  *@param os is the current pipeline specification for this job.
  */
  @Override
  public void viewSpecification(IHTTPOutput out, Locale locale, Specification os,
    int connectionSequenceNumber)
    throws ManifoldCFException, IOException
  {
    // View specification
    Map<String, Object> paramMap = new HashMap<String, Object>();
    paramMap.put("SEQNUM",Integer.toString(connectionSequenceNumber));
    
    // Fill in the map with data from all tabs
    fillInExpressionsTab(paramMap, os);

    Messages.outputResourceWithVelocity(out,locale,VIEW_SPEC,paramMap);
  }

  protected static void fillInExpressionsTab(Map<String,Object> paramMap, Specification os)
  {
    final Map<String,Set<String>> expressions = new HashMap<String,Set<String>>();
    final Map<String,Set<String>> expressionAdditions = new HashMap<String,Set<String>>();
    final Map<String,Set<String>> additions = new HashMap<String,Set<String>>();
    
    String keepAllMetadataValue = "true";
    String filterEmptyValue = "true";
    for (int i = 0; i < os.getChildCount(); i++)
    {
      SpecificationNode sn = os.getChild(i);
      if (sn.getType().equals(NODE_FIELDMAP)) {
        String source = sn.getAttributeValue(ATTRIBUTE_SOURCE);
        String target = sn.getAttributeValue(ATTRIBUTE_TARGET);
        String targetDisplay;
        
        expressions.put(source,new HashSet<String>());
        if (target != null)
        {
          Set<String> sources = new HashSet<String>();
          sources.add("${"+source+"}");
          expressions.put(target,sources);
        }
      }
      else if (sn.getType().equals(NODE_PAIR))
      {
        String parameter = sn.getAttributeValue(ATTRIBUTE_PARAMETER);
        String value = sn.getAttributeValue(ATTRIBUTE_VALUE);
        // Since the same target is completely superceded by a NODE_PAIR, but NODE_PAIRs
        // are cumulative, I have to build these completely and then post-process them.
        Set<String> addition = additions.get(parameter);
        if (addition == null)
        {
          addition = new HashSet<String>();
          additions.put(parameter,addition);
        }
        addition.add(nonExpressionEscape(value));
      }
      else if (sn.getType().equals(NODE_EXPRESSION))
      {
        String parameter = sn.getAttributeValue(ATTRIBUTE_PARAMETER);
        String value = sn.getAttributeValue(ATTRIBUTE_VALUE);
        if (value == null) {
          expressionAdditions.put(parameter,new HashSet<String>());
        } else {
          Set<String> expressionAddition = expressionAdditions.get(parameter);
          if (expressionAddition == null)
          {
            expressionAddition = new HashSet<String>();
            expressionAdditions.put(parameter,expressionAddition);
          }
          expressionAddition.add(value);
        }
      }
      else if (sn.getType().equals(NODE_KEEPMETADATA))
      {
        keepAllMetadataValue = sn.getAttributeValue(ATTRIBUTE_VALUE);
      }
      else if (sn.getType().equals(NODE_FILTEREMPTY))
      {
        filterEmptyValue = sn.getAttributeValue(ATTRIBUTE_VALUE);
      }
    }
    
    // Postprocessing.
    // Override the moves with the additions
    for (String parameter : additions.keySet())
    {
      expressions.put(parameter,additions.get(parameter));
    }

    // Override all with expression additions
    for (String parameter : expressionAdditions.keySet())
    {
      expressions.put(parameter,expressionAdditions.get(parameter));
    }

    // Problem: how to display case where we want a null source??
    // A: Special value
    List<Map<String,String>> pObject = new ArrayList<Map<String,String>>();
    String[] keys = expressions.keySet().toArray(new String[0]);
    java.util.Arrays.sort(keys);
    
    // Now, build map
    for (String key : keys)
    {
      Set<String> values = expressions.get(key);
      if (values.size() == 0) {
        Map<String,String> record = new HashMap<String,String>();
        record.put("parameter",key);
        record.put("value","");
        record.put("isnull","true");
        pObject.add(record);
      } else {
        String[] valueArray = values.toArray(new String[0]);
        java.util.Arrays.sort(valueArray);
        
        for (String value : valueArray)
        {
          Map<String,String> record = new HashMap<String,String>();
          record.put("parameter",key);
          record.put("value",value);
          record.put("isnull","false");
          pObject.add(record);
        }
      }
    }
    
    paramMap.put("EXPRESSIONS",pObject);
    paramMap.put("KEEPALLMETADATA",keepAllMetadataValue);
    paramMap.put("FILTEREMPTY",filterEmptyValue);
  }
  
  /** This is used to upgrade older constant values to new ones, that won't trigger expression eval.
  */
  protected static String nonExpressionEscape(String input) {
    // Not doing any escaping yet
    return input;
  }

  /** This is used to unescape text that's been escaped to prevent substitution of ${} expressions.
  */
  protected static String nonExpressionUnescape(String input) {
    // Not doing any escaping yet
    return input;
  }
  
  
  protected static IDataSource append(IDataSource currentValues, IDataSource data)
    throws IOException, ManifoldCFException {
    // currentValues and data can either be:
    // Date[], String[], or Reader[].
    // We want to preserve the type in as high a form as possible when we compute the combinations.
    if (currentValues == null)
      return data;
    if (currentValues.getSize() == 0)
      return currentValues;
    // Any combination causes conversion to a string, so if we get here, we can read the inputs all
    // as strings safely.
    String[] currentStrings = currentValues.getStringForm();
    String[] dataStrings = data.getStringForm();
    String[] rval = new String[currentStrings.length * dataStrings.length];
    int rvalIndex = 0;
    for (String currentString : currentStrings) {
      for (String dataString : dataStrings) {
        rval[rvalIndex++] = currentString + dataString;
      }
    }
    return new StringSource(rval);
  }
  
  public static IDataSource processExpression(String expression, FieldDataFactory sourceDocument)
    throws IOException, ManifoldCFException {
    int index = 0;
    IDataSource input = null;
    while (true) {
      // If we're at the end, return the input
      if (index == expression.length())
        return input;
      // Look for next field specification
      int field = expression.indexOf("${",index);
      if (field == -1)
        return append(input, new StringSource(nonExpressionUnescape(expression.substring(index))));
      if (field > 0)
        input = append(input, new StringSource(nonExpressionUnescape(expression.substring(index,field))));
      // Parse the field name, and regular expression (if any)
      StringBuilder fieldNameBuffer = new StringBuilder();
      StringBuilder regExpBuffer = new StringBuilder();
      StringBuilder groupNumberBuffer = new StringBuilder();
      field = parseArgument(expression, field+2, fieldNameBuffer);
      field = parseArgument(expression, field, regExpBuffer);
      field = parseArgument(expression, field, groupNumberBuffer);
      int fieldEnd = parseToEnd(expression, field);
      if (fieldEnd == expression.length()) {
        if (fieldNameBuffer.length() > 0)
          return append(input, new FieldSource(sourceDocument, fieldNameBuffer.toString(), regExpBuffer.toString(), groupNumberBuffer.toString()));
        return input;
      } else {
        if (fieldNameBuffer.length() > 0)
          input = append(input, new FieldSource(sourceDocument, fieldNameBuffer.toString(), regExpBuffer.toString(), groupNumberBuffer.toString()));
        index = fieldEnd;
      }
    }
  }
  
  protected static int parseArgument(final String input, int start, final StringBuilder output) {
    // Parse until we hit the end marker or an unescaped pipe symbol
    while (true) {
      if (input.length() == start)
        return start;
      char theChar = input.charAt(start);
      if (theChar == '}')
        return start;
      start++;
      if (theChar == '|')
        return start;
      if (theChar == '\\') {
        if (input.length() == start)
          return start;
        theChar = input.charAt(start++);
      }
      output.append(theChar);
    }
  }
  
  protected static int parseToEnd(final String input, int start) {
    while (true) {
      if (input.length() == start)
        return start;
      char theChar = input.charAt(start++);
      if (theChar == '}')
        return start;
      if (theChar == '\\') {
        if (input.length() == start)
          return start;
        start++;
      }
    }
  }

  protected static class SpecPacker {
    
    private final boolean keepAllMetadata;
    private final boolean filterEmpty;
    private final Map<String,Set<String>> expressions = new HashMap<String,Set<String>>();

    public SpecPacker(Specification os) {
      boolean keepAllMetadata = true;
      boolean filterEmpty = true;
      final Map<String,Set<String>> additions = new HashMap<String,Set<String>>();
      final Map<String,Set<String>> expressionAdditions = new HashMap<String,Set<String>>();
      for (int i = 0; i < os.getChildCount(); i++) {
        SpecificationNode sn = os.getChild(i);
        
        if(sn.getType().equals(NODE_KEEPMETADATA)) {
          String value = sn.getAttributeValue(ATTRIBUTE_VALUE);
          keepAllMetadata = Boolean.parseBoolean(value);
        } else if (sn.getType().equals(NODE_FILTEREMPTY)) {
          String value = sn.getAttributeValue(ATTRIBUTE_VALUE);
          filterEmpty = Boolean.parseBoolean(value);
        } else if (sn.getType().equals(NODE_FIELDMAP)) {
          String source = sn.getAttributeValue(ATTRIBUTE_SOURCE);
          String target = sn.getAttributeValue(ATTRIBUTE_TARGET);
          
          expressions.put(source,new HashSet<String>());
          // Null target means to remove the *source* from the document.
          if (target != null) {
            Set<String> sources = new HashSet<String>();
            sources.add("${"+source+"}");
            expressions.put(target,sources);
          }
        }
        else if (sn.getType().equals(NODE_PAIR))
        {
          String parameter = sn.getAttributeValue(ATTRIBUTE_PARAMETER);
          String value = sn.getAttributeValue(ATTRIBUTE_VALUE);
          // Since the same target is completely superceded by a NODE_PAIR, but NODE_PAIRs
          // are cumulative, I have to build these completely and then post-process them.
          Set<String> addition = additions.get(parameter);
          if (addition == null)
          {
            addition = new HashSet<String>();
            additions.put(parameter,addition);
          }
          addition.add(nonExpressionEscape(value));
        }
        else if (sn.getType().equals(NODE_EXPRESSION))
        {
          String parameter = sn.getAttributeValue(ATTRIBUTE_PARAMETER);
          String value = sn.getAttributeValue(ATTRIBUTE_VALUE);
          if (value == null) {
            expressionAdditions.put(parameter,new HashSet<String>());
          } else {
            Set<String> expressionAddition = expressionAdditions.get(parameter);
            if (expressionAddition == null)
            {
              expressionAddition = new HashSet<String>();
              expressionAdditions.put(parameter,expressionAddition);
            }
            expressionAddition.add(value);
          }
        }
      }
      
      // Override the moves with the additions
      for (String parameter : additions.keySet())
      {
        expressions.put(parameter,additions.get(parameter));
      }

      // Override all with expression additions
      for (String parameter : expressionAdditions.keySet())
      {
        expressions.put(parameter,expressionAdditions.get(parameter));
      }
      
      this.keepAllMetadata = keepAllMetadata;
      this.filterEmpty = filterEmpty;
    }
    
    public String toPackedString() {
      StringBuilder sb = new StringBuilder();
      int i;
      
      final String[] sortArray = expressions.keySet().toArray(new String[0]);
      java.util.Arrays.sort(sortArray);
      // Pack the list of keys
      packList(sb,sortArray,'+');
      for (String key : sortArray) {
        Set<String> values = expressions.get(key);
        String[] valueArray = values.toArray(new String[0]);
        java.util.Arrays.sort(valueArray);
        packList(sb,valueArray,'+');
      }

      // Keep all metadata
      if (keepAllMetadata)
        sb.append('+');
      else
        sb.append('-');
      
      // Filter empty
      if (filterEmpty)
        sb.append('+');
      else
        sb.append('-');

      return sb.toString();
    }
    
    public Iterator<String> getExpressionKeys()
    {
      return expressions.keySet().iterator();
    }
    
    public Set<String> getExpressionValues(String key)
    {
      return expressions.get(key);
    }
    
    public boolean keepAllMetadata() {
      return keepAllMetadata;
    }
    
    public boolean filterEmpty() {
      return filterEmpty;
    }
  }
  
}


