/*
 * 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
 * trunk/opends/resource/legal-notices/OpenDS.LICENSE
 * or https://OpenDS.dev.java.net/OpenDS.LICENSE.
 * 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
 * trunk/opends/resource/legal-notices/OpenDS.LICENSE.  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 2007-2008 Sun Microsystems, Inc.
 */

package org.opends.quicksetup.upgrader;

import org.opends.messages.Message;
import org.opends.messages.MessageBuilder;
import static org.opends.messages.QuickSetupMessages.*;

import org.opends.quicksetup.ApplicationException;
import org.opends.quicksetup.ReturnCode;
import org.opends.quicksetup.Installation;
import org.opends.quicksetup.UserInteraction;
import org.opends.quicksetup.Constants;

import org.opends.quicksetup.util.ExternalTools;
import org.opends.quicksetup.util.FileManager;
import org.opends.quicksetup.util.Utils;
import org.opends.quicksetup.util.OperationOutput;
import org.opends.quicksetup.util.InProcessServerController;
import org.opends.server.util.LDIFReader;
import org.opends.server.util.ChangeRecordEntry;
import org.opends.server.types.LDIFImportConfig;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * This class handles migration of both schema and configuration
 * for an installation that is in the process of being upgraded.
 *
 * This class is intented to be used in two differnt stages of the
 * upgrade process:  first, before installation's filesystem bits
 * have been upgraded, a call to both {@link #calculateConfigCustomizations()}
 * and {@link #calculateSchemaCustomizations()} should be made in
 * order to capture what changes will need to be made to the server
 * later on.  Then once the filesystem bit have been upgraded calls to
 * {@link #migrateConfiguration()} and {@link #migrateSchema()} are
 * made to actually perform the migration.
 */
public class MigrationManager {

  static private final Logger LOG =
          Logger.getLogger(MigrationManager.class.getName());

  /** Describes a particular component to be migrated. */
  private enum Component { SCHEMA, CONFIGURATION }

  private Installation installation;
  private File backupDir;
  private UserInteraction ui;
  private boolean isSchemaCustomized;
  private boolean mustRunDSJavaProperties;

  /**
   * Creates a new parameterized instance.
   * @param installation describing the file layout of the installation
   *        to be migrated
   * @param backupDir directory where diffs between the base configuration
   *        and schema are to be stored
   * @param ui user interaction allowing this class to prompt the user
   *        for feedback if necessary.  This object can be null in which
   *        case this class will throw an exception rather than prompt
   *        the user for more information if necessary.
   */
  public MigrationManager(Installation installation, File backupDir,
                          UserInteraction ui)
  {
    if (installation == null) {
      throw new NullPointerException("installation cannot be null");
    }
    if (backupDir == null) {
      throw new NullPointerException("backup directory cannot be null");
    }
    this.installation = installation;
    this.backupDir = backupDir;
    this.ui = ui;
  }

  /**
   * Diffs the current and base schema for the
   * <code>Installation</code> associated with this class and
   * generates a diff file that can later be used to migrate
   * an upgraded server.
   *
   * @throws ApplicationException if there is a problem performing
   *         the diff operation
   */
  public void calculateSchemaCustomizations() throws ApplicationException {
    isSchemaCustomized = false;
    if (installation.getStatus().schemaHasBeenModified()) {
      LOG.log(Level.INFO, "Schema contains customizations that will " +
              "be migrated");
      try {
        ldifDiff(installation.getBaseSchemaFile(),
                installation.getSchemaConcatFile(),
                getCustomSchemaDiffFile(), true);
        isSchemaCustomized = true;
      } catch (ApplicationException ae) {
        throw ae;
      } catch (Exception e) {
        throw ApplicationException.createFileSystemException(
                INFO_ERROR_DETERMINING_CUSTOM_SCHEMA.get(), e);
      }
    } else {
      LOG.log(Level.INFO, "No schema customizations to migrate");
    }
  }

  /**
   * Diffs the current and base configuration for the
   * <code>Installation</code> associated with this class and
   * generates a diff file that can later be used to migrate
   * an upgraded server.
   *
   * @throws ApplicationException if there is a problem performing
   *         the diff operation
   */
  public void calculateConfigCustomizations() throws ApplicationException {
    try {
      if (installation.getCurrentConfiguration().hasBeenModified()) {
        LOG.log(Level.INFO, "Configuration contains customizations that will " +
                "be migrated");
        try {
          ldifDiff(installation.getBaseConfigurationFile(),
                  installation.getCurrentConfigurationFile(),
                  getCustomConfigDiffFile(), false);
        } catch (ApplicationException ae) {
          throw ae;
        } catch (Exception e) {
          throw ApplicationException.createFileSystemException(
                  INFO_ERROR_DETERMINING_CUSTOM_CONFIG.get(), e);
        }
      } else {
        LOG.log(Level.INFO, "No configuration customizations to migrate");
      }
    } catch (IOException e) {
      throw ApplicationException.createFileSystemException(
              INFO_ERROR_DETERMINING_CUSTOM_CONFIG.get(), e);
    }
    mustRunDSJavaProperties = installation.getSetJavaHomeFile().exists();
  }

  /**
   * Migrates a configuration using the diff file generated by a call
   * to {@link MigrationManager#calculateConfigCustomizations()}.
   * @throws ApplicationException if there is an error migrating the
   *         configuration
   */
  public void migrateConfiguration() throws ApplicationException {
    try {
      File configDiff = getCustomConfigDiffFile();
      if (configDiff.exists()) {
        modify(configDiff, Component.CONFIGURATION);
      }
    } catch (ApplicationException ae) {
      throw ae;
    } catch (Exception e) {
      Message msg = INFO_ERROR_APPLYING_CUSTOM_CONFIG.get();
      LOG.log(Level.INFO, msg.toString(), e);
      throw new ApplicationException(
          ReturnCode.IMPORT_ERROR, msg, e);
    }
  }

  /**
   * Tells whether the ADS backend file must be migrated or not.
   * This method assumes that the ADS backend file in the original install
   * has not been overwritten.
   * @return <CODE>true</CODE> if the tools properties file must be migrated
   * and <CODE>false</CODE> otherwise.
   * @throws IOException if there is an error accessing the installation
   * information.
   */
  public boolean mustMigrateADS() throws IOException {
    return !installation.getADSBackendFile().exists();
  }

  /**
   * Migrates the ADS backend file.
   * @param newADSFile the new ADS backend file.
   * @throws ApplicationException if there is an error copying the new
   * ADS backend file.
   */
  public void migrateADS(File newADSFile) throws ApplicationException
  {
    FileManager fileManager = new FileManager();
    fileManager.copy(newADSFile, installation.getConfigurationDirectory(),
        true);
  }


  /**
   * Tells whether the tools properties file must be migrated or not.
   * This method assumes that the tools properties file in the original install
   * has not been overwritten.
   * @return <CODE>true</CODE> if the tools properties file must be migrated
   * and <CODE>false</CODE> otherwise.
   */
  public boolean mustMigrateToolProperties()
  {
   return !installation.getToolsPropertiesFile().exists();
  }

  /**
   * Tells whether a set-java-home file exists and so if we have to run
   * dsjavaproperties to regenerate it.
   * @return <CODE>true</CODE> if the set-java-home file exists and
   * <CODE>false</CODE> otherwise.
   */
  public boolean mustRunDSJavaProperties()
  {
    return mustRunDSJavaProperties;
  }

  /**
   * Migrates the tools properties file.
   * @param newPropertiesFile the new properties file.
   * @throws ApplicationException if there is an error copying the new
   * properties file.
   */
  public void migrateToolPropertiesFile(File newPropertiesFile)
  throws ApplicationException
  {
    FileManager fileManager = new FileManager();
    fileManager.copy(newPropertiesFile,
        installation.getConfigurationDirectory(), true);
  }

  /**
   * Migrates a schema using the diff file generated by a call
   * to {@link MigrationManager#calculateSchemaCustomizations()}.
   * @throws ApplicationException if there is an error migrating the
   *         schema
   */
  public void migrateSchema() throws ApplicationException {
    try {
      File schemaDiff = getCustomSchemaDiffFile();
      if (schemaDiff.exists()) {
        modify(schemaDiff, Component.SCHEMA);
      }
    } catch (ApplicationException ae) {
      throw ae;
    } catch (Exception e) {
      Message msg = INFO_ERROR_APPLYING_CUSTOM_SCHEMA.get();
      LOG.log(Level.INFO, msg.toString(), e);
      throw new ApplicationException(
          ReturnCode.IMPORT_ERROR, msg, e);
    }
  }

  /**
   * Returns a value to indicate whether the installation associated
   * with this class contains schema customizations.  A call to
   * {@link #calculateSchemaCustomizations()} must first be made in
   * order for this value to be valid.
   *
   * @return boolean where true indicates schema customization
   */
  public boolean isSchemaCustomized() {
    return isSchemaCustomized;
  }

  /**
   * Returns a value to indicate whether the installation associated
   * with this class contains configuration customizations.  A call to
   * {@link #calculateConfigCustomizations()} must first be made in
   * order for this value to be valid.
   *
   * @return boolean where true indicates configuration customization
   * @throws java.io.IOException if there was a problem reading the
   *         current configuration file.
   */
  public boolean isConfigurationCustomized() throws IOException {
    return installation.getCurrentConfiguration().hasBeenModified();
  }


  /**
   * Applies modifications contained in an LDIF file to the server.
   *
   * @param ldifFile LDIF file to apply
   * @param component being modified
   * @throws Exception if something goes wrong. This may be an
   *         ApplicationException if the user cancels the application
   *         altogether or an LDIFException or IOException should other
   *         problems occur.
   */
  private void modify(File ldifFile, Component component)
          throws Exception {
    InProcessServerController ipsc =
            new InProcessServerController(installation);
    LDIFImportConfig importCfg =
            new LDIFImportConfig(
                    Utils.getPath(ldifFile));
    LDIFReader ldifReader =
            new LDIFReader(importCfg);
    ArrayList<ChangeRecordEntry> remainingChanges =
      new ArrayList<ChangeRecordEntry>();
    ChangeRecordEntry o = ldifReader.readChangeRecord(false);
    while (o != null)
    {
      remainingChanges.add(o);
      o = ldifReader.readChangeRecord(false);
    }
    ArrayList<ChangeRecordEntry> appliedChanges =
      new ArrayList<ChangeRecordEntry>();
    // In the particular case of the schema, the order on which the
    // the modifications are applied is important.  That is why we retry
    // until no modification works.
    while (remainingChanges.size() > 0)
    {
      Exception firstException = null;
      appliedChanges.clear();
      for (ChangeRecordEntry cre : remainingChanges)
      {
        try {
          ipsc.modify(cre);
          appliedChanges.add(cre);
        } catch (Exception e) {
          if (firstException == null)
          {
            firstException = e;
          }
        }
      }
      remainingChanges.removeAll(appliedChanges);
      if ((firstException != null) && (appliedChanges.size() == 0))
      {
        if (ui != null) {
          Message cancel = INFO_CANCEL_BUTTON_LABEL.get();
          Message cont = INFO_CONTINUE_BUTTON_LABEL.get();
          Message retry = INFO_RETRY_BUTTON_LABEL.get();
          // Now changeList contains the changes that could not be applied:
          // just ask to confirm about the first element.
          Object r = ui.confirm(
                  getModificationErrorSummary(component),
                  getModificationErrorMessage(remainingChanges.get(0)),
                  Message.raw(firstException.getLocalizedMessage()),
                  INFO_ERROR_UPGRADE_MIGRATION.get(),
                  UserInteraction.MessageType.ERROR,
                  new Message[]{cancel, cont, retry},
                  cancel, null);
          if (cont.equals(r)) {
            remainingChanges.remove(0);
          } else if (retry.equals(r)) {
            // do nothing; will retry;
          } else {
            throw new ApplicationException(
                ReturnCode.CANCELLED,
                INFO_UPGRADE_CANCELED.get(), firstException);
          }
        } else {
          throw firstException;
        }
      }
    }
  }

  private Message getModificationErrorSummary(Component c) {
    Message summary;
    switch(c) {
      case SCHEMA:
        summary = INFO_ERROR_UPGRADE_MIGRATION_SCHEMA.get();
        break;
      case CONFIGURATION:
        summary = INFO_ERROR_UPGRADE_MIGRATION_CONFIG.get();
        break;
      default:
        summary = Message.EMPTY;
    }
    return summary;
  }

  private Message getModificationErrorMessage(ChangeRecordEntry cre) {
    MessageBuilder msg = new MessageBuilder();
    if (Utils.isCli())
    {
      msg.append(Constants.LINE_SEPARATOR);
    }
    else
    {
      msg.append(Constants.HTML_LINE_BREAK);
    }
    if (cre != null) {
      switch (cre.getChangeOperationType()) {
        case MODIFY:
          if (Utils.isCli())
          {
            msg.append(INFO_ERROR_UPGRADE_MIGRATION_MODIFY_CLI.get(
                cre.getDN().toNormalizedString()));
          }
          else
          {
            msg.append(INFO_ERROR_UPGRADE_MIGRATION_MODIFY.get(
                cre.getDN().toNormalizedString()));
          }
          break;
        case ADD:
          if (Utils.isCli())
          {
            msg.append(INFO_ERROR_UPGRADE_MIGRATION_ADD_CLI.get(
                cre.getDN().toNormalizedString()));
          }
          else
          {
            msg.append(INFO_ERROR_UPGRADE_MIGRATION_ADD.get(
                cre.getDN().toNormalizedString()));
          }
          break;
        case DELETE:
          if (Utils.isCli())
          {
            msg.append(INFO_ERROR_UPGRADE_MIGRATION_DELETE_CLI.get(
                cre.getDN().toNormalizedString()));
          }
          else
          {
            msg.append(INFO_ERROR_UPGRADE_MIGRATION_DELETE.get(
                cre.getDN().toNormalizedString()));
          }
          break;
        default:
          LOG.log(Level.INFO, "Unexpected change operation type " +
              cre.getChangeOperationType());
        if (Utils.isCli())
        {
          msg.append(INFO_ERROR_UPGRADE_MIGRATION_UNEXPECTED_CLI.get(
              cre.getDN().toNormalizedString()));
        }
        else
        {
          msg.append(INFO_ERROR_UPGRADE_MIGRATION_UNEXPECTED.get(
              cre.getDN().toNormalizedString()));
        }
        break;
      }
    }
    if (Utils.isCli())
    {
      msg.append(Constants.LINE_SEPARATOR);
      msg.append(Constants.LINE_SEPARATOR);
      msg.append(INFO_ERROR_UPGRADE_MIGRATION_NOTE_CLI.get(
          Utils.getPath(backupDir)));
    }
    else
    {
      msg.append(Constants.HTML_LINE_BREAK);
      msg.append(Constants.HTML_LINE_BREAK);
      msg.append(INFO_ERROR_UPGRADE_MIGRATION_NOTE.get(
          Utils.getPath(backupDir)));
    }
    return msg.toMessage();
  }

  private void ldifDiff(File source, File target, File output,
      boolean splitMods)
          throws ApplicationException, IOException, InterruptedException {
    ExternalTools et = new ExternalTools(installation);
    String[] args;
    if (splitMods)
    {
      args = new String[]{
          "-o", Utils.getPath(output),
          "-O",
          "-S"
      };
    }
    else
    {
      args = new String[]{
          "-o", Utils.getPath(output),
          "-O"
      };
    }
    OperationOutput oo = et.ldifDiff(source, target, args);
    int ret = oo.getReturnCode();
    if (ret != 0) {
      throw new ApplicationException(
          ReturnCode.TOOL_ERROR,
              INFO_ERROR_LDIF_DIFF_TOOL_RETURN_CODE.get(Integer.toString(ret)),
              null);
    }
  }

  private File getCustomConfigDiffFile() throws IOException {
    return new File(backupDir, "config.custom.diff");
  }

  private File getCustomSchemaDiffFile() throws IOException {
    return new File(backupDir, "schema.custom.diff");
  }

  private File getCustomADSDiffFile() throws IOException {
    return new File(backupDir, "admin-backend.custom.diff");
  }
}
