/*
 * CDDL HEADER START
 *
 * The contents of this file are subject to the terms of the
 * Common Development and Distribution License, Version 1.0 only
 * (the "License").  You may not use this file except in compliance
 * with the License.
 *
 * You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
 * or http://forgerock.org/license/CDDLv1.0.html.
 * See the License for the specific language governing permissions
 * and limitations under the License.
 *
 * When distributing Covered Code, include this CDDL HEADER in each
 * file and include the License file at legal-notices/CDDLv1_0.txt.
 * If applicable, add the following below this CDDL HEADER, with the
 * fields enclosed by brackets "[]" replaced with your own identifying
 * information:
 *      Portions Copyright [yyyy] [name of copyright owner]
 *
 * CDDL HEADER END
 *
 *
 *      Copyright 2006-2009 Sun Microsystems, Inc.
 *      Portions Copyright 2013-2015 ForgeRock AS.
 */
package org.opends.server.util;

import java.io.BufferedWriter;
import java.io.Closeable;
import java.io.IOException;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.regex.Pattern;

import org.forgerock.i18n.LocalizableMessage;
import org.forgerock.opendj.ldap.ByteSequence;
import org.forgerock.opendj.ldap.ByteString;
import org.opends.server.tools.makeldif.TemplateEntry;
import org.opends.server.types.*;

import static org.forgerock.util.Reject.*;
import static org.opends.server.util.StaticUtils.*;

/**
 * This class provides a mechanism for writing entries in LDIF form to a file or
 * an output stream.
 */
@org.opends.server.types.PublicAPI(
     stability=org.opends.server.types.StabilityLevel.UNCOMMITTED,
     mayInstantiate=true,
     mayExtend=false,
     mayInvoke=true)
public final class LDIFWriter implements Closeable
{
  // FIXME -- Add support for generating a hash when writing the data.
  // FIXME -- Add support for signing the hash that is generated.

  /** The writer to which the LDIF information will be written. */
  private BufferedWriter writer;

  /** The configuration to use for the export. */
  private LDIFExportConfig exportConfig;

  /** Regular expression used for splitting comments on line-breaks. */
  private static final Pattern SPLIT_NEWLINE = Pattern.compile("\\r?\\n");



  /**
   * Creates a new LDIF writer with the provided configuration.
   *
   * @param  exportConfig  The configuration to use for the export.  It must not
   *                       be <CODE>null</CODE>.
   *
   * @throws  IOException  If a problem occurs while opening the writer.
   */
  public LDIFWriter(LDIFExportConfig exportConfig)
         throws IOException
  {
    ifNull(exportConfig);
    this.exportConfig = exportConfig;

    writer = exportConfig.getWriter();
  }



  /**
   * Writes the provided comment to the LDIF file, optionally wrapping near the
   * specified column.  Each line will be prefixed by the octothorpe (#)
   * character followed by a space.  If the comment should be wrapped at a
   * specified column, then it will attempt to do so at the first whitespace
   * character at or before that column (so it will try not wrap in the middle
   * of a word).
   * <BR><BR>
   * This comment will be ignored by the
   * Directory Server's LDIF reader, as well as any other compliant LDIF parsing
   * software.
   *
   * @param  comment     The comment to be written.  Any line breaks that it
   *                     contains will be honored, and potentially new line
   *                     breaks may be introduced by the wrapping process.  It
   *                     must not be <CODE>null</CODE>.
   * @param  wrapColumn  The column at which long lines should be wrapped, or
   *                     -1 to indicate that no additional wrapping should be
   *                     added.  This will override the wrap column setting
   *                     specified in the LDIF export configuration.
   *
   * @throws  IOException  If a problem occurs while attempting to write the
   *                       comment to the LDIF file.
   */
  public void writeComment(LocalizableMessage comment, int wrapColumn)
         throws IOException
  {
    ifNull(comment);


    // First, break up the comment into multiple lines to preserve the original
    // spacing that it contained.
    String[] lines = SPLIT_NEWLINE.split(comment);

    // Now iterate through the lines and write them out, prefixing and wrapping
    // them as necessary.
    for (String l : lines)
    {
      if (wrapColumn <= 0)
      {
        writer.write("# ");
        writer.write(l);
        writer.newLine();
      }
      else
      {
        int breakColumn = wrapColumn - 2;

        if (l.length() <= breakColumn)
        {
          writer.write("# ");
          writer.write(l);
          writer.newLine();
        }
        else
        {
          int startPos = 0;
outerLoop:
          while (startPos < l.length())
          {
            if (startPos + breakColumn >= l.length())
            {
              writer.write("# ");
              writer.write(l.substring(startPos));
              writer.newLine();
              startPos = l.length();
            }
            else
            {
              int endPos = startPos + breakColumn;

              int i=endPos - 1;
              while (i > startPos)
              {
                if (l.charAt(i) == ' ')
                {
                  writer.write("# ");
                  writer.write(l.substring(startPos, i));
                  writer.newLine();

                  startPos = i+1;
                  continue outerLoop;
                }

                i--;
              }

              // If we've gotten here, then there are no spaces on the entire
              // line.  If that happens, then we'll have to break in the middle
              // of a word.
              writer.write("# ");
              writer.write(l.substring(startPos, endPos));
              writer.newLine();

              startPos = endPos;
            }
          }
        }
      }
    }
  }

  /**
 * Iterates over each entry contained in the map and writes out the entry in
 * LDIF format. The main benefit of this method is that the entries can be
 * sorted by DN and output in sorted order.
 *
 * @param entries The Map containing the entries keyed by DN.
 *
 * @return <CODE>true</CODE>of all of the entries were
 *                  written out, <CODE>false</CODE>if it was not
 *                  because of the export configuration.
 *
 * @throws IOException  If a problem occurs while writing the entry to LDIF.
 *
 * @throws LDIFException If a problem occurs while trying to determine
 *                         whether to include the entry in the export.
 */
  public boolean writeEntries(Collection<Entry> entries) throws IOException,
      LDIFException
  {
    for (Entry entry : entries)
    {
      if (!writeEntry(entry))
      {
        return false;
      }
    }
    return true;
  }


  /**
   * Writes the provided entry to LDIF.
   *
   * @param  entry  The entry to be written.  It must not be <CODE>null</CODE>.
   *
   * @return  <CODE>true</CODE> if the entry was actually written, or
   *          <CODE>false</CODE> if it was not because of the export
   *          configuration.
   *
   * @throws  IOException  If a problem occurs while writing the entry to LDIF.
   *
   * @throws  LDIFException  If a problem occurs while trying to determine
   *                         whether to include the entry in the export.
   */
  public boolean writeEntry(Entry entry)
         throws IOException, LDIFException
  {
    ifNull(entry);
    return entry.toLDIF(exportConfig);
  }


  /**
   * Writes the provided template entry to LDIF.
   *
   * @param  templateEntry  The template entry to be written.  It must not be
   * <CODE>null</CODE>.
   *
   * @return  <CODE>true</CODE> if the entry was actually written, or
   *          <CODE>false</CODE> if it was not because of the export
   *          configuration.
   *
   * @throws  IOException  If a problem occurs while writing the template entry
   *                       to LDIF.
   *
   * @throws  LDIFException  If a problem occurs while trying to determine
   *                         whether to include the template entry in the
   *                         export.
   */
  public boolean writeTemplateEntry(TemplateEntry templateEntry)
  throws IOException, LDIFException
  {
    ifNull(templateEntry);
    return templateEntry.toLDIF(exportConfig);
  }

  /**
   * Writes a change record entry for the provided change record.
   *
   * @param  changeRecord  The change record entry to be written.
   *
   * @throws  IOException  If a problem occurs while writing the change record.
   */
  public void writeChangeRecord(ChangeRecordEntry changeRecord)
         throws IOException
  {
    ifNull(changeRecord);


    // Get the information necessary to write the LDIF.
    BufferedWriter writer     = exportConfig.getWriter();
    int            wrapColumn = exportConfig.getWrapColumn();
    boolean        wrapLines  = wrapColumn > 1;


    // First, write the DN.
    writeDN("dn", changeRecord.getDN(), writer, wrapLines, wrapColumn);


    // Figure out what type of change it is and act accordingly.
    if (changeRecord instanceof AddChangeRecordEntry)
    {
      StringBuilder changeTypeLine = new StringBuilder("changetype: add");
      writeLDIFLine(changeTypeLine, writer, wrapLines, wrapColumn);

      AddChangeRecordEntry addRecord = (AddChangeRecordEntry) changeRecord;
      for (Attribute a : addRecord.getAttributes())
      {
        for (ByteString v : a)
        {
          final String attrName = a.getNameWithOptions();
          writeAttribute(attrName, v, writer, wrapLines, wrapColumn);
        }
      }
    }
    else if (changeRecord instanceof DeleteChangeRecordEntry)
    {
      StringBuilder changeTypeLine = new StringBuilder("changetype: delete");
      writeLDIFLine(changeTypeLine, writer, wrapLines, wrapColumn);
    }
    else if (changeRecord instanceof ModifyChangeRecordEntry)
    {
      StringBuilder changeTypeLine = new StringBuilder("changetype: modify");
      writeLDIFLine(changeTypeLine, writer, wrapLines, wrapColumn);

      ModifyChangeRecordEntry modifyRecord =
           (ModifyChangeRecordEntry) changeRecord;
      List<RawModification> mods = modifyRecord.getModifications();
      Iterator<RawModification> iterator = mods.iterator();
      while (iterator.hasNext())
      {
        RawModification m = iterator.next();
        RawAttribute a = m.getAttribute();
        String attrName = a.getAttributeType();
        StringBuilder modTypeLine = new StringBuilder();
        modTypeLine.append(m.getModificationType());
        modTypeLine.append(": ");
        modTypeLine.append(attrName);
        writeLDIFLine(modTypeLine, writer, wrapLines, wrapColumn);

        for (ByteString s : a.getValues())
        {
          StringBuilder valueLine = new StringBuilder(attrName);
          String stringValue = s.toString();

          if (needsBase64Encoding(stringValue))
          {
            valueLine.append(":: ");
            valueLine.append(Base64.encode(s));
          }
          else
          {
            valueLine.append(": ");
            valueLine.append(stringValue);
          }

          writeLDIFLine(valueLine, writer, wrapLines, wrapColumn);
        }

        if (iterator.hasNext())
        {
          StringBuilder dashLine = new StringBuilder("-");
          writeLDIFLine(dashLine, writer, wrapLines, wrapColumn);
        }
      }
    }
    else if (changeRecord instanceof ModifyDNChangeRecordEntry)
    {
      StringBuilder changeTypeLine = new StringBuilder("changetype: moddn");
      writeLDIFLine(changeTypeLine, writer, wrapLines, wrapColumn);

      ModifyDNChangeRecordEntry modifyDNRecord =
           (ModifyDNChangeRecordEntry) changeRecord;

      StringBuilder newRDNLine = new StringBuilder("newrdn: ");
      modifyDNRecord.getNewRDN().toString(newRDNLine);
      writeLDIFLine(newRDNLine, writer, wrapLines, wrapColumn);

      StringBuilder deleteOldRDNLine = new StringBuilder("deleteoldrdn: ");
      deleteOldRDNLine.append(modifyDNRecord.deleteOldRDN() ? "1" : "0");
      writeLDIFLine(deleteOldRDNLine, writer, wrapLines, wrapColumn);

      DN newSuperiorDN = modifyDNRecord.getNewSuperiorDN();
      if (newSuperiorDN != null)
      {
        StringBuilder newSuperiorLine = new StringBuilder("newsuperior: ");
        newSuperiorDN.toString(newSuperiorLine);
        writeLDIFLine(newSuperiorLine, writer, wrapLines, wrapColumn);
      }
    }


    // Make sure there is a blank line after the entry.
    writer.newLine();
  }



  /**
   * Writes an add change record for the provided entry.  No filtering will be
   * performed for this entry, nor will any export plugins be invoked.  Further,
   * only the user attributes will be included.
   *
   * @param  entry  The entry to include in the add change record.  It must not
   *                be <CODE>null</CODE>.
   *
   * @throws  IOException  If a problem occurs while writing the add record.
   */
  public void writeAddChangeRecord(Entry entry)
         throws IOException
  {
    ifNull(entry);


    // Get the information necessary to write the LDIF.
    BufferedWriter writer     = exportConfig.getWriter();
    int            wrapColumn = exportConfig.getWrapColumn();
    boolean        wrapLines  = wrapColumn > 1;


    // First, write the DN.
    writeDN("dn", entry.getName(), writer, wrapLines, wrapColumn);


    // Next, the changetype.
    StringBuilder changeTypeLine = new StringBuilder("changetype: add");
    writeLDIFLine(changeTypeLine, writer, wrapLines, wrapColumn);


    // Now the objectclasses.
    for (String s : entry.getObjectClasses().values())
    {
      StringBuilder ocLine = new StringBuilder();
      ocLine.append("objectClass: ");
      ocLine.append(s);
      writeLDIFLine(ocLine, writer, wrapLines, wrapColumn);
    }


    // Finally, the set of user attributes.
    for (AttributeType attrType : entry.getUserAttributes().keySet())
    {
      for (Attribute a : entry.getUserAttribute(attrType))
      {
        StringBuilder attrName = new StringBuilder(a.getName());
        for (String o : a.getOptions())
        {
          attrName.append(";");
          attrName.append(o);
        }

        for (ByteString v : a)
        {
          writeAttribute(attrName, v, writer, wrapLines, wrapColumn);
        }
      }
    }


    // Make sure there is a blank line after the entry.
    writer.newLine();
  }



  /**
   * Writes a delete change record for the provided entry, optionally including
   * a comment with the full entry contents.  No filtering will be performed for
   * this entry, nor will any export plugins be invoked.  Further, only the user
   * attributes will be included.
   *
   * @param  entry         The entry to include in the delete change record.  It
   *                       must not be <CODE>null</CODE>.
   * @param  commentEntry  Indicates whether to include a comment with the
   *                       contents of the entry.
   *
   * @throws  IOException  If a problem occurs while writing the delete record.
   */
  public void writeDeleteChangeRecord(Entry entry, boolean commentEntry)
         throws IOException
  {
    ifNull(entry);

    // Get the information necessary to write the LDIF.
    BufferedWriter writer     = exportConfig.getWriter();
    int            wrapColumn = exportConfig.getWrapColumn();
    boolean        wrapLines  = wrapColumn > 1;


    // Add the DN and changetype lines.
    writeDN("dn", entry.getName(), writer, wrapLines, wrapColumn);

    StringBuilder changeTypeLine = new StringBuilder("changetype: delete");
    writeLDIFLine(changeTypeLine, writer, wrapLines, wrapColumn);


    // If we should include a comment with the rest of the entry contents, then
    // do so now.
    if (commentEntry)
    {
      // Write the objectclasses.
      for (String s : entry.getObjectClasses().values())
      {
        StringBuilder ocLine = new StringBuilder();
        ocLine.append("# objectClass: ");
        ocLine.append(s);
        writeLDIFLine(ocLine, writer, wrapLines, wrapColumn);
      }

      // Write the set of user attributes.
      for (AttributeType attrType : entry.getUserAttributes().keySet())
      {
        for (Attribute a : entry.getUserAttribute(attrType))
        {
          StringBuilder attrName = new StringBuilder();
          attrName.append("# ");
          attrName.append(a.getName());
          for (String o : a.getOptions())
          {
            attrName.append(";");
            attrName.append(o);
          }

          for (ByteString v : a)
          {
            writeAttribute(attrName, v, writer, wrapLines, wrapColumn);
          }
        }
      }
    }


    // Make sure there is a blank line after the entry.
    writer.newLine();
  }



  /**
   * Writes a modify change record with the provided information.  No filtering
   * will be performed, nor will any export plugins be invoked.
   *
   * @param  dn             The DN of the entry being modified.  It must not be
   *                        <CODE>null</CODE>.
   * @param  modifications  The set of modifications to include in the change
   *                        record.  It must not be <CODE>null</CODE>.
   *
   * @throws  IOException  If a problem occurs while writing the modify record.
   */
  public void writeModifyChangeRecord(DN dn, List<Modification> modifications)
         throws IOException
  {
    ifNull(dn, modifications);

    // If there aren't any modifications, then there's nothing to do.
    if (modifications.isEmpty())
    {
      return;
    }


    // Get the information necessary to write the LDIF.
    BufferedWriter writer     = exportConfig.getWriter();
    int            wrapColumn = exportConfig.getWrapColumn();
    boolean        wrapLines  = wrapColumn > 1;


    // Write the DN and changetype.
    writeDN("dn", dn, writer, wrapLines, wrapColumn);

    StringBuilder changeTypeLine = new StringBuilder("changetype: modify");
    writeLDIFLine(changeTypeLine, writer, wrapLines, wrapColumn);


    // Iterate through the modifications and write them to the LDIF.
    Iterator<Modification> iterator = modifications.iterator();
    while (iterator.hasNext())
    {
      Modification m    = iterator.next();
      Attribute    a    = m.getAttribute();

      StringBuilder nameBuffer = new StringBuilder(a.getName());
      for (String o : a.getOptions())
      {
        nameBuffer.append(";");
        nameBuffer.append(o);
      }
      String  name = nameBuffer.toString();

      StringBuilder modTypeLine = new StringBuilder();
      modTypeLine.append(m.getModificationType());
      modTypeLine.append(": ");
      modTypeLine.append(name);
      writeLDIFLine(modTypeLine, writer, wrapLines, wrapColumn);

      for (ByteString v : a)
      {
        writeAttribute(name, v, writer, wrapLines, wrapColumn);
      }


      // If this is the last modification, then append blank line.  Otherwise
      // write a line with just a dash.
      if (iterator.hasNext())
      {
        writer.write("-");
      }
      writer.newLine();
    }
  }



  /**
   * Writes a modify DN change record with the provided information.  No
   * filtering will be performed, nor will any export plugins be invoked.
   *
   * @param  dn            The DN of the entry before the rename.  It must not
   *                       be <CODE>null</CODE>.
   * @param  newRDN        The new RDN for the entry.  It must not be
   *                       <CODE>null</CODE>.
   * @param  deleteOldRDN  Indicates whether the old RDN value should be removed
   *                       from the entry.
   * @param  newSuperior   The new superior DN for the entry, or
   *                       <CODE>null</CODE> if the entry will stay below the
   *                       same parent.
   *
   * @throws  IOException  If a problem occurs while writing the modify record.
   */
  public void writeModifyDNChangeRecord(DN dn, RDN newRDN, boolean deleteOldRDN,
                                        DN newSuperior)
         throws IOException
  {
    ifNull(dn, newRDN);


    // Get the information necessary to write the LDIF.
    BufferedWriter writer     = exportConfig.getWriter();
    int            wrapColumn = exportConfig.getWrapColumn();
    boolean        wrapLines  = wrapColumn > 1;


    // Write the current DN.
    writeDN("dn", dn, writer, wrapLines, wrapColumn);


    // Write the changetype.  Some older tools may not support the "moddn"
    // changetype, so only use it if a newSuperior element has been provided,
    // but use modrdn elsewhere.
    String changeType = newSuperior == null ? "changetype: modrdn" : "changetype: moddn";
    writeLDIFLine(new StringBuilder(changeType), writer, wrapLines, wrapColumn);


    // Write the newRDN element.
    StringBuilder rdnLine = new StringBuilder("newrdn");
    appendLDIFSeparatorAndValue(rdnLine, ByteString.valueOfUtf8(newRDN.toString()));
    writeLDIFLine(rdnLine, writer, wrapLines, wrapColumn);


    // Write the deleteOldRDN element.
    StringBuilder deleteOldRDNLine = new StringBuilder();
    deleteOldRDNLine.append("deleteoldrdn: ");
    deleteOldRDNLine.append(deleteOldRDN ? "1" : "0");
    writeLDIFLine(deleteOldRDNLine, writer, wrapLines, wrapColumn);

    if (newSuperior != null)
    {
      writeDN("newsuperior", newSuperior, writer, wrapLines, wrapColumn);
    }


    // Make sure there is a blank line after the entry.
    writer.newLine();
  }

  private void writeDN(String attrType, DN dn, BufferedWriter writer,
      boolean wrapLines, int wrapColumn) throws IOException
  {
    final StringBuilder newLine = new StringBuilder(attrType);
    appendLDIFSeparatorAndValue(newLine, ByteString.valueOfUtf8(dn.toString()));
    writeLDIFLine(newLine, writer, wrapLines, wrapColumn);
  }

  private void writeAttribute(CharSequence attrName, ByteString v,
      BufferedWriter writer, boolean wrapLines, int wrapColumn)
      throws IOException
  {
    StringBuilder newLine = new StringBuilder(attrName);
    appendLDIFSeparatorAndValue(newLine, v);
    writeLDIFLine(newLine, writer, wrapLines, wrapColumn);
  }

  /**
   * Flushes the data written to the output stream or underlying file.
   *
   * @throws  IOException  If a problem occurs while flushing the output.
   */
  public void flush()
         throws IOException
  {
    writer.flush();
  }



  /**
   * Closes the LDIF writer and the underlying output stream or file.
   *
   * @throws  IOException  If a problem occurs while closing the writer.
   */
  @Override
  public void close()
         throws IOException
  {
    writer.flush();
    writer.close();
  }



  /**
   * Appends an LDIF separator and properly-encoded form of the given
   * value to the provided buffer.  If the value is safe to include
   * as-is, then a single colon, a single space, space, and the
   * provided value will be appended.  Otherwise, two colons, a single
   * space, and a base64-encoded form of the value will be appended.
   *
   * @param  buffer      The buffer to which the information should be
   *                     appended.  It must not be <CODE>null</CODE>.
   * @param  valueBytes  The value to append to the buffer.  It must not be
   *                     <CODE>null</CODE>.
   */
  public static void appendLDIFSeparatorAndValue(StringBuilder buffer,
                                                 ByteSequence valueBytes)
  {
    appendLDIFSeparatorAndValue(buffer, valueBytes, false, false);
  }

  /**
   * Appends an LDIF separator and properly-encoded form of the given
   * value to the provided buffer.  If the value is safe to include
   * as-is, then a single colon, a single space, space, and the
   * provided value will be appended.  Otherwise, two colons, a single
   * space, and a base64-encoded form of the value will be appended.
   * @param  buffer      The buffer to which the information should be
   *                     appended.  It must not be <CODE>null</CODE>.
   * @param  valueBytes  The value to append to the buffer.  It must not be
   *                     <CODE>null</CODE>.
   * @param isURL        Whether the provided value is an URL value or not.
   * @param isBase64     Whether the provided value is a base 64 value or not.
   */
  public static void appendLDIFSeparatorAndValue(StringBuilder buffer,
      ByteSequence valueBytes, boolean isURL, boolean isBase64)
  {
    ifNull(buffer, valueBytes);


    // If the value is empty, then just append a single colon (the URL '<' if
    // required) and a single space.
    final boolean valueIsEmpty = valueBytes == null || valueBytes.length() == 0;
    if (isURL)
    {
      buffer.append(":< ");
      if (!valueIsEmpty)
      {
        buffer.append(valueBytes.toString());
      }
    }
    else if (isBase64)
    {
      buffer.append(":: ");
      if (!valueIsEmpty)
      {
        buffer.append(valueBytes.toString());
      }
    }
    else if (needsBase64Encoding(valueBytes))
    {
      buffer.append(":: ");
      buffer.append(Base64.encode(valueBytes));
    }
    else
    {
      buffer.append(": ");
      if (!valueIsEmpty)
      {
        buffer.append(valueBytes.toString());
      }
    }
  }



  /**
   * Writes the provided line to LDIF using the provided information.
   *
   * @param  line        The line of information to write.  It must not be
   *                     <CODE>null</CODE>.
   * @param  writer      The writer to which the data should be written.  It
   *                     must not be <CODE>null</CODE>.
   * @param  wrapLines   Indicates whether to wrap long lines.
   * @param  wrapColumn  The column at which long lines should be wrapped.
   *
   * @throws  IOException  If a problem occurs while writing the information.
   */
  public static void writeLDIFLine(StringBuilder line, BufferedWriter writer,
                                   boolean wrapLines, int wrapColumn)
          throws IOException
  {
    ifNull(line, writer);

    int length = line.length();
    if (wrapLines && length > wrapColumn)
    {
      writer.write(line.substring(0, wrapColumn));
      writer.newLine();

      int pos = wrapColumn;
      while (pos < length)
      {
        int writeLength = Math.min(wrapColumn-1, length-pos);
        writer.write(' ');
        writer.write(line.substring(pos, pos+writeLength));
        writer.newLine();

        pos += wrapColumn-1;
      }
    }
    else
    {
      writer.write(line.toString());
      writer.newLine();
    }
  }
}
