/*
 * 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 2008-2010 Sun Microsystems, Inc.
 *      Portions Copyright 2014-2015 ForgeRock AS
 */
package org.opends.guitools.controlpanel.task;

import static org.opends.messages.AdminToolMessages.*;
import static org.opends.server.config.ConfigConstants.*;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

import javax.naming.NamingException;
import javax.naming.directory.Attribute;
import javax.naming.directory.BasicAttribute;
import javax.naming.directory.DirContext;
import javax.naming.directory.ModificationItem;
import javax.naming.ldap.InitialLdapContext;
import javax.swing.SwingUtilities;
import javax.swing.tree.TreePath;

import org.forgerock.i18n.LocalizableMessage;
import org.forgerock.opendj.ldap.ByteString;
import org.opends.guitools.controlpanel.browser.BrowserController;
import org.opends.guitools.controlpanel.datamodel.BackendDescriptor;
import org.opends.guitools.controlpanel.datamodel.BaseDNDescriptor;
import org.opends.guitools.controlpanel.datamodel.CannotRenameException;
import org.opends.guitools.controlpanel.datamodel.ControlPanelInfo;
import org.opends.guitools.controlpanel.datamodel.CustomSearchResult;
import org.opends.guitools.controlpanel.ui.ColorAndFontConstants;
import org.opends.guitools.controlpanel.ui.ProgressDialog;
import org.opends.guitools.controlpanel.ui.StatusGenericPanel;
import org.opends.guitools.controlpanel.ui.ViewEntryPanel;
import org.opends.guitools.controlpanel.ui.nodes.BasicNode;
import org.opends.guitools.controlpanel.util.Utilities;
import org.opends.messages.AdminToolMessages;
import org.opends.server.types.DN;
import org.opends.server.types.DirectoryException;
import org.opends.server.types.Entry;
import org.opends.server.types.OpenDsException;
import org.opends.server.types.RDN;
import org.opends.server.types.Schema;

/** The task that is called when we must modify an entry. */
public class ModifyEntryTask extends Task
{
  private Set<String> backendSet;
  private boolean mustRename;
  private boolean hasModifications;
  private CustomSearchResult oldEntry;
  private DN oldDn;
  private ArrayList<ModificationItem> modifications;
  private ModificationItem passwordModification;
  private Entry newEntry;
  private BrowserController controller;
  private TreePath treePath;
  private boolean useAdminCtx;

  /**
   * Constructor of the task.
   * @param info the control panel information.
   * @param dlg the progress dialog where the task progress will be displayed.
   * @param newEntry the entry containing the new values.
   * @param oldEntry the old entry as we retrieved using JNDI.
   * @param controller the BrowserController.
   * @param path the TreePath corresponding to the node in the tree that we
   * want to modify.
   */
  public ModifyEntryTask(ControlPanelInfo info, ProgressDialog dlg,
      Entry newEntry, CustomSearchResult oldEntry,
      BrowserController controller, TreePath path)
  {
    super(info, dlg);
    backendSet = new HashSet<>();
    this.oldEntry = oldEntry;
    this.newEntry = newEntry;
    this.controller = controller;
    this.treePath = path;
    DN newDn = newEntry.getName();
    try
    {
      oldDn = DN.valueOf(oldEntry.getDN());
      for (BackendDescriptor backend : info.getServerDescriptor().getBackends())
      {
        for (BaseDNDescriptor baseDN : backend.getBaseDns())
        {
          if (newDn.isDescendantOf(baseDN.getDn()) ||
              oldDn.isDescendantOf(baseDN.getDn()))
          {
            backendSet.add(backend.getBackendID());
          }
        }
      }
      mustRename = !newDn.equals(oldDn);
    }
    catch (OpenDsException ode)
    {
      throw new RuntimeException("Could not parse DN: " + oldEntry.getDN(), ode);
    }
    modifications = getModifications(newEntry, oldEntry, getInfo());
    // Find password modifications
    for (ModificationItem mod : modifications)
    {
      if (mod.getAttribute().getID().equalsIgnoreCase("userPassword"))
      {
        passwordModification = mod;
        break;
      }
    }
    if (passwordModification != null)
    {
      modifications.remove(passwordModification);
    }
    hasModifications = !modifications.isEmpty()
        || !oldDn.equals(newEntry.getName())
        || passwordModification != null;
  }

  /**
   * Tells whether there actually modifications on the entry.
   * @return <CODE>true</CODE> if there are modifications and <CODE>false</CODE>
   * otherwise.
   */
  public boolean hasModifications()
  {
    return hasModifications;
  }

  /** {@inheritDoc} */
  public Type getType()
  {
    return Type.MODIFY_ENTRY;
  }

  /** {@inheritDoc} */
  public Set<String> getBackends()
  {
    return backendSet;
  }

  /** {@inheritDoc} */
  public LocalizableMessage getTaskDescription()
  {
    return INFO_CTRL_PANEL_MODIFY_ENTRY_TASK_DESCRIPTION.get(oldEntry.getDN());
  }

  /** {@inheritDoc} */
  protected String getCommandLinePath()
  {
    return null;
  }

  /** {@inheritDoc} */
  protected ArrayList<String> getCommandLineArguments()
  {
    return new ArrayList<>();
  }

  /** {@inheritDoc} */
  public boolean canLaunch(Task taskToBeLaunched,
      Collection<LocalizableMessage> incompatibilityReasons)
  {
    if (!isServerRunning()
        && state == State.RUNNING
        && runningOnSameServer(taskToBeLaunched))
    {
      // All the operations are incompatible if they apply to this
      // backend for safety.  This is a short operation so the limitation
      // has not a lot of impact.
      Set<String> backends = new TreeSet<>(taskToBeLaunched.getBackends());
      backends.retainAll(getBackends());
      if (!backends.isEmpty())
      {
        incompatibilityReasons.add(getIncompatibilityMessage(this, taskToBeLaunched));
        return false;
      }
    }
    return true;
  }

  /** {@inheritDoc} */
  public boolean regenerateDescriptor()
  {
    return false;
  }

  /** {@inheritDoc} */
  public void runTask()
  {
    state = State.RUNNING;
    lastException = null;

    try
    {
      BasicNode node = (BasicNode)treePath.getLastPathComponent();
      InitialLdapContext ctx = controller.findConnectionForDisplayedEntry(node);
      useAdminCtx = controller.isConfigurationNode(node);
      if (!mustRename)
      {
        if (!modifications.isEmpty()) {
          ModificationItem[] mods =
          new ModificationItem[modifications.size()];
          modifications.toArray(mods);

          SwingUtilities.invokeLater(new Runnable()
          {
            public void run()
            {
              printEquivalentCommandToModify(newEntry.getName(), modifications,
                  useAdminCtx);
              getProgressDialog().appendProgressHtml(
                  Utilities.getProgressWithPoints(
                      INFO_CTRL_PANEL_MODIFYING_ENTRY.get(oldEntry.getDN()),
                      ColorAndFontConstants.progressFont));
            }
          });

          ctx.modifyAttributes(Utilities.getJNDIName(oldEntry.getDN()), mods);

          SwingUtilities.invokeLater(new Runnable()
          {
            public void run()
            {
              getProgressDialog().appendProgressHtml(
                  Utilities.getProgressDone(
                      ColorAndFontConstants.progressFont));
              controller.notifyEntryChanged(
                  controller.getNodeInfoFromPath(treePath));
              controller.getTree().removeSelectionPath(treePath);
              controller.getTree().setSelectionPath(treePath);
            }
          });
        }
      }
      else
      {
        modifyAndRename(ctx, oldDn, oldEntry, newEntry, modifications);
      }
      state = State.FINISHED_SUCCESSFULLY;
    }
    catch (Throwable t)
    {
      lastException = t;
      state = State.FINISHED_WITH_ERROR;
    }
  }

  /** {@inheritDoc} */
  public void postOperation()
  {
    if (lastException == null
        && state == State.FINISHED_SUCCESSFULLY
        && passwordModification != null)
    {
      try
      {
        Object o = passwordModification.getAttribute().get();
        String sPwd;
        if (o instanceof byte[])
        {
          try
          {
            sPwd = new String((byte[])o, "UTF-8");
          }
          catch (Throwable t)
          {
            throw new RuntimeException("Unexpected error: "+t, t);
          }
        }
        else
        {
          sPwd = String.valueOf(o);
        }
        ResetUserPasswordTask newTask = new ResetUserPasswordTask(getInfo(),
            getProgressDialog(), (BasicNode)treePath.getLastPathComponent(),
            controller, sPwd.toCharArray());
        if (!modifications.isEmpty() || mustRename)
        {
          getProgressDialog().appendProgressHtml("<br><br>");
        }
        StatusGenericPanel.launchOperation(newTask,
            INFO_CTRL_PANEL_RESETTING_USER_PASSWORD_SUMMARY.get(),
            INFO_CTRL_PANEL_RESETTING_USER_PASSWORD_SUCCESSFUL_SUMMARY.get(),
            INFO_CTRL_PANEL_RESETTING_USER_PASSWORD_SUCCESSFUL_DETAILS.get(),
            ERR_CTRL_PANEL_RESETTING_USER_PASSWORD_ERROR_SUMMARY.get(),
            ERR_CTRL_PANEL_RESETTING_USER_PASSWORD_ERROR_DETAILS.get(),
            null,
            getProgressDialog(),
            false,
            getInfo());
        getProgressDialog().setVisible(true);
      }
      catch (NamingException ne)
      {
        // This should not happen
        throw new RuntimeException("Unexpected exception: "+ne, ne);
      }
    }
  }

  /**
   * Modifies and renames the entry.
   * @param ctx the connection to the server.
   * @param oldDN the oldDN of the entry.
   * @param originalEntry the original entry.
   * @param newEntry the new entry.
   * @param originalMods the original modifications (these are required since
   * we might want to update them).
   * @throws CannotRenameException if we cannot perform the modification.
   * @throws NamingException if an error performing the modification occurs.
   */
  private void modifyAndRename(DirContext ctx, final DN oldDN,
  CustomSearchResult originalEntry, final Entry newEntry,
  final ArrayList<ModificationItem> originalMods)
  throws CannotRenameException, NamingException
  {
    RDN oldRDN = oldDN.rdn();
    RDN newRDN = newEntry.getName().rdn();

    if (rdnTypeChanged(oldRDN, newRDN)
        && userChangedObjectclass(originalMods)
        /* See if the original entry contains the new naming attribute(s) if it does we will be able
        to perform the renaming and then the modifications without problem */
        && !entryContainsRdnTypes(originalEntry, newRDN))
    {
      throw new CannotRenameException(AdminToolMessages.ERR_CANNOT_MODIFY_OBJECTCLASS_AND_RENAME.get());
    }

    SwingUtilities.invokeLater(new Runnable()
    {
      public void run()
      {
        printEquivalentRenameCommand(oldDN, newEntry.getName(), useAdminCtx);
        getProgressDialog().appendProgressHtml(
            Utilities.getProgressWithPoints(
                INFO_CTRL_PANEL_RENAMING_ENTRY.get(oldDN, newEntry.getName()),
                ColorAndFontConstants.progressFont));
      }
    });

    ctx.rename(Utilities.getJNDIName(oldDn.toString()),
        Utilities.getJNDIName(newEntry.getName().toString()));

    final TreePath[] newPath = {null};

    SwingUtilities.invokeLater(new Runnable()
    {
      public void run()
      {
        getProgressDialog().appendProgressHtml(
            Utilities.getProgressDone(ColorAndFontConstants.progressFont));
        getProgressDialog().appendProgressHtml("<br>");
        TreePath parentPath = controller.notifyEntryDeleted(
            controller.getNodeInfoFromPath(treePath));
        newPath[0] = controller.notifyEntryAdded(
            controller.getNodeInfoFromPath(parentPath),
            newEntry.getName().toString());
      }
    });


    ModificationItem[] mods = new ModificationItem[originalMods.size()];
    originalMods.toArray(mods);
    if (mods.length > 0)
    {
      SwingUtilities.invokeLater(new Runnable()
      {
        public void run()
        {
          DN dn = newEntry.getName();
          printEquivalentCommandToModify(dn, originalMods, useAdminCtx);
          getProgressDialog().appendProgressHtml(
              Utilities.getProgressWithPoints(
                  INFO_CTRL_PANEL_MODIFYING_ENTRY.get(dn),
                  ColorAndFontConstants.progressFont));
        }
      });

      ctx.modifyAttributes(Utilities.getJNDIName(newEntry.getName().toString()), mods);

      SwingUtilities.invokeLater(new Runnable()
      {
        public void run()
        {
          getProgressDialog().appendProgressHtml(
              Utilities.getProgressDone(ColorAndFontConstants.progressFont));
          if (newPath[0] != null)
          {
            controller.getTree().setSelectionPath(newPath[0]);
          }
        }
      });
    }
  }

  private boolean rdnTypeChanged(RDN oldRDN, RDN newRDN)
  {
    if (newRDN.getNumValues() != oldRDN.getNumValues())
    {
      return true;
    }

    for (int i = 0; i < newRDN.getNumValues(); i++)
    {
      if (!find(oldRDN, newRDN.getAttributeName(i)))
      {
        return true;
      }
    }
    return false;
  }

  private boolean find(RDN rdn, String attrName)
  {
    for (int j = 0; j < rdn.getNumValues(); j++)
    {
      if (attrName.equalsIgnoreCase(rdn.getAttributeName(j)))
      {
        return true;
      }
    }
    return false;
  }

  private boolean userChangedObjectclass(final ArrayList<ModificationItem> mods)
  {
    for (ModificationItem mod : mods)
    {
      if (ATTR_OBJECTCLASS.equalsIgnoreCase(mod.getAttribute().getID()))
      {
        return true;
      }
    }
    return false;
  }

  private boolean entryContainsRdnTypes(CustomSearchResult entry, RDN rdn)
  {
    for (int i = 0; i < rdn.getNumValues(); i++)
    {
      List<Object> values = entry.getAttributeValues(rdn.getAttributeName(i));
      if (values.isEmpty())
      {
        return false;
      }
    }
    return true;
  }

  /**
   * Gets the modifications to apply between two entries.
   * @param newEntry the new entry.
   * @param oldEntry the old entry.
   * @param info the ControlPanelInfo, used to retrieve the schema for instance.
   * @return the modifications to apply between two entries.
   */
  public static ArrayList<ModificationItem> getModifications(Entry newEntry,
      CustomSearchResult oldEntry, ControlPanelInfo info) {
    ArrayList<ModificationItem> modifications = new ArrayList<>();
    Schema schema = info.getServerDescriptor().getSchema();

    List<org.opends.server.types.Attribute> newAttrs = newEntry.getAttributes();
    newAttrs.add(newEntry.getObjectClassAttribute());
    for (org.opends.server.types.Attribute attr : newAttrs)
    {
      String attrName = attr.getNameWithOptions();
      if (!ViewEntryPanel.isEditable(attrName, schema))
      {
        continue;
      }
      List<ByteString> newValues = new ArrayList<>();
      Iterator<ByteString> it = attr.iterator();
      while (it.hasNext())
      {
        newValues.add(it.next());
      }
      List<Object> oldValues = oldEntry.getAttributeValues(attrName);

      boolean isAttributeInNewRdn = false;
      ByteString rdnValue = null;
      RDN rdn = newEntry.getName().rdn();
      for (int i=0; i<rdn.getNumValues() && !isAttributeInNewRdn; i++)
      {
        isAttributeInNewRdn =
          rdn.getAttributeName(i).equalsIgnoreCase(attrName);
        if (isAttributeInNewRdn)
        {
          rdnValue = rdn.getAttributeValue(i);
        }
      }

      /* Check the attributes of the old DN.  If we are renaming them they
       * will be deleted.  Check that they are on the new entry but not in
       * the new RDN. If it is the case we must add them after the renaming.
       */
      ByteString oldRdnValueToAdd = null;
      /* Check the value in the RDN that will be deleted.  If the value was
       * on the previous RDN but not in the new entry it will be deleted.  So
       * we must avoid to include it as a delete modification in the
       * modifications.
       */
      ByteString oldRdnValueDeleted = null;
      RDN oldRDN = null;
      try
      {
        oldRDN = DN.valueOf(oldEntry.getDN()).rdn();
      }
      catch (DirectoryException de)
      {
        throw new RuntimeException("Unexpected error parsing DN: "+
            oldEntry.getDN(), de);
      }
      for (int i=0; i<oldRDN.getNumValues(); i++)
      {
        if (oldRDN.getAttributeName(i).equalsIgnoreCase(attrName))
        {
          ByteString value = oldRDN.getAttributeValue(i);
          if (attr.contains(value))
          {
            if (rdnValue == null || !rdnValue.equals(value))
            {
              oldRdnValueToAdd = value;
            }
          }
          else
          {
            oldRdnValueDeleted = value;
          }
          break;
        }
      }
      if (oldValues == null)
      {
        Set<ByteString> vs = new HashSet<>(newValues);
        if (rdnValue != null)
        {
          vs.remove(rdnValue);
        }
        if (!vs.isEmpty())
        {
          modifications.add(new ModificationItem(
              DirContext.ADD_ATTRIBUTE,
              createAttribute(attrName, newValues)));
        }
      } else {
        List<ByteString> toDelete = getValuesToDelete(oldValues, newValues);
        if (oldRdnValueDeleted != null)
        {
          toDelete.remove(oldRdnValueDeleted);
        }
        List<ByteString> toAdd = getValuesToAdd(oldValues, newValues);
        if (oldRdnValueToAdd != null)
        {
          toAdd.add(oldRdnValueToAdd);
        }
        if (toDelete.size() + toAdd.size() >= newValues.size() &&
            !isAttributeInNewRdn)
        {
          modifications.add(new ModificationItem(
              DirContext.REPLACE_ATTRIBUTE,
              createAttribute(attrName, newValues)));
        }
        else
        {
          if (!toDelete.isEmpty())
          {
            modifications.add(new ModificationItem(
                DirContext.REMOVE_ATTRIBUTE,
                createAttribute(attrName, toDelete)));
          }
          if (!toAdd.isEmpty())
          {
            List<ByteString> vs = new ArrayList<>(toAdd);
            if (rdnValue != null)
            {
              vs.remove(rdnValue);
            }
            if (!vs.isEmpty())
            {
              modifications.add(new ModificationItem(
                  DirContext.ADD_ATTRIBUTE,
                  createAttribute(attrName, vs)));
            }
          }
        }
      }
    }

    /* Check if there are attributes to delete */
    for (String attrName : oldEntry.getAttributeNames())
    {
      if (!ViewEntryPanel.isEditable(attrName, schema))
      {
        continue;
      }
      List<Object> oldValues = oldEntry.getAttributeValues(attrName);
      String attrNoOptions =
        Utilities.getAttributeNameWithoutOptions(attrName).toLowerCase();

      List<org.opends.server.types.Attribute> attrs = newEntry.getAttribute(attrNoOptions);
      if (!find(attrs, attrName) && !oldValues.isEmpty())
      {
        modifications.add(new ModificationItem(
            DirContext.REMOVE_ATTRIBUTE,
            new BasicAttribute(attrName)));
      }
    }
    return modifications;
  }

  private static boolean find(List<org.opends.server.types.Attribute> attrs, String attrName)
  {
    if (attrs != null)
    {
      for (org.opends.server.types.Attribute attr : attrs)
      {
        if (attr.getNameWithOptions().equalsIgnoreCase(attrName))
        {
          return true;
        }
      }
    }
    return false;
  }

  /**
   * Creates a JNDI attribute using an attribute name and a set of values.
   * @param attrName the attribute name.
   * @param values the values.
   * @return a JNDI attribute using an attribute name and a set of values.
   */
  private static Attribute createAttribute(String attrName, List<ByteString> values) {
    Attribute attribute = new BasicAttribute(attrName);
    for (ByteString value : values)
    {
      attribute.add(value.toByteArray());
    }
    return attribute;
  }

  /**
   * Creates a ByteString for an attribute and a value (the one we got using JNDI).
   * @param value the value found using JNDI.
   * @return a ByteString object.
   */
  private static ByteString createAttributeValue(Object value)
  {
    if (value instanceof String)
    {
      return ByteString.valueOfUtf8((String) value);
    }
    else if (value instanceof byte[])
    {
      return ByteString.wrap((byte[]) value);
    }
    return ByteString.valueOfUtf8(String.valueOf(value));
  }

  /**
   * Returns the set of ByteString that must be deleted.
   * @param oldValues the old values of the entry.
   * @param newValues the new values of the entry.
   * @return the set of ByteString that must be deleted.
   */
  private static List<ByteString> getValuesToDelete(List<Object> oldValues,
      List<ByteString> newValues)
  {
    List<ByteString> valuesToDelete = new ArrayList<>();
    for (Object o : oldValues)
    {
      ByteString oldValue = createAttributeValue(o);
      if (!newValues.contains(oldValue))
      {
        valuesToDelete.add(oldValue);
      }
    }
    return valuesToDelete;
  }

  /**
   * Returns the set of ByteString that must be added.
   * @param oldValues the old values of the entry.
   * @param newValues the new values of the entry.
   * @return the set of ByteString that must be added.
   */
  private static List<ByteString> getValuesToAdd(List<Object> oldValues,
    List<ByteString> newValues)
  {
    List<ByteString> valuesToAdd = new ArrayList<>();
    for (ByteString newValue : newValues)
    {
      if (!contains(oldValues, newValue))
      {
        valuesToAdd.add(newValue);
      }
    }
    return valuesToAdd;
  }

  private static boolean contains(List<Object> oldValues, ByteString newValue)
  {
    for (Object o : oldValues)
    {
      if (createAttributeValue(o).equals(newValue))
      {
        return true;
      }
    }
    return false;
  }
}
