/**
 * Manage placeable linking to one another using `links` flag.
 */

import { libWrapper } from '../libs/shim/shim.js';
import { pickerSelectMultiLayerDocuments, updateEmbeddedDocumentsViaGM } from '../utils.js';
import { getDataBounds } from '../presets/utils.js';
import { LINKER_DOC_COLORS, MODULE_ID, SUPPORTED_PLACEABLES } from '../constants.js';
import { DataTransformer } from '../data/transformer.js';

const PROCESSED_UPDATES = new Map();
export const LINK_TYPES = {
  TWO_WAY: 0,
  RECEIVE: 1,
  SEND: 2,
};

export function registerLinkerHooks() {
  SUPPORTED_PLACEABLES.forEach((name) => {
    Hooks.on(`preUpdate${name}`, preUpdate);
    Hooks.on(`update${name}`, update);
    Hooks.on(`delete${name}`, _delete);
  });

  // UNDO linked document delete operation
  libWrapper.register(
    MODULE_ID,
    'foundry.canvas.layers.PlaceablesLayer.prototype.undoHistory',
    async function (wrapped, ...args) {
      const type = this.history[this.history.length - 1]?.type;
      const undone = await wrapped(...args);
      if (type === 'delete' && undone?.length) {
        for (const document of undone) {
          const event = LinkerAPI.history.find((h) => h.id === document.id);

          if (event) {
            event.data.forEach((data, documentName) => {
              canvas.scene.createEmbeddedDocuments(documentName, data, { isUndo: true, keepId: true });
            });
            LinkerAPI.history = LinkerAPI.history.filter((h) => h.id !== document.id);
          }
        }
      }
      return undone;
    },
    'WRAPPER'
  );
}

function processLinks(transform, origin, links, scene, docUpdates, processedLinks, sourceId) {
  SUPPORTED_PLACEABLES.forEach((documentName) => {
    const linked = scene
      .getEmbeddedCollection(documentName)
      .filter(
        (t) =>
          t.flags[MODULE_ID]?.links?.some((l1) => l1.type < 2 && links.find((l2) => l2.id === l1.id)) &&
          t.id !== sourceId
      );

    if (linked.length) {
      const updates = [];
      for (const d of linked) {
        const data = d._source;
        let update = foundry.utils.deepClone(data);

        DataTransformer.apply(documentName, update, origin, transform);
        update = foundry.utils.diffObject(data, update);

        update._id = d.id;
        updates.push(update);

        // Check if the document has unprocessed links and if so chain the update
        const dLinks = d.flags[MODULE_ID].links.filter(
          (l) => !(l.type === LINK_TYPES.RECEIVE || processedLinks.has(l.id))
        );
        if (dLinks.length) {
          dLinks.forEach((l) => processedLinks.add(l.id));
          processLinks(transform, origin, dLinks, scene, docUpdates, processedLinks, d.id);
        }
      }

      docUpdates.set(documentName, (docUpdates.get(documentName) ?? []).concat(updates));
    }
  });
}

class PromiseQueue {
  queue = Promise.resolve();

  add(operation) {
    this.queue = this.queue.then(operation).catch(() => {});
  }
}

const updateQueue = new PromiseQueue();

function preUpdate(document, change, options, userId) {
  if (game.user.id !== userId || options.ignoreLinks) return true;

  // If the document does not contain links do nothing
  let links = document.flags[MODULE_ID]?.links?.filter((l) => l.type !== LINK_TYPES.RECEIVE);
  if (!links?.length) return true;

  const positionUpdate =
    change.hasOwnProperty('x') ||
    change.hasOwnProperty('y') ||
    change.hasOwnProperty('shapes') ||
    change.hasOwnProperty('c') ||
    change.hasOwnProperty('elevation');
  const rotationUpdate =
    change.hasOwnProperty('rotation') || change.hasOwnProperty('direction') || options.hasOwnProperty('meRotation');
  if (!(positionUpdate || rotationUpdate)) return true;

  // Special handling for walls.
  // We do not want to perform linked placeable translation if only a single wall segment is moved.
  if (change.c) {
    if (
      (change.c[0] === document.c[0] && change.c[1] === document.c[1]) ||
      (change.c[2] === document.c[2] && change.c[3] === document.c[3])
    ) {
      return true;
    }
  }

  // If alt is held during during the update, we want to ignore links
  if (game.keyboard.isModifierActive(foundry.helpers.interaction.KeyboardManager.MODIFIER_KEYS.ALT)) {
    return true;
  }

  // If an update occurred at the same time (likely same drag, or mass update) we need to check whether
  // this update has unique links which need to be processed
  const puLinks = PROCESSED_UPDATES.get(options.modifiedTime);
  if (puLinks) {
    links = links.filter((l) => !puLinks.some((l2) => l2.id === l.id));
    if (!links.length) return false;
    links.forEach((l) => puLinks.push(l));
  } else {
    PROCESSED_UPDATES.set(options.modifiedTime, [...links]);
    setTimeout(() => PROCESSED_UPDATES.delete(options.modifiedTime), 2000);
  }

  foundry.utils.setProperty(options, `links.${document.id}`, links);
  foundry.utils.setProperty(options, `linkSources.${document.id}`, document.toObject());
  return true;
}

async function update(document, change, options, userId) {
  if (!options.links?.[document.id] || options.ignoreLinks || game.user.id !== userId) return true;

  const positionUpdate =
    change.hasOwnProperty('x') ||
    change.hasOwnProperty('y') ||
    change.hasOwnProperty('shapes') ||
    change.hasOwnProperty('c') ||
    change.hasOwnProperty('elevation');
  const rotationUpdate =
    change.hasOwnProperty('rotation') || change.hasOwnProperty('direction') || options.hasOwnProperty('meRotation');
  if (!(positionUpdate || rotationUpdate)) return true;

  const previousSource = foundry.utils.deepClone(options.linkSources[document.id]);
  foundry.utils.mergeObject(options.linkSources[document.id], change);

  const scene = document.parent;
  const { transform, origin } = calculateTransform(
    document.documentName,
    document.toObject(),
    previousSource,
    change,
    options
  );
  const links = options.links[document.id];

  updateQueue.add(async () => {
    const docUpdates = new Map();
    processLinks(transform, origin, links, scene, docUpdates, new Set(links.map((l) => l.id)), document.id);

    for (const [documentName, updates] of docUpdates.entries()) {
      const options = { ignoreLinks: true, animate: false };
      await updateEmbeddedDocumentsViaGM(documentName, updates, options, scene);
    }
  });
}

function calculateTransform(documentName, currentSource, previousSource, change, options) {
  let transform;

  if (change.hasOwnProperty('shapes') || change.hasOwnProperty('c')) {
    if (options.hasOwnProperty('meRotation')) {
      transform = { x: 0, y: 0 };
    } else {
      const previousBounds = getDataBounds(documentName, previousSource);
      const currentBounds = getDataBounds(documentName, currentSource);

      transform = {
        x: currentBounds.x1 - previousBounds.x1,
        y: currentBounds.y1 - previousBounds.y1,
      };
    }
  } else {
    transform = {
      x: currentSource.x - previousSource.x,
      y: currentSource.y - previousSource.y,
    };
  }

  const origin = { x: 0, y: 0 };

  // Calculate rotation delta
  let dRotation;
  if (options.hasOwnProperty('meRotation')) dRotation = options.meRotation;
  else if (currentSource.hasOwnProperty('rotation'))
    dRotation = (currentSource.rotation - previousSource.rotation) % 360;
  else if (currentSource.hasOwnProperty('direction'))
    dRotation = (currentSource.direction - previousSource.direction) % 360;

  if (dRotation != null) {
    transform.rotation = dRotation;

    const { x1, y1, x2, y2 } = getDataBounds(documentName, currentSource);

    origin.x = x1 + (x2 - x1) / 2;
    origin.y = y1 + (y2 - y1) / 2;
  }

  if (currentSource.hasOwnProperty('elevation')) {
    let dElevation;
    if (Number.isNumeric(currentSource.elevation)) {
      dElevation = currentSource.elevation - previousSource.elevation;
    } else {
      dElevation = (currentSource.elevation.bottom ?? 0) - (previousSource.elevation.bottom ?? 0);
    }
    if (dElevation != 0) transform.z = dElevation;
  }

  return { transform, origin };
}

/**
 * Cascade delete of a placeable to other linked placeables while recording their history
 * for an undo operation
 */
function _delete(document, options, userId) {
  if (game.user.id !== userId || options.linkerDelete) return;

  const linked = LinkerAPI.getHardLinkedDocuments(document);
  if (!linked.size) return;

  // Delete linked
  const toDelete = new Map();
  linked.forEach((d) => {
    if (!toDelete.get(d.documentName)) toDelete.set(d.documentName, [d.toObject()]);
    else toDelete.get(d.documentName).push(d.toObject());
  });

  const scene = document.parent;
  toDelete.forEach((data, documentName) => {
    scene.deleteEmbeddedDocuments(
      documentName,
      data.map((d) => d._id),
      { linkerDelete: true, isUndo: true } // Hack to prevent history tracking
    );
  });

  // Track history
  LinkerAPI.history.push({ id: document.id, data: toDelete });
  if (LinkerAPI.history.length > 10) LinkerAPI.history.shift();
}

export class LinkerAPI {
  /**
   * Open Linker Menu window
   * @returns
   */
  static async openMenu() {
    const module = await import('./menu.js');
    return module.openLinkerMenu();
  }

  static async smartLink({ multiLayer = false } = {}) {
    let selected;
    if (multiLayer) selected = await pickerSelectMultiLayerDocuments();
    else selected = this._getSelected().map((p) => p.document);

    if (!selected.length) return;

    if (!this._smartLink) {
      let link;
      for (const d of selected) {
        link = (this.getLinks(d) ?? []).find((l) => l.type === LINK_TYPES.TWO_WAY);
        if (link) break;
      }
      if (!link) link = { id: foundry.utils.randomID(), type: LINK_TYPES.TWO_WAY, label: 'S_LINK' };
      this._smartLink = link;

      // Open menu window
      const module = await import('./smartMenu.js');
      module.openSmartLinkMenu(selected[0]);
    }

    const { id, type, label } = this._smartLink;
    let numUpdates = 0;
    for (const d of selected) {
      if (await this.addLink(d, id, type, label)) numUpdates++;
    }
    if (numUpdates) {
      ui.notifications.info(`Mass Edit: ${numUpdates} documents have been linked.`);
    }
  }

  /**
   * Links provided documents/placeables using an already existing or otherwise an automatically
   * generated link.
   * @param {*} documents
   * @returns
   */
  static async link(documents) {
    if (!documents?.length || documents.length === 1) return;
    documents = documents.map((d) => d.document ?? d);

    let link;
    for (const d of documents) {
      link = (this.getLinks(d) ?? []).find((l) => l.type === LINK_TYPES.TWO_WAY);
      if (link) break;
    }
    if (!link) link = { id: foundry.utils.randomID(), type: LINK_TYPES.TWO_WAY, label: 'A_LINK' };

    const { id, type, label } = link;
    for (const d of documents) {
      await this.addLink(d, id, type, label);
    }
  }

  static getLinks(document) {
    document = document.document ?? document;
    return document.flags[MODULE_ID]?.links;
  }

  /**
   * Retrieve all linked embedded documents
   * @param {CanvasDocumentMixin|Array<CanvasDocumentMixin>} documents embedded document/s
   * @returns {Set<CanvasDocumentMixin>}
   */
  static getLinkedDocuments(documents, { hardLinked = false } = {}) {
    if (hardLinked) return this.getHardLinkedDocuments(documents);
    if (!Array.isArray(documents)) documents = [documents];
    documents = documents.map((d) => d.document ?? d);

    const allLinked = new Set();
    documents.forEach((document) => this._findLinked(document, allLinked));
    documents.forEach((document) => allLinked.delete(document));

    return allLinked;
  }

  /**
   * Retrieve TWO_WAY and SEND linked embedded documents
   * @param {Array<CanvasDocumentMixin>} documents
   * @returns
   */
  static getHardLinkedDocuments(documents, array = false) {
    if (!Array.isArray(documents)) documents = [documents];

    const allLinked = new Set();
    documents.forEach((document) => this._findHardLinked(document, allLinked));
    documents.forEach((document) => allLinked.delete(document));

    return array ? Array.from(allLinked) : allLinked;
  }

  /**
   * Returns true if the placeable has the provided linkId
   * @param {Placeable} placeable
   * @param {String} linkId
   * @returns
   */
  static hasLink(placeable, linkId) {
    const document = placeable.document ?? placeable;
    return Boolean(document.flags[MODULE_ID]?.links?.find((l) => l.id === linkId));
  }

  /**
   * Returns true if the two placeables share a link
   * @param {*} placeable1
   * @param {*} placeable2
   * @returns
   */
  static areLinked(placeable1, placeable2) {
    const links1 = (placeable1.document ?? placeable1).flags[MODULE_ID]?.links;
    const links2 = (placeable2.document ?? placeable2).flags[MODULE_ID]?.links;

    if (!(links1 && links2)) return false;
    return links1.some((l1) => links2.find((l2) => l1.id === l2.id));
  }

  /**
   * Add a link to the provided placeable
   * @param {Placeable} placeable
   * @param {String} linkId
   * @param {String} type LINK_TYPES
   * @param {String} label hover text displayed within the Linker Menu
   * @returns
   */
  static async addLink(placeable, linkId, type = LINK_TYPES.TWO_WAY, label = null) {
    if (!Object.values(LINK_TYPES).includes(type)) throw Error(`Invalid link type: ${type}`);

    const document = placeable.document ?? placeable;
    const links = document.flags[MODULE_ID]?.links ?? [];

    let link = links.find((l) => l.id === linkId);
    if (!link) {
      link = { id: linkId, type };
      if (label) link.label = label;
      links.push(link);
    } else if (link.type !== type || (label && link.label !== label)) {
      link.type = type;
      if (label) link.label = label;
    } else {
      return false;
    }

    await document.setFlag(MODULE_ID, 'links', links);
    Hooks.call(`${MODULE_ID}.addLink`, document.documentName, document.id, linkId, type, label);

    return true;
  }

  /**
   * Remove link from the provided placeable
   * @param {Placeable} placeable
   * @param {String} linkId
   */
  static async removeLink(placeable, linkId) {
    const document = placeable.document ?? placeable;

    let links = document.flags[MODULE_ID]?.links;
    if (links) {
      links = links.filter((l) => l.id !== linkId);
      if (links.length) await document.setFlag(MODULE_ID, 'links', links);
      else await document.unsetFlag(MODULE_ID, 'links');
      Hooks.call(`${MODULE_ID}.removeLink`, document.id, linkId);
    }
  }

  /**
   * Remove all links from the given placeable/document
   * @param {*} documents
   */
  static async removeLinks(documents) {
    if (!Array.isArray(documents)) documents = [documents];
    documents = documents.map((d) => d.document ?? d);

    let removed = false;
    for (const document of documents) {
      if (document.flags[MODULE_ID]?.links) {
        await document.unsetFlag(MODULE_ID, 'links');
        Hooks.call(`${MODULE_ID}.removeNode`, document.id);
        removed = true;
      }
    }
    return removed;
  }

  /**
   * Remove all links from selected placeables
   */
  static async removeLinksFromSelected({ multiLayer = false, notification = false } = {}) {
    let selected;
    if (multiLayer) selected = await pickerSelectMultiLayerDocuments();
    else selected = LinkerAPI._getSelected();

    let numRemoved = 0;
    for (const s of selected) {
      if (await LinkerAPI.removeLinks(s)) numRemoved++;
    }
    if (notification && numRemoved) ui.notifications.info(`Mass Edit: Links removed from ${numRemoved} documents.`);

    // Inform Smart Link menu of removed links
    Object.values(ui.windows)
      .find((w) => w.unlink)
      ?.unlink(selected);
  }

  static history = [];

  /**
   * Remove all links on the current scene.
   * @returns
   */
  static removeAllLinksOnCurrentScene() {
    const scene = canvas.scene;
    if (!scene) return;

    SUPPORTED_PLACEABLES.forEach((documentName) => {
      const updates = [];
      scene.getEmbeddedCollection(documentName).forEach((d) => {
        if (d.flags[MODULE_ID]?.links) {
          updates.push({ _id: d.id, [`flags.${MODULE_ID}.-=links`]: null });
        }
      });
      if (updates.length) scene.updateEmbeddedDocuments(documentName, updates);
    });
  }

  /**
   * Update linkId on the current scene with the provided label
   * @param {String} linkId
   * @param {String} label
   * @returns
   */
  static updateLinkLabelOnCurrentScene(linkId, label) {
    if (!linkId) return;

    const scene = canvas.scene;
    if (!scene) return;

    let updated = false;
    SUPPORTED_PLACEABLES.forEach((documentName) => {
      const updates = [];
      scene.getEmbeddedCollection(documentName).forEach((d) => {
        const link = d.flags[MODULE_ID]?.links?.find((l) => l.id === linkId);
        if (link && link.label != label) {
          if (!label) delete link.label;
          else link.label = label;

          updates.push({ _id: d.id, [`flags.${MODULE_ID}.links`]: d.flags[MODULE_ID]?.links });
        }
      });
      if (updates.length) {
        scene.updateEmbeddedDocuments(documentName, updates);
        updated = true;
      }
    });

    if (updated) {
      Hooks.call(`${MODULE_ID}.linkLabelChange`, linkId, label);
    }
  }

  /**
   * Remove a link from all placeables on the current scene
   * @param {String} linkId
   * @returns
   */
  static removeLinkFromScene(linkId) {
    const scene = canvas.scene;
    if (!scene || !linkId) return;

    SUPPORTED_PLACEABLES.forEach((documentName) => {
      const updates = [];
      scene.getEmbeddedCollection(documentName).forEach((d) => {
        let links = d.flags[MODULE_ID]?.links;
        if (links) {
          let fLinks = links.filter((l) => l.id !== linkId);
          if (fLinks.length !== links.length) {
            updates.push({ _id: d.id, [`flags.${MODULE_ID}.links`]: fLinks });
          }
        }
      });
      if (updates.length) scene.updateEmbeddedDocuments(documentName, updates);
    });

    Hooks.call(`${MODULE_ID}.removeNode`, linkId);
  }

  /**
   * Private Utils
   */

  /**
   * Returns all selected placeables
   * @returns
   */
  static _getSelected() {
    const activeLayer = canvas.activeLayer;
    if (!SUPPORTED_PLACEABLES.includes(activeLayer.options.objectClass.embeddedName)) return [];
    return [...canvas.activeLayer.controlled];
  }

  /**
   * Returns all documents on the current scene matching the provided linkId and type
   * @param {String} linkId
   * @param {LINK_TYPES|null} type
   * @returns
   */
  static _getLinkedDocumentsUsingLink(linkId, type = null) {
    if (type != null && !Object.values(LINK_TYPES).includes(type)) throw Error(`Invalid link type: ${type}`);
    const allLinked = new Set();
    SUPPORTED_PLACEABLES.forEach((documentName) => {
      canvas.scene.getEmbeddedCollection(documentName).forEach((d) => {
        if (d.flags[MODULE_ID]?.links?.some((l) => l.id === linkId && (type == null || l.type === type))) {
          allLinked.add(d);
        }
      });
    });
    return allLinked;
  }

  /**
   * Utility for LinkerAPI.getLinkedDocuments
   * @param {*} document
   * @param {*} allLinked
   * @param {*} processedLinks
   * @returns
   */
  static _findLinked(document, allLinked, processedLinks = new Set()) {
    allLinked.add(document);

    const links = document.flags[MODULE_ID]?.links?.filter((l) => !processedLinks.has(l.id)).map((l) => l.id);
    if (!links?.length) return allLinked;

    links.forEach((l) => processedLinks.add(l));

    SUPPORTED_PLACEABLES.forEach((documentName) => {
      const linked = canvas.scene
        .getEmbeddedCollection(documentName)
        .filter((t) => t.flags[MODULE_ID]?.links?.some((l1) => links.find((l2) => l2 === l1.id)));

      for (const d of linked) {
        this._findLinked(d, allLinked, processedLinks);
      }
    });

    return allLinked;
  }

  /**
   * Utility for LinkerAPI.getHardLinkedDocuments
   * @param {*} document
   * @param {*} allLinked
   * @param {*} processedLinks
   * @returns
   */
  static _findHardLinked(document, allLinked, processedLinks = new Set()) {
    allLinked.add(document);

    const links = document.flags[MODULE_ID]?.links
      ?.filter((l) => l.type !== LINK_TYPES.RECEIVE && !processedLinks.has(l.id))
      .map((l) => l.id);
    if (!links?.length) return allLinked;

    links.forEach((l) => processedLinks.add(l));

    SUPPORTED_PLACEABLES.forEach((documentName) => {
      const linked = document.parent
        .getEmbeddedCollection(documentName)
        .filter((t) =>
          t.flags[MODULE_ID]?.links?.some((l1) => links.find((l2) => l2 === l1.id && l1.type !== LINK_TYPES.SEND))
        );

      for (const d of linked) {
        this._findHardLinked(d, allLinked, processedLinks);
      }
    });

    return allLinked;
  }

  static _highlightDocuments(docs) {
    const dg = canvas.controls.debug;
    dg.clear();

    const width = 8;
    const alpha = 1;
    docs.forEach((d) => {
      let bounds = d.object.bounds;

      dg.lineStyle(width + 2, 0, alpha, 0.5);
      dg.drawRect(bounds.x, bounds.y, bounds.width, bounds.height);

      dg.lineStyle(width, LINKER_DOC_COLORS[d.documentName], alpha, 0.5);
      dg.drawRect(bounds.x, bounds.y, bounds.width, bounds.height);
    });
  }

  static _clearHighlight() {
    canvas.controls.debug.clear();
  }
}
