/*****************************************************************************
 * Open MCT, Copyright (c) 2014-2024, United States Government
 * as represented by the Administrator of the National Aeronautics and Space
 * Administration. All rights reserved.
 *
 * Open MCT is licensed 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.
 *
 * Open MCT includes source code licensed under additional open source
 * licenses. See the Open Source Licenses file (LICENSES.md) included with
 * this source code distribution or the Licensing information page available
 * at runtime from the About dialog for additional information.
 *****************************************************************************/

import { v4 as uuid } from 'uuid';

/**
 * This class encapsulates the process of  duplicating/copying a domain object
 * and all of its children.
 *
 * @param {DomainObject} domainObject The object to duplicate
 * @param {DomainObject} parent The new location of the cloned object tree
 * @param {src/plugins/duplicate.DuplicateService~filter} filter
 *        a function used to filter out objects from
 *        the cloning process
 * @constructor
 */
export default class DuplicateTask {
  constructor(openmct) {
    this.domainObject = undefined;
    this.parent = undefined;
    this.firstClone = undefined;
    this.filter = undefined;
    this.persisted = 0;
    this.clones = [];
    this.idMap = {};
    this.name = undefined;

    this.openmct = openmct;
  }

  changeName(name) {
    this.name = name;
  }

  /**
   * Execute the duplicate/copy task with the objects provided.
   * @returns {promise} Which will resolve with a clone of the object
   * once complete.
   */
  async duplicate(domainObject, parent, filter) {
    this.domainObject = domainObject;
    this.parent = parent;
    this.namespace = parent.identifier.namespace;
    this.filter = filter || this.isCreatable;

    await this.buildDuplicationPlan();
    await this.persistObjects();
    await this.addClonesToParent();

    return this.firstClone;
  }

  /**
   * Will build a graph of an object and all of its child objects in
   * memory
   * @private
   * @param domainObject The original object to be copied
   * @param parent The parent of the original object to be copied
   * @returns {Promise} resolved with an array of clones of the models
   * of the object tree being copied. Duplicating is done in a bottom-up
   * fashion, so that the last member in the array is a clone of the model
   * object being copied. The clones are all full composed with
   * references to their own children.
   */
  async buildDuplicationPlan() {
    let domainObjectClone = await this.duplicateObject(this.domainObject);
    if (domainObjectClone !== this.domainObject) {
      domainObjectClone.location = this.getKeyString(this.parent);
    }

    if (this.name) {
      domainObjectClone.name = this.name;
    }

    this.firstClone = domainObjectClone;

    return;
  }

  /**
   * Will persist a list of {@link objectClones}. It will persist all
   * simultaneously, irrespective of order in the list. This may
   * result in automatic request batching by the browser.
   */
  async persistObjects() {
    let initialCount = this.clones.length;
    let dialog = this.openmct.overlays.progressDialog({
      progressPerc: 0,
      message: `Duplicating ${initialCount} objects.`,
      iconClass: 'info',
      title: 'Duplicating'
    });

    let clonesDone = Promise.all(
      this.clones.map((clone) => {
        let percentPersisted = Math.ceil(100 * (++this.persisted / initialCount));
        let message = `Duplicating ${initialCount - this.persisted} objects.`;

        dialog.updateProgress(percentPersisted, message);

        return this.openmct.objects.save(clone);
      })
    );

    await clonesDone;

    dialog.dismiss();
    this.openmct.notifications.info(`Duplicated ${this.persisted} objects.`);

    return;
  }

  /**
   * Will add a list of clones to the specified parent's composition
   */
  async addClonesToParent() {
    let parentComposition = this.openmct.composition.get(this.parent);
    await parentComposition.load();
    parentComposition.add(this.firstClone);

    return;
  }

  /**
   * A recursive function that will perform a bottom-up duplicate of
   * the object tree with originalObject at the root. Recurses to
   * the farthest leaf, then works its way back up again,
   * cloning objects, and composing them with their child clones
   * as it goes
   * @private
   * @returns {DomainObject} If the type of the original object allows for
   * duplication, then a duplicate of the object, otherwise the object
   * itself (to allow linking to non duplicatable objects).
   */
  async duplicateObject(originalObject) {
    // Check if the creatable (or other passed in filter).
    if (this.filter(originalObject)) {
      let clone = this.cloneObjectModel(originalObject);
      let composeesCollection = this.openmct.composition.get(originalObject);
      let composees;

      if (composeesCollection) {
        composees = await composeesCollection.load();
      }

      return this.duplicateComposees(clone, composees);
    }

    // Not creatable, creating a link, no need to iterate children
    return originalObject;
  }

  /**
   * Given an array of objects composed by a parent, clone them, then
   * add them to the parent.
   * @private
   * @returns {*}
   */
  async duplicateComposees(clonedParent, composees = []) {
    let idMappings = [];
    let allComposeesDuplicated = composees.reduce(async (previousPromise, nextComposee) => {
      await previousPromise;

      let clonedComposee = await this.duplicateObject(nextComposee);

      if (clonedComposee) {
        idMappings.push({
          newId: clonedComposee.identifier,
          oldId: nextComposee.identifier
        });
        this.composeChild(clonedComposee, clonedParent, clonedComposee !== nextComposee);
      }

      return;
    }, Promise.resolve());

    await allComposeesDuplicated;

    clonedParent = this.rewriteIdentifiers(clonedParent, idMappings);
    this.clones.push(clonedParent);

    return clonedParent;
  }

  /**
   * Update identifiers in a cloned object model (or part of
   * a cloned object model) to reflect new identifiers after
   * duplicating.
   * @private
   */
  rewriteIdentifiers(clonedParent, childIdMappings) {
    for (let { newId, oldId } of childIdMappings) {
      let newIdKeyString = this.openmct.objects.makeKeyString(newId);
      let oldIdKeyString = this.openmct.objects.makeKeyString(oldId);

      // regex replace keystrings
      clonedParent = JSON.stringify(clonedParent).replace(
        new RegExp(oldIdKeyString, 'g'),
        newIdKeyString
      );

      // parse reviver to replace identifiers
      clonedParent = JSON.parse(clonedParent, (key, value) => {
        if (
          value !== null &&
          value !== undefined &&
          Object.prototype.hasOwnProperty.call(value, 'key') &&
          Object.prototype.hasOwnProperty.call(value, 'namespace') &&
          value.key === oldId.key &&
          value.namespace === oldId.namespace
        ) {
          return newId;
        } else {
          return value;
        }
      });
    }

    return clonedParent;
  }

  composeChild(child, parent, setLocation) {
    parent.composition.push(child.identifier);

    //If a location is not specified, set it.
    if (setLocation && child.location === undefined) {
      let parentKeyString = this.getKeyString(parent);
      child.location = parentKeyString;
    }
  }

  getTypeDefinition(domainObject, definition) {
    let typeDefinitions = this.openmct.types.get(domainObject.type).definition;

    return typeDefinitions[definition] || false;
  }

  cloneObjectModel(domainObject) {
    let clone = JSON.parse(JSON.stringify(domainObject));
    let identifier = {
      key: uuid(),
      namespace: this.namespace // set to NEW parent's namespace
    };

    if (clone.modified || clone.persisted || clone.location) {
      clone.modified = undefined;
      clone.persisted = undefined;
      clone.location = undefined;
      delete clone.modified;
      delete clone.persisted;
      delete clone.location;
    }

    if (clone.composition) {
      clone.composition = [];
    }

    clone.identifier = identifier;

    return clone;
  }

  getKeyString(domainObject) {
    return this.openmct.objects.makeKeyString(domainObject.identifier);
  }

  isCreatable(domainObject) {
    return this.getTypeDefinition(domainObject, 'creatable');
  }
}
